From e034a2e54efaa1543fdf4a8d24f25da307cb287f Mon Sep 17 00:00:00 2001 From: yanghao05 Date: Tue, 5 Aug 2025 17:26:59 +0800 Subject: [PATCH] feat: init project --- .air.toml | 41 +++ .dockerignore | 55 +++ .gitignore | 1 + Dockerfile | 57 +++ Makefile | 240 ++++++++++++ TODO.md | 155 ++++++++ cmd/root.go | 51 +++ cmd/server/main.go | 137 +++++++ config/config.example.yaml | 147 ++++++++ config/config.yaml | 230 ++++++++++++ config/schema.example.sql | 346 ++++++++++++++++++ config/schema.sql | 346 ++++++++++++++++++ data/app.db | Bin 0 -> 73728 bytes database_render/.gitignore | 27 ++ README.md => database_render/README.md | 0 docker-compose.yml | 75 ++++ go.mod | 56 +++ go.sum | 138 +++++++ internal/config/config.go | 236 ++++++++++++ internal/database/connection.go | 482 +++++++++++++++++++++++++ internal/handler/data_handler.go | 161 +++++++++ internal/model/table_config.go | 69 ++++ internal/repository/data_repository.go | 192 ++++++++++ internal/service/data_service.go | 204 +++++++++++ internal/template/renderer.go | 250 +++++++++++++ main.go | 10 + requirements.md | 470 ++++++++++++++++++++++++ scripts/build.sh | 345 ++++++++++++++++++ scripts/dev.sh | 360 ++++++++++++++++++ web/templates/list.html | 278 ++++++++++++++ 30 files changed, 5159 insertions(+) create mode 100644 .air.toml create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 TODO.md create mode 100644 cmd/root.go create mode 100644 cmd/server/main.go create mode 100644 config/config.example.yaml create mode 100644 config/config.yaml create mode 100644 config/schema.example.sql create mode 100644 config/schema.sql create mode 100644 data/app.db create mode 100644 database_render/.gitignore rename README.md => database_render/README.md (100%) create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/database/connection.go create mode 100644 internal/handler/data_handler.go create mode 100644 internal/model/table_config.go create mode 100644 internal/repository/data_repository.go create mode 100644 internal/service/data_service.go create mode 100644 internal/template/renderer.go create mode 100644 main.go create mode 100644 requirements.md create mode 100755 scripts/build.sh create mode 100755 scripts/dev.sh create mode 100644 web/templates/list.html diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..6714fef --- /dev/null +++ b/.air.toml @@ -0,0 +1,41 @@ +# Config file for [Air](https://github.com/cosmtrek/air) in TOML format + +# Working directory +root = "." +tmp_dir = "tmp" + +[build] + # Just plain old shell command. You could use `make` as well. + args_bin = [] + bin = "./bin/database-render" + cmd = "make build" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "node_modules", "web/static", "dist"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html", "yaml", "yml", "json"] + kill_delay = "0s" + log = "build-errors.log" + send_interrupt = false + stop_on_root = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4c6cefb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,55 @@ +# Build artifacts +bin/ +dist/ +tmp/ + +# Dependencies +vendor/ +node_modules/ + +# Logs +*.log +logs/ + +# Environment files +.env +.env.local +.env.*.local + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Git +.git/ +.gitignore + +# Documentation +README.md +docs/ + +# Test files +*_test.go +.coverage/ +coverage.out +coverage.html + +# Temporary files +*.tmp +*.temp + +# Database files (for development) +data/*.db +data/*.sqlite +data/*.sqlite3 + +# Configuration (keep example configs) +config/config.yaml +config/local.yaml \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5b90e79..20af84e 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ go.work.sum # env file .env +bin/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1c9ce7d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +# Multi-stage build for Database Render Application +FROM golang:1.21-alpine AS builder + +# Set working directory +WORKDIR /app + +# Install build dependencies +RUN apk add --no-cache git ca-certificates tzdata + +# Copy go mod files +COPY go.mod go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o database-render ./cmd/server + +# Final stage +FROM alpine:latest + +# Install runtime dependencies +RUN apk --no-cache add ca-certificates tzdata + +# Create non-root user +RUN addgroup -g 1000 -S appgroup && \ + adduser -u 1000 -S appuser -G appgroup + +# Set working directory +WORKDIR /app + +# Copy binary from builder +COPY --from=builder /app/database-render . + +# Copy configuration files +COPY --from=builder /app/config ./config +COPY --from=builder /app/web ./web + +# Create data directory for SQLite database +RUN mkdir -p /app/data && \ + chown -R appuser:appgroup /app + +# Switch to non-root user +USER appuser + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 + +# Run the application +CMD ["./database-render"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6e49aa2 --- /dev/null +++ b/Makefile @@ -0,0 +1,240 @@ +# Database Render Application Makefile + +# Variables +BINARY_NAME=database-render +APP_NAME=database-render +VERSION?=latest +BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S') +GIT_COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +LDFLAGS=-ldflags "-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) -X main.GitCommit=$(GIT_COMMIT)" + +# Go parameters +GOCMD=go +GOBUILD=$(GOCMD) build +GOCLEAN=$(GOCMD) clean +GOTEST=$(GOCMD) test +GOGET=$(GOCMD) get +GOMOD=$(GOCMD) mod +GOFMT=gofmt +GOFMT_CHECK=$(GOFMT) -l +GOFMT_FMT=$(GOFMT) -w + +# Directories +BIN_DIR=bin +DIST_DIR=dist +CONFIG_DIR=config +WEB_DIR=web +TEMPLATES_DIR=web/templates +STATIC_DIR=web/static + +# Default target +.PHONY: all +all: clean build + +# Build the application +.PHONY: build +build: deps + @echo "Building $(BINARY_NAME)..." + @mkdir -p $(BIN_DIR) + CGO_ENABLED=1 $(GOBUILD) $(LDFLAGS) -o $(BIN_DIR)/$(BINARY_NAME) ./cmd/server + @echo "Build completed: $(BIN_DIR)/$(BINARY_NAME)" + +# Build for multiple platforms +.PHONY: build-all +build-all: clean deps + @echo "Building for multiple platforms..." + @mkdir -p $(DIST_DIR) + # Linux AMD64 with CGO + CGO_ENABLED=1 GOOS=linux GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-linux-amd64 ./cmd/server + # Linux ARM64 with CGO + CGO_ENABLED=1 GOOS=linux GOARCH=arm64 $(GOBUILD) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-linux-arm64 ./cmd/server + # macOS AMD64 with CGO + CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-darwin-amd64 ./cmd/server + # macOS ARM64 with CGO + CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 $(GOBUILD) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-darwin-arm64 ./cmd/server + # Windows AMD64 with CGO (requires mingw-w64) + CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=x86_64-w64-mingw32-gcc $(GOBUILD) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-windows-amd64.exe ./cmd/server + @echo "Multi-platform build completed in $(DIST_DIR)/" + +# Install dependencies +.PHONY: deps +deps: + @echo "Installing dependencies..." + $(GOMOD) tidy + $(GOMOD) download + +# Clean build artifacts +.PHONY: clean +clean: + @echo "Cleaning..." + $(GOCLEAN) + rm -rf $(BIN_DIR) + rm -rf $(DIST_DIR) + @echo "Clean completed" + +# Run tests +.PHONY: test +test: + @echo "Running tests..." + $(GOTEST) -v ./... + +# Run tests with coverage +.PHONY: test-coverage +test-coverage: + @echo "Running tests with coverage..." + $(GOTEST) -v -coverprofile=coverage.out ./... + $(GOCMD) tool cover -html=coverage.out -o coverage.html + @echo "Coverage report generated: coverage.html" + +# Format code +.PHONY: fmt +fmt: + @echo "Formatting code..." + $(GOFMT_FMT) . + +# Check code formatting +.PHONY: fmt-check +fmt-check: + @echo "Checking code formatting..." + @if [ -n "$$($(GOFMT_CHECK) .)" ]; then \ + echo "Code is not formatted. Please run 'make fmt' to format the code."; \ + exit 1; \ + fi + @echo "Code formatting is correct" + +# Lint code +.PHONY: lint +lint: + @echo "Running linter..." + @if command -v golangci-lint >/dev/null 2>&1; then \ + golangci-lint run; \ + else \ + echo "golangci-lint not found. Please install it: https://golangci-lint.run/"; \ + exit 1; \ + fi + +# Security scan +.PHONY: security +security: + @echo "Running security scan..." + @if command -v gosec >/dev/null 2>&1; then \ + gosec ./...; \ + else \ + echo "gosec not found. Please install it: go install github.com/securecodewarrior/gosec/v2/cmd/gosec@latest"; \ + exit 1; \ + fi + +# Run the application +.PHONY: run +run: build + @echo "Starting $(BINARY_NAME)..." + ./$(BIN_DIR)/$(BINARY_NAME) + +# Run with development configuration +.PHONY: dev +dev: build + @echo "Starting $(BINARY_NAME) in development mode..." + CONFIG_FILE=config/config.yaml ./$(BIN_DIR)/$(BINARY_NAME) + +# Create sample configuration +.PHONY: init-config +init-config: + @echo "Creating sample configuration..." + @mkdir -p $(CONFIG_DIR) + @if [ ! -f $(CONFIG_DIR)/config.yaml ]; then \ + cp config/config.example.yaml $(CONFIG_DIR)/config.yaml; \ + echo "Sample configuration created at $(CONFIG_DIR)/config.yaml"; \ + else \ + echo "Configuration file already exists at $(CONFIG_DIR)/config.yaml"; \ + fi + +# Generate database schema example +.PHONY: init-db +init-db: + @echo "Creating sample database schema..." + @mkdir -p $(CONFIG_DIR) + @if [ ! -f $(CONFIG_DIR)/schema.sql ]; then \ + cp config/schema.example.sql $(CONFIG_DIR)/schema.sql; \ + echo "Sample database schema created at $(CONFIG_DIR)/schema.sql"; \ + else \ + echo "Database schema file already exists at $(CONFIG_DIR)/schema.sql"; \ + fi + +# Initialize project +.PHONY: init +init: init-config init-db deps + @echo "Project initialized successfully!" + @echo "Please edit $(CONFIG_DIR)/config.yaml to configure your database connection" + @echo "Then run: make run" + +# Docker commands +.PHONY: docker-build +docker-build: + @echo "Building Docker image..." + docker build -t $(APP_NAME):$(VERSION) . + +.PHONY: docker-run +docker-run: + @echo "Running Docker container..." + docker run -p 8080:8080 -v $(PWD)/config:/app/config $(APP_NAME):$(VERSION) + +.PHONY: docker-compose-up +docker-compose-up: + @echo "Starting with Docker Compose..." + docker-compose up --build + +.PHONY: docker-compose-down +docker-compose-down: + @echo "Stopping Docker Compose..." + docker-compose down + +# Development helpers +.PHONY: watch +watch: + @echo "Watching for changes..." + @if command -v air >/dev/null 2>&1; then \ + air; \ + else \ + echo "air not found. Please install it: go install github.com/cosmtrek/air@latest"; \ + exit 1; \ + fi + +# Check system health +.PHONY: health +health: + @echo "Checking system health..." + @curl -f http://localhost:8080/health || echo "Server not running" + +# Show help +.PHONY: help +help: + @echo "Database Render Application - Available Commands:" + @echo "" + @echo "Development:" + @echo " make init - Initialize project with sample config and database" + @echo " make run - Build and run the application" + @echo " make dev - Run in development mode" + @echo " make watch - Run with hot reload (requires air)" + @echo "" + @echo "Building:" + @echo " make build - Build the application" + @echo " make build-all - Build for multiple platforms" + @echo " make clean - Clean build artifacts" + @echo "" + @echo "Quality:" + @echo " make test - Run tests" + @echo " make test-coverage - Run tests with coverage" + @echo " make fmt - Format code" + @echo " make fmt-check - Check code formatting" + @echo " make lint - Run linter" + @echo " make security - Run security scan" + @echo "" + @echo "Docker:" + @echo " make docker-build - Build Docker image" + @echo " make docker-run - Run Docker container" + @echo " make docker-compose-up - Start with Docker Compose" + @echo " make docker-compose-down - Stop Docker Compose" + @echo "" + @echo "Utilities:" + @echo " make health - Check application health" + @echo " make help - Show this help message" \ No newline at end of file diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..a4484ba --- /dev/null +++ b/TODO.md @@ -0,0 +1,155 @@ +# Database Render Application - 后续任务清单 + +## 已完成 ✅ + +### 核心系统 + +- [x] 项目基础结构和 go.mod 配置 +- [x] 配置管理系统 (Viper + YAML) +- [x] 数据库连接和 ORM 层 (GORM + 多数据库支持) +- [x] HTTP 处理器层 (Fiber 框架) +- [x] 模板渲染系统 (Go templates) +- [x] 前端模板和静态资源 (TailwindCSS + 响应式设计) +- [x] 主应用入口点 +- [x] Makefile 和构建脚本 +- [x] Docker 配置 +- [x] 开发工具脚本 + +### 文档和配置 + +- [x] Makefile (包含所有常用命令) +- [x] Docker 配置 (Dockerfile + docker-compose.yml) +- [x] 示例配置文件 +- [x] 示例数据库 schema +- [x] 构建脚本 (scripts/build.sh) +- [x] 开发脚本 (scripts/dev.sh) +- [x] Air 热重载配置 + +## 待完成 📋 + +### 测试相关 + +- [ ] 单元测试 (repository 层) +- [ ] 单元测试 (service 层) +- [ ] 单元测试 (handler 层) +- [ ] 集成测试 (API 端点) +- [ ] 端到端测试 (UI 测试) +- [ ] 测试数据工厂 +- [ ] Mock 数据生成器 + +### 功能增强 + +- [ ] 导出功能 (CSV, JSON, Excel) +- [ ] 批量操作 (删除, 状态更新) +- [ ] 高级搜索 (过滤器, 排序) +- [ ] 数据可视化图表 +- [ ] 用户认证系统 +- [ ] 权限管理 +- [ ] 审计日志 +- [ ] 数据备份/恢复 + +### 性能优化 + +- [ ] 数据库连接池配置优化 +- [ ] 缓存层 (Redis) +- [ ] 分页性能优化 +- [ ] 静态资源缓存 +- [ ] 数据库索引优化 +- [ ] 查询性能分析 + +### 前端增强 + +- [ ] 暗黑模式切换 +- [ ] 移动端优化 +- [ ] 表格视图 (可选) +- [ ] 数据编辑界面 +- [ ] 图片上传和预览 +- [ ] 富文本编辑器 + +### 部署和运维 + +- [ ] CI/CD 配置 (GitHub Actions) +- [ ] 生产环境配置模板 +- [ ] 监控和告警 +- [ ] 日志轮转配置 +- [ ] 健康检查增强 +- [ ] 容器编排 (K8s manifests) + +### 文档完善 + +- [ ] 项目 README +- [ ] API 文档 (OpenAPI/Swagger) +- [ ] 部署指南 +- [ ] 配置说明文档 +- [ ] 开发指南 +- [ ] 故障排除指南 + +### 安全增强 + +- [ ] 输入验证和清理 +- [ ] SQL 注入防护检查 +- [ ] XSS 防护 +- [ ] CSRF 保护 +- [ ] 安全头部配置 +- [ ] 依赖安全扫描 + +## 立即行动项 🚀 + +### 启动应用测试 + +```bash +# 初始化项目 +./scripts/dev.sh init-db +./scripts/build.sh build +./scripts/dev.sh start + +# 或者使用Makefile +make init +make run +``` + +### 测试验证 + +1. 访问 http://localhost:8080 +2. 验证数据库连接 +3. 测试数据展示 +4. 检查搜索功能 +5. 验证分页功能 + +### 下一步开发建议 + +1. **优先完成测试**:为现有功能添加测试覆盖 +2. **功能增强**:从导出功能开始,逐步增加实用性功能 +3. **性能优化**:添加 Redis 缓存提升查询性能 +4. **安全加固**:实施基本的输入验证和安全防护 + +### 快速开始命令 + +```bash +# 完整开发环境启动 +./scripts/dev.sh start + +# 热重载开发 +./scripts/dev.sh watch + +# 多平台构建 +./scripts/build.sh build-all + +# Docker环境 +make docker-compose-up +``` + +## 技术债务 + +- [ ] 代码注释完善 +- [ ] 错误处理统一 +- [ ] 日志格式标准化 +- [ ] 配置验证增强 +- [ ] 代码重构优化 + +## 版本规划 + +- **v1.0.0**: 基础功能完成,稳定运行 +- **v1.1.0**: 增加导出和批量操作 +- **v1.2.0**: 添加用户认证和权限 +- **v2.0.0**: 架构优化,性能提升 diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..be29436 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,51 @@ +/* +Copyright © 2025 NAME HERE + +*/ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + + + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "database_render", + Short: "A brief description of your application", + Long: `A longer description that spans multiple lines and likely contains +examples and usage of using your application. For example: + +Cobra is a CLI library for Go that empowers applications. +This application is a tool to generate the needed files +to quickly create a Cobra application.`, + // Uncomment the following line if your bare application + // has an action associated with it: + // Run: func(cmd *cobra.Command, args []string) { }, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func init() { + // Here you will define your flags and configuration settings. + // Cobra supports persistent flags, which, if defined here, + // will be global for your application. + + // rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.database_render.yaml)") + + // Cobra also supports local flags, which will only run + // when this action is called directly. + rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") +} + + diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..7078cd6 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,137 @@ +package main + +import ( + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/cors" + "github.com/gofiber/fiber/v3/middleware/recover" + "github.com/rogeecn/database_render/internal/config" + "github.com/rogeecn/database_render/internal/database" + "github.com/rogeecn/database_render/internal/handler" + "github.com/rogeecn/database_render/internal/repository" + "github.com/rogeecn/database_render/internal/service" + "github.com/rogeecn/database_render/internal/template" +) + +func main() { + // Initialize structured logging + opts := &slog.HandlerOptions{ + Level: slog.LevelInfo, + } + logger := slog.New(slog.NewJSONHandler(os.Stdout, opts)) + slog.SetDefault(logger) + + // Load configuration + cfg, err := config.LoadConfig("") + if err != nil { + slog.Error("failed to load configuration", "error", err) + os.Exit(1) + } + + // Initialize database + db, err := database.NewConnectionManager(cfg) + if err != nil { + slog.Error("failed to initialize database", "error", err) + os.Exit(1) + } + defer func() { + if err := db.Close(); err != nil { + slog.Error("failed to close database", "error", err) + } + }() + + // Initialize repository and services + repo := repository.NewDataRepository(db, cfg) + svc := service.NewDataService(repo, cfg) + if err := svc.ValidateConfiguration(); err != nil { + slog.Error("configuration validation failed", "error", err) + os.Exit(1) + } + + // Initialize renderer + renderer, err := template.NewRenderer(svc, cfg) + if err != nil { + slog.Error("failed to initialize renderer", "error", err) + os.Exit(1) + } + + // Initialize handlers + dataHandler := handler.NewDataHandler(svc) + + // Initialize Fiber app + app := fiber.New(fiber.Config{ + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + ErrorHandler: renderer.ErrorHandler, + Views: nil, // Using custom renderer + }) + + // Setup middleware + app.Use(recover.New()) + app.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, + })) + + // Setup routes + // API routes + dataHandler.SetupRoutes(app) + + // Template routes + app.Get("/", func(c fiber.Ctx) error { + tableName := c.Query("table") + if tableName == "" { + return renderer.RenderIndex(c) + } + return renderer.RenderList(c, tableName) + }) + + // Setup static file serving + renderer.ServeStatic(app) + + // Health check endpoint + app.Get("/health", func(c fiber.Ctx) error { + return c.JSON(map[string]string{ + "status": "ok", + "timestamp": time.Now().Format(time.RFC3339), + }) + }) + + // Start server + port := cfg.App.Port + if port == 0 { + port = 8080 + } + + // Start server in a goroutine + go func() { + slog.Info("starting server", "port", port) + if err := app.Listen(fmt.Sprintf(":%d", port)); err != nil { + slog.Error("server error", "error", err) + os.Exit(1) + } + }() + + // Wait for interrupt signal to gracefully shutdown the server + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + slog.Info("shutting down server...") + + // Shutdown server + if err := app.Shutdown(); err != nil { + slog.Error("server shutdown error", "error", err) + os.Exit(1) + } + + slog.Info("server gracefully stopped") +} \ No newline at end of file diff --git a/config/config.example.yaml b/config/config.example.yaml new file mode 100644 index 0000000..2c06cdf --- /dev/null +++ b/config/config.example.yaml @@ -0,0 +1,147 @@ +# Database Render Application Configuration +# Copy this file to config.yaml and modify as needed + +app: + name: "Database Render" + port: 8080 + debug: false + +database: + # Database type: sqlite, mysql, postgres + type: "sqlite" + + # SQLite configuration (used when type is sqlite) + path: "data/app.db" + + # MySQL configuration (used when type is mysql) + # host: "localhost" + # port: 3306 + # user: "root" + # password: "password" + # db_name: "database_render" + + # PostgreSQL configuration (used when type is postgres) + # host: "localhost" + # port: 5432 + # user: "postgres" + # password: "password" + # db_name: "database_render" + # dsn: "host=localhost port=5432 user=postgres password=password dbname=database_render sslmode=disable" + +# Table configurations +tables: + - name: "posts" + alias: "文章管理" + description: "博客文章管理" + default: true + layout: "cards" # cards or list + columns: + - name: "id" + alias: "ID" + type: "int" + primary: true + hidden: true + - name: "title" + alias: "标题" + type: "string" + searchable: true + - name: "content" + alias: "内容" + type: "text" + render_type: "markdown" + searchable: true + - name: "category" + alias: "分类" + type: "string" + searchable: true + render_type: "tag" + values: + technology: + label: "技术" + color: "#3b82f6" + life: + label: "生活" + color: "#10b981" + work: + label: "工作" + color: "#f59e0b" + - name: "tags" + alias: "标签" + type: "string" + searchable: true + - name: "created_at" + alias: "创建时间" + type: "datetime" + render_type: "time" + - name: "updated_at" + alias: "更新时间" + type: "datetime" + render_type: "time" + + - name: "users" + alias: "用户管理" + description: "系统用户管理" + layout: "list" + columns: + - name: "id" + alias: "ID" + type: "int" + primary: true + hidden: true + - name: "username" + alias: "用户名" + type: "string" + searchable: true + - name: "email" + alias: "邮箱" + type: "string" + searchable: true + - name: "full_name" + alias: "姓名" + type: "string" + searchable: true + - name: "status" + alias: "状态" + type: "string" + render_type: "tag" + values: + active: + label: "激活" + color: "#10b981" + inactive: + label: "未激活" + color: "#ef4444" + pending: + label: "待审核" + color: "#f59e0b" + - name: "created_at" + alias: "创建时间" + type: "datetime" + render_type: "time" + +# Logging configuration +logging: + level: "info" # debug, info, warn, error + format: "json" # json, text + output: "stdout" # stdout, stderr, file + file: "logs/app.log" # Only used when output is file + +# Server configuration +server: + read_timeout: 30s + write_timeout: 30s + idle_timeout: 60s + +# Security configuration +security: + cors: + enabled: true + allow_origins: ["*"] + allow_methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"] + allow_headers: ["Origin", "Content-Type", "Accept", "Authorization"] + +# Cache configuration +cache: + enabled: false + ttl: 5m + max_size: 100 \ No newline at end of file diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..3268884 --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,230 @@ +# Database Render Application Configuration + +app: + name: "Database Render" + port: 9080 + debug: false + +database: + type: "sqlite" + path: "data/app.db" + +# Table configurations +tables: + posts: + alias: "文章管理" + description: "博客文章管理" + page_size: 10 + fields: + id: + type: "int" + alias: "ID" + hidden: true + title: + type: "string" + alias: "标题" + searchable: true + size: "large" + content: + type: "text" + alias: "内容" + hidden: true # Hide from list view, show in detail + markdown: true + category: + type: "string" + alias: "分类" + searchable: true + colors: + Technology: "#3b82f6" + Programming: "#8b5cf6" + "Web Development": "#10b981" + DevOps: "#f59e0b" + "Data Science": "#ef4444" + Lifestyle: "#ec4899" + tags: + type: "string" + alias: "标签" + searchable: true + status: + type: "string" + alias: "状态" + searchable: true + colors: + published: "#10b981" + draft: "#6b7280" + pending: "#f59e0b" + view_count: + type: "int" + alias: "浏览量" + created_at: + type: "time" + alias: "创建时间" + format: "2006-01-02 15:04:05" + updated_at: + type: "time" + alias: "更新时间" + format: "2006-01-02 15:04:05" + + users: + alias: "用户管理" + description: "系统用户管理" + page_size: 10 + fields: + id: + type: "int" + alias: "ID" + hidden: true + username: + type: "string" + alias: "用户名" + searchable: true + email: + type: "string" + alias: "邮箱" + searchable: true + full_name: + type: "string" + alias: "姓名" + searchable: true + bio: + type: "text" + alias: "简介" + max_length: 200 + status: + type: "string" + alias: "状态" + searchable: true + colors: + active: "#10b981" + inactive: "#ef4444" + pending: "#f59e0b" + role: + type: "string" + alias: "角色" + searchable: true + colors: + admin: "#ef4444" + user: "#3b82f6" + last_login: + type: "time" + alias: "最后登录" + format: "2006-01-02 15:04:05" + created_at: + type: "time" + alias: "创建时间" + format: "2006-01-02 15:04:05" + updated_at: + type: "time" + alias: "更新时间" + format: "2006-01-02 15:04:05" + + categories: + alias: "分类管理" + description: "文章分类管理" + page_size: 10 + fields: + id: + type: "int" + alias: "ID" + hidden: true + name: + type: "string" + alias: "名称" + searchable: true + slug: + type: "string" + alias: "别名" + searchable: true + description: + type: "text" + alias: "描述" + max_length: 200 + color: + type: "string" + alias: "颜色" + render_type: "color" + sort_order: + type: "int" + alias: "排序" + is_active: + type: "bool" + alias: "激活" + render_type: "bool" + created_at: + type: "time" + alias: "创建时间" + format: "2006-01-02 15:04:05" + updated_at: + type: "time" + alias: "更新时间" + format: "2006-01-02 15:04:05" + + comments: + alias: "评论管理" + description: "文章评论管理" + page_size: 15 + fields: + id: + type: "int" + alias: "ID" + hidden: true + post_id: + type: "int" + alias: "文章ID" + user_id: + type: "int" + alias: "用户ID" + author_name: + type: "string" + alias: "作者" + searchable: true + author_email: + type: "string" + alias: "邮箱" + searchable: true + content: + type: "text" + alias: "内容" + max_length: 500 + status: + type: "string" + alias: "状态" + searchable: true + colors: + approved: "#10b981" + pending: "#f59e0b" + rejected: "#ef4444" + created_at: + type: "time" + alias: "创建时间" + format: "2006-01-02 15:04:05" + updated_at: + type: "time" + alias: "更新时间" + format: "2006-01-02 15:04:05" + +# Logging configuration +logging: + level: "info" + format: "json" + output: "stdout" + +# Server configuration +server: + read_timeout: 30s + write_timeout: 30s + idle_timeout: 60s + +# Security configuration +security: + cors: + enabled: true + allow_origins: ["*"] + allow_methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"] + allow_headers: ["Origin", "Content-Type", "Accept"] + +# Cache configuration +cache: + enabled: false + ttl: 5m + max_size: 100 diff --git a/config/schema.example.sql b/config/schema.example.sql new file mode 100644 index 0000000..c05f37c --- /dev/null +++ b/config/schema.example.sql @@ -0,0 +1,346 @@ +-- Database Render Application - Sample Database Schema +-- This file contains sample database tables for testing the application + +-- Posts table (blog posts/articles) +CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title VARCHAR(255) NOT NULL, + content TEXT, + category VARCHAR(50), + tags VARCHAR(255), + author_id INTEGER, + status VARCHAR(20) DEFAULT 'draft', + view_count INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + full_name VARCHAR(100), + password_hash VARCHAR(255), + avatar_url VARCHAR(255), + bio TEXT, + status VARCHAR(20) DEFAULT 'active', + role VARCHAR(20) DEFAULT 'user', + last_login DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Categories table +CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) UNIQUE NOT NULL, + slug VARCHAR(100) UNIQUE NOT NULL, + description TEXT, + color VARCHAR(7), + sort_order INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Tags table +CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(50) UNIQUE NOT NULL, + slug VARCHAR(50) UNIQUE NOT NULL, + color VARCHAR(7), + description TEXT, + usage_count INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Post-Tag relationship table +CREATE TABLE IF NOT EXISTS post_tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE, + UNIQUE(post_id, tag_id) +); + +-- Comments table +CREATE TABLE IF NOT EXISTS comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL, + user_id INTEGER, + author_name VARCHAR(100), + author_email VARCHAR(255), + content TEXT NOT NULL, + parent_id INTEGER, + status VARCHAR(20) DEFAULT 'pending', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE +); + +-- Media/Attachments table +CREATE TABLE IF NOT EXISTS media ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename VARCHAR(255) NOT NULL, + original_filename VARCHAR(255), + file_path VARCHAR(500), + file_size INTEGER, + mime_type VARCHAR(100), + alt_text VARCHAR(255), + title VARCHAR(255), + description TEXT, + uploaded_by INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL +); + +-- Sample data insertion +-- Insert sample users +INSERT OR IGNORE INTO users (username, email, full_name, bio, role) VALUES +('admin', 'admin@example.com', 'System Administrator', 'System administrator account', 'admin'), +('john_doe', 'john@example.com', 'John Doe', 'Technology enthusiast and blogger', 'user'), +('jane_smith', 'jane@example.com', 'Jane Smith', 'Content creator and writer', 'user'), +('bob_wilson', 'bob@example.com', 'Bob Wilson', 'Software developer and tech reviewer', 'user'); + +-- Insert sample categories +INSERT OR IGNORE INTO categories (name, slug, description, color) VALUES +('Technology', 'technology', 'Latest technology trends and developments', '#3b82f6'), +('Programming', 'programming', 'Programming tutorials and best practices', '#8b5cf6'), +('Web Development', 'web-development', 'Web development articles and tutorials', '#10b981'), +('DevOps', 'devops', 'DevOps practices and tools', '#f59e0b'), +('Data Science', 'data-science', 'Data science and machine learning', '#ef4444'), +('Lifestyle', 'lifestyle', 'Personal development and lifestyle', '#ec4899'); + +-- Insert sample tags +INSERT OR IGNORE INTO tags (name, slug, color, description) VALUES +('Go', 'go', '#00add8', 'Go programming language'), +('Python', 'python', '#3776ab', 'Python programming language'), +('JavaScript', 'javascript', '#f7df1e', 'JavaScript programming language'), +('Docker', 'docker', '#2496ed', 'Docker containerization'), +('Kubernetes', 'kubernetes', '#326ce5', 'Kubernetes orchestration'), +('React', 'react', '#61dafb', 'React framework'), +('Vue.js', 'vuejs', '#4fc08d', 'Vue.js framework'), +('Database', 'database', '#4479a1', 'Database technologies'), +('API', 'api', '#68d391', 'API development'), +('Security', 'security', '#f56565', 'Security practices'); + +-- Insert sample posts +INSERT OR IGNORE INTO posts (title, content, category, tags, author_id, status) VALUES +('Getting Started with Go and Fiber Framework', '# Getting Started with Go and Fiber + +This is a comprehensive guide to building web applications with Go and the Fiber framework. + +## Introduction + +Fiber is an Express.js inspired web framework written in Go. It provides a robust set of features for building web applications and APIs. + +## Installation + +```bash +go get github.com/gofiber/fiber/v2 +``` + +## Basic Usage + +```go +package main + +import "github.com/gofiber/fiber/v2" + +func main() { + app := fiber.New() + + app.Get("/", func(c *fiber.Ctx) error { + return c.SendString("Hello, World!") + }) + + app.Listen(":3000") +} +``` + +## Conclusion + +Fiber provides an excellent foundation for building high-performance web applications in Go.', 'Programming', 'Go,Web Development,API', 2, 'published'), + +('Modern Database Design Best Practices', '# Modern Database Design Best Practices + +Learn the essential principles of designing scalable and maintainable databases. + +## Key Principles + +1. **Normalization**: Reduce data redundancy +2. **Indexing**: Improve query performance +3. **Data Types**: Choose appropriate data types +4. **Constraints**: Ensure data integrity + +## Example Schema + +Here''s an example of a well-designed user table: + +```sql +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +## Performance Tips + +- Use appropriate indexes +- Consider partitioning for large tables +- Regular database maintenance', 'Technology', 'Database,Design,Best Practices', 3, 'published'), + +('Container Orchestration with Kubernetes', '# Container Orchestration with Kubernetes + +Master the art of container orchestration with Kubernetes. + +## What is Kubernetes? + +Kubernetes is an open-source platform designed to automate deploying, scaling, and operating application containers. + +## Key Features + +- **Service Discovery**: Automatic container discovery +- **Load Balancing**: Distribute traffic across containers +- **Storage Orchestration**: Mount storage systems +- **Self-Healing**: Automatic container restarts +- **Secret Management**: Secure sensitive data + +## Getting Started + +```bash +# Start a local cluster +minikube start + +# Deploy an application +kubectl create deployment hello-node --image=k8s.gcr.io/echoserver:1.4 + +# Expose the deployment +kubectl expose deployment hello-node --type=LoadBalancer --port=8080 +``` + +## Best Practices + +- Use namespaces for organization +- Implement health checks +- Monitor resource usage +- Use ConfigMaps for configuration', 'DevOps', 'Kubernetes,Docker,Containerization', 4, 'published'), + +('Building RESTful APIs with Best Practices', '# Building RESTful APIs with Best Practices + +Create robust and scalable RESTful APIs following industry standards. + +## REST Principles + +- **Stateless**: Each request contains all necessary information +- **Resource-Based**: URLs represent resources +- **HTTP Methods**: Use appropriate HTTP verbs (GET, POST, PUT, DELETE) +- **Status Codes**: Return appropriate HTTP status codes + +## API Design Guidelines + +### 1. Version Your API +``` +/api/v1/users +/api/v2/users +``` + +### 2. Use Plural Nouns +``` +GET /users +POST /users +GET /users/123 +``` + +### 3. Filtering and Pagination +``` +GET /users?page=1&limit=10&sort=name&order=asc +``` + +### 4. Error Handling +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "Email is required", + "details": [] + } +} +``` + +## Security Considerations + +- Use HTTPS +- Implement rate limiting +- Validate input data +- Use authentication and authorization', 'Programming', 'API,REST,Security', 2, 'published'), + +('React State Management in 2024', '# React State Management in 2024 + +Explore modern state management solutions for React applications. + +## State Management Options + +### 1. React Context API +Built-in solution for sharing state across components. + +### 2. Redux Toolkit +Predictable state container with great developer tools. + +### 3. Zustand +Lightweight and simple state management. + +### 4. Jotai +Atomic approach to state management. + +## Choosing the Right Solution + +| Solution | Best For | Bundle Size | +|----------|----------|-------------| +| Context | Simple apps | Small | +| Redux | Complex apps | Large | +| Zustand | Medium apps | Small | +| Jotai | Atomic state | Small | + +## Code Example + +```javascript +// Using Zustand +import { create } from ''zustand'' + +const useStore = create((set) => ({ + count: 0, + increment: () => set((state) => ({ count: state.count + 1 })), +})) +``` + +## Performance Tips + +- Use React.memo for expensive components +- Implement proper memoization +- Consider code splitting for large state', 'Web Development', 'React,JavaScript,State Management', 3, 'published'); + +-- Insert sample comments +INSERT OR IGNORE INTO comments (post_id, user_id, content, status) VALUES +(1, 3, 'Great tutorial! Very helpful for getting started with Fiber.', 'approved'), +(1, 4, 'Thanks for sharing this. The examples are really clear.', 'approved'), +(2, 2, 'Excellent overview of database design principles!', 'approved'), +(3, 1, 'Kubernetes can be overwhelming, but this guide makes it approachable.', 'approved'), +(4, 3, 'RESTful API best practices are so important. Great article!', 'approved'); + +-- Insert post-tag relationships +INSERT OR IGNORE INTO post_tags (post_id, tag_id) VALUES +(1, 1), (1, 9), (1, 10), +(2, 8), (2, 9), +(3, 4), (3, 5), +(4, 9), (4, 10), +(5, 6), (5, 7), (5, 10); \ No newline at end of file diff --git a/config/schema.sql b/config/schema.sql new file mode 100644 index 0000000..c05f37c --- /dev/null +++ b/config/schema.sql @@ -0,0 +1,346 @@ +-- Database Render Application - Sample Database Schema +-- This file contains sample database tables for testing the application + +-- Posts table (blog posts/articles) +CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title VARCHAR(255) NOT NULL, + content TEXT, + category VARCHAR(50), + tags VARCHAR(255), + author_id INTEGER, + status VARCHAR(20) DEFAULT 'draft', + view_count INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + full_name VARCHAR(100), + password_hash VARCHAR(255), + avatar_url VARCHAR(255), + bio TEXT, + status VARCHAR(20) DEFAULT 'active', + role VARCHAR(20) DEFAULT 'user', + last_login DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Categories table +CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) UNIQUE NOT NULL, + slug VARCHAR(100) UNIQUE NOT NULL, + description TEXT, + color VARCHAR(7), + sort_order INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Tags table +CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(50) UNIQUE NOT NULL, + slug VARCHAR(50) UNIQUE NOT NULL, + color VARCHAR(7), + description TEXT, + usage_count INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Post-Tag relationship table +CREATE TABLE IF NOT EXISTS post_tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE, + UNIQUE(post_id, tag_id) +); + +-- Comments table +CREATE TABLE IF NOT EXISTS comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL, + user_id INTEGER, + author_name VARCHAR(100), + author_email VARCHAR(255), + content TEXT NOT NULL, + parent_id INTEGER, + status VARCHAR(20) DEFAULT 'pending', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE +); + +-- Media/Attachments table +CREATE TABLE IF NOT EXISTS media ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename VARCHAR(255) NOT NULL, + original_filename VARCHAR(255), + file_path VARCHAR(500), + file_size INTEGER, + mime_type VARCHAR(100), + alt_text VARCHAR(255), + title VARCHAR(255), + description TEXT, + uploaded_by INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL +); + +-- Sample data insertion +-- Insert sample users +INSERT OR IGNORE INTO users (username, email, full_name, bio, role) VALUES +('admin', 'admin@example.com', 'System Administrator', 'System administrator account', 'admin'), +('john_doe', 'john@example.com', 'John Doe', 'Technology enthusiast and blogger', 'user'), +('jane_smith', 'jane@example.com', 'Jane Smith', 'Content creator and writer', 'user'), +('bob_wilson', 'bob@example.com', 'Bob Wilson', 'Software developer and tech reviewer', 'user'); + +-- Insert sample categories +INSERT OR IGNORE INTO categories (name, slug, description, color) VALUES +('Technology', 'technology', 'Latest technology trends and developments', '#3b82f6'), +('Programming', 'programming', 'Programming tutorials and best practices', '#8b5cf6'), +('Web Development', 'web-development', 'Web development articles and tutorials', '#10b981'), +('DevOps', 'devops', 'DevOps practices and tools', '#f59e0b'), +('Data Science', 'data-science', 'Data science and machine learning', '#ef4444'), +('Lifestyle', 'lifestyle', 'Personal development and lifestyle', '#ec4899'); + +-- Insert sample tags +INSERT OR IGNORE INTO tags (name, slug, color, description) VALUES +('Go', 'go', '#00add8', 'Go programming language'), +('Python', 'python', '#3776ab', 'Python programming language'), +('JavaScript', 'javascript', '#f7df1e', 'JavaScript programming language'), +('Docker', 'docker', '#2496ed', 'Docker containerization'), +('Kubernetes', 'kubernetes', '#326ce5', 'Kubernetes orchestration'), +('React', 'react', '#61dafb', 'React framework'), +('Vue.js', 'vuejs', '#4fc08d', 'Vue.js framework'), +('Database', 'database', '#4479a1', 'Database technologies'), +('API', 'api', '#68d391', 'API development'), +('Security', 'security', '#f56565', 'Security practices'); + +-- Insert sample posts +INSERT OR IGNORE INTO posts (title, content, category, tags, author_id, status) VALUES +('Getting Started with Go and Fiber Framework', '# Getting Started with Go and Fiber + +This is a comprehensive guide to building web applications with Go and the Fiber framework. + +## Introduction + +Fiber is an Express.js inspired web framework written in Go. It provides a robust set of features for building web applications and APIs. + +## Installation + +```bash +go get github.com/gofiber/fiber/v2 +``` + +## Basic Usage + +```go +package main + +import "github.com/gofiber/fiber/v2" + +func main() { + app := fiber.New() + + app.Get("/", func(c *fiber.Ctx) error { + return c.SendString("Hello, World!") + }) + + app.Listen(":3000") +} +``` + +## Conclusion + +Fiber provides an excellent foundation for building high-performance web applications in Go.', 'Programming', 'Go,Web Development,API', 2, 'published'), + +('Modern Database Design Best Practices', '# Modern Database Design Best Practices + +Learn the essential principles of designing scalable and maintainable databases. + +## Key Principles + +1. **Normalization**: Reduce data redundancy +2. **Indexing**: Improve query performance +3. **Data Types**: Choose appropriate data types +4. **Constraints**: Ensure data integrity + +## Example Schema + +Here''s an example of a well-designed user table: + +```sql +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +## Performance Tips + +- Use appropriate indexes +- Consider partitioning for large tables +- Regular database maintenance', 'Technology', 'Database,Design,Best Practices', 3, 'published'), + +('Container Orchestration with Kubernetes', '# Container Orchestration with Kubernetes + +Master the art of container orchestration with Kubernetes. + +## What is Kubernetes? + +Kubernetes is an open-source platform designed to automate deploying, scaling, and operating application containers. + +## Key Features + +- **Service Discovery**: Automatic container discovery +- **Load Balancing**: Distribute traffic across containers +- **Storage Orchestration**: Mount storage systems +- **Self-Healing**: Automatic container restarts +- **Secret Management**: Secure sensitive data + +## Getting Started + +```bash +# Start a local cluster +minikube start + +# Deploy an application +kubectl create deployment hello-node --image=k8s.gcr.io/echoserver:1.4 + +# Expose the deployment +kubectl expose deployment hello-node --type=LoadBalancer --port=8080 +``` + +## Best Practices + +- Use namespaces for organization +- Implement health checks +- Monitor resource usage +- Use ConfigMaps for configuration', 'DevOps', 'Kubernetes,Docker,Containerization', 4, 'published'), + +('Building RESTful APIs with Best Practices', '# Building RESTful APIs with Best Practices + +Create robust and scalable RESTful APIs following industry standards. + +## REST Principles + +- **Stateless**: Each request contains all necessary information +- **Resource-Based**: URLs represent resources +- **HTTP Methods**: Use appropriate HTTP verbs (GET, POST, PUT, DELETE) +- **Status Codes**: Return appropriate HTTP status codes + +## API Design Guidelines + +### 1. Version Your API +``` +/api/v1/users +/api/v2/users +``` + +### 2. Use Plural Nouns +``` +GET /users +POST /users +GET /users/123 +``` + +### 3. Filtering and Pagination +``` +GET /users?page=1&limit=10&sort=name&order=asc +``` + +### 4. Error Handling +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "Email is required", + "details": [] + } +} +``` + +## Security Considerations + +- Use HTTPS +- Implement rate limiting +- Validate input data +- Use authentication and authorization', 'Programming', 'API,REST,Security', 2, 'published'), + +('React State Management in 2024', '# React State Management in 2024 + +Explore modern state management solutions for React applications. + +## State Management Options + +### 1. React Context API +Built-in solution for sharing state across components. + +### 2. Redux Toolkit +Predictable state container with great developer tools. + +### 3. Zustand +Lightweight and simple state management. + +### 4. Jotai +Atomic approach to state management. + +## Choosing the Right Solution + +| Solution | Best For | Bundle Size | +|----------|----------|-------------| +| Context | Simple apps | Small | +| Redux | Complex apps | Large | +| Zustand | Medium apps | Small | +| Jotai | Atomic state | Small | + +## Code Example + +```javascript +// Using Zustand +import { create } from ''zustand'' + +const useStore = create((set) => ({ + count: 0, + increment: () => set((state) => ({ count: state.count + 1 })), +})) +``` + +## Performance Tips + +- Use React.memo for expensive components +- Implement proper memoization +- Consider code splitting for large state', 'Web Development', 'React,JavaScript,State Management', 3, 'published'); + +-- Insert sample comments +INSERT OR IGNORE INTO comments (post_id, user_id, content, status) VALUES +(1, 3, 'Great tutorial! Very helpful for getting started with Fiber.', 'approved'), +(1, 4, 'Thanks for sharing this. The examples are really clear.', 'approved'), +(2, 2, 'Excellent overview of database design principles!', 'approved'), +(3, 1, 'Kubernetes can be overwhelming, but this guide makes it approachable.', 'approved'), +(4, 3, 'RESTful API best practices are so important. Great article!', 'approved'); + +-- Insert post-tag relationships +INSERT OR IGNORE INTO post_tags (post_id, tag_id) VALUES +(1, 1), (1, 9), (1, 10), +(2, 8), (2, 9), +(3, 4), (3, 5), +(4, 9), (4, 10), +(5, 6), (5, 7), (5, 10); \ No newline at end of file diff --git a/data/app.db b/data/app.db new file mode 100644 index 0000000000000000000000000000000000000000..9b458226f37500f6037880929715b5e1e60a3213 GIT binary patch literal 73728 zcmeI5Uu+x6eaDv~^@nRodzZ^qaSpD>CK5}sB_fl58Irq{>5J7%eNFMBEpm5ec4qeT`^~@K%VSjE(<-VGVrJ7;ldM;;|o&{dnxV*?&9sd&l-i zma~7J{o2siN1n)h0^a=j`6KYw>(=nZ#6;=~cOtv&%7&~uw)%Qvxmd`T3Zj(1wptL% zNlcmoAEzo-)=Pz(g`(IjuB_#Ycg6FCyCT0`+E`h~RcnRy(u@jOb6n{mI`KljxO^*L zoLszcVM?rTl*Ib>>Z%Uo2TtAbY-*u5&lO5ijj$M;03@agvbwVJMPS7mjle9)VE zC6IOmca+HMfLK~tD~Rib8~N?kl33m@7C~r-9JfmOwN0IJt64qjB{w#Tg_WD@ivDCG zFH@pexKThN%Y`k`3S|&ZI@Kw$v5q{f79i8Df|h_e_4Z5S!xJY?q<&>giNE4E8q$lx z&ac&@1Rd8S>p{~Gqn(7>^lAX*FyX6l*sW;S4|aOACm})YD;u`s_K{-6_afYelGGt} zYuW*P9!M^X?5GvSQO!?v<#nbhy{hBY&*+T3529`3>k%ut9Tw|yerq{@y`b~is~j3} zPYmy-7=5DXg6oN-qY{S{teq2c>T~|+@WjcJsdr|TeyKZ&?0VQe80*onuJg}W#gJq| zOPeWU2HN-wP;I zI@^Z?5W1~;x9B}IRK<7wpd0Mjc7^?riaE6Fa;M_APziL}s!sho|9Hx=Id$gP(8R}{ zewZYP=guO1kDf7qYQO~wc-BC8w$_bydmZ@64qdWcr+aS)a&2@_ zTpu0LZh!4@Dbm#7(8Se=hbfYh>dvD28Jf!c^q>nW=pY6nBQzM<92lB7dp31fsZP5! z2<0m+=~d*R({ztI9rBzcC7HpMQ*WhGN*&*Us4!qq4W0TL=(%1w9a$Y8?0z3=Dv~|B zwuE%mchGGa(12(k)nX+_9@@Fxvy8i=+WFlSsv6j}sI4k{j@*C0TbAT7lSG}eenL(k z&_79EK4t<;fC(@GCcp%k025#WOn?b60Vco%em)62nViE{ll(*0w+!n?)(@<|vc6~i z5l;BS1egF5U;<2l2`~XBzyz286JP>NfC;=G1jdHaCsW-eccY_&=@Y5W;>p2mCOx4% z!@-es`soy{TrkH6(x+1KI-sF!3W3z>jt9p7nXw-kR%86%$D26d4-;SlOn?b60Vco% zm;e)C0!)Aj{CpF5Gm{xJPMtkv7~996HkOvketBo#aYNt3(Wm5nyU}#z9LAK_{Ia;C zJhuE=w2ucKMOE%e*Kgv98r!RiNLF@5AZaDL4D`biG_G8nU%W6oe`$99yqI6Sa(?m3 z;^jV0uceWTS>z(Ga`Ce5$(^v_M7wm9%*Jy#7hB}B+^mQPUz&UVVV~ zaY`z(iIa@=UBmjZ^NfC(^x{~H9xG0PvD zax!!p+f>q{SSCQ$4YG0!jHD;B%1V&TxgP(2!?6C*`iAv~*7uWse9Q!x025#WOn?b6 z0Vco%m;e)C0!)AjJSGB9rO(m;Ke<34^TG728s#S))5`JCN``?CrqAgye==w?=k${Q zp|MrN`m8lP{-yD=W8cOJf0zIhU;<2l2`~XBzyz286JP>N;9Uva8#0ZPCvQJ9l3iL_ zb!sw<4qWMW4mPool8>FLI;-hvrzEwQyc;4XE9Wm=?zd{b&m!*TplN*MBO5BQ>v);I z*s3`6T7GN;WzUBCSh=^4RZf_WJFpXU9C6)MQ;MW7Yfz|i* zW_*}%6;US*{V)Io9p8QKQu#uqcCk+yeVn3{X`DK>qmqrEHlPPA^m7NR*qs28#||RG z{!>+rIDsI{EtM}VK5D8-C8qiRA?quK^-b%Kt*=;rYyFG$XGwoPW&%ur2`~XBzyz28 z6JP>NfC(@GCcp&#_Xv#PeScjW($Ev>kED`Do025#WOn?b60Vco%m;e)C0!)AjJYE7c{_idfQRDyQ!VWe5 z$NWE5U#Ri_p#>6Z{GV96pvM34B7)?d(E5LKv}xe!|7+uaH~#wghsOS5EE<~_{XQJ| z!vvTB6JP>NfC(@GCcp%k026o*1U_wMjT0xHSr}Pb+LDzPUgLfcwtul&{38B!oZ}_c zeHKplae6+RHJ*CvnX`yFzqw*Jo!rGs)uqb|I7zM+?{j4zrx!<5HlIagH1ArrL$$D; zeskx~KYQ6;=s3rVvmF`siK&m%?cuC(eBzl|ME*ic&b=J&wdBiT?tHB>f2pcnL=7v@ z<-Q->>vMe{r;VYkF)^WTz9{iR_<+807Z<8_t*ji58ew@biNvuS_ z*FDNDEnck13$d5*gUT+}s^e|-j_>u!VIQZv8E`oM%*P0_U%4lPs`}0?p1*uiR<)C; z;O+l5o>>T-SC0_2lUCV(9#KDM@7Y_Gz-dM=<0w=|x!SYU+JcO|plsbm|H8GsddsfM zK3VSL^wNO3i)AFWc@XXTUQ>PNmY#j~qFvTb?;-lzsjTtjlM2#JzwYPe=k03s(oO$8 zM42qUH2-f}ZyDCFWA*=ETK{4FjrAStpRM1w-m-o>8IF&c025#WOn?b60Vco%m;e)C z0!)AjFoE|(fL_wyF?yN9>8Db#SY^qvV`3Vi)&ANpG@MOOq?AEth+g#{+h6n(0m^=& zHUWrD8)>>)n;`}U(@$n^`pMkt@&DU~^&8e#d&d87Ti;BE<6|bk1egF5U;<2l2`~XB zzyz286JP>N;Jp!`v3_R}j4}XV@rp74(2{>zaxy}rezizMjsMl+4mJLd7i8!s0^;$1 zyw*XD|Mi*#HU6*njQ?r<|LFMd7}noge`5WX^+n6IHm&p4hsOVF{GZ0Z2M_)*0Vco% zm;e)C0!)AjFaajO1egF5c#H%-JUVEkQcHtopB4BYKBoPK`}vvLZ>XPNR{3T6#yO(> zvi+_bR(|QhettvRFVoL&Q27n?jWeVDM*3WLJUwV+Q%n7_d3->5^^5UCDR^a<`oxzM z8_oZZT3_19Ph@TbAV*Nh<&tba2@>USVJsDDQ?Lz!$U zWv10nb|90<;x!qG56%A%j(y#*Ub9BVzcxOBL;f%UCcp%k025#WOn?b60Vco%n80Hv zus=A8S#QI5Gi{t$TFRu0g{@Mp+5&L}`@7O@I9`25lv~)Pe%A>_ zz2#J;XxR5;C>*3tsoIrYyX?xjqh-NOA5-`4Qw9bG3im70b)^@nl=mFDFZ`P5tdPeW z9z&<@;iY(vS8ra+f=ZSP(vYJMQXT^n)M)!ogB(ykQpzTKb{ z6ml?j-2(x_c5uWS+)s_FB;OoVh_$$)R^$hc?Vb`Z$lyQ#aFYa1IjPGiqMU>gWGbs- z--&j`4F@l0JYvF0HKy^uVf|;)iI1586JP>NfC(@GCcp%k025#WOn?b6f&XU&X#Af^ zecTxRR<<_yaUAgHkqLa^4H(IGzC4&X~vAFj0=w@L<@gq1GTR+?*MtJ@5)Cx=s! zVNN`9P}5u{E1ndAUv41|+pCJOV!LEd>rJQTyRN@a$va*ZL18?Cp~$P+K{cE+&0J0e zMb1><-5w<&lBuUzvcHQwb zZb~b@?vqVyQF3+Rqps6%qN@w@9}Qvnz3O4xn2-8FRR&kF*GU}j`8iPt0$34mAyV9P zoxENSeb0Q&6ymfbztdL)o#2#k;q3Ga`PG%{`O?b9`c9!(+$f%&(f$omhPnz;XxNS` zVEHAzaROQGgsDpG+v0|Z=EawA)f?s;o!p4d+fE@qFN6&ArZJGhtYSzyu!Z9lGP5?6 z4v}i88>O7jiWh9xf$3L58=J5^lNGpqt=)=tafMS+$P__xf=yL#AF~00pFP=${8ZUmiaQwC^|F{{wQ;pb2?uDakfY^YZqq}~TfTp=-htLF= zo>4{u7`|(rLJ7y7{dNwgp$q(-UD*F2nKmC{G zp*|GXov=dF2?tb=^Ex%BlDOAuCjf*zJ-zDNRdEeLJDj^RIA}) zyAt>zmHReoUHfdoxDLJT6%fU+hVmMr!qnd30k}8Xmq@oZdka?nc3S=NhdUyheCL8n zAS1D6dq|12m*Q6{h6x%8eUF?yuq2hGTKQ(%s*j48tokMu61?+g*USm!By8dOsPQqy zfLdJ!CMH~*d)SCc&jTP1RA(d?l4XLwNnt7xuG0;O7M?0im{f56Sr27g%+5LuWb^91 zOW|C-63jXNIa%5DQPDvjR~F{ZQ~ZVdO}HpEjAQA>FST#%Lz5P9{Lw*EUL}!fi9vH` zXK9M+>ZSQh^W7Suw}I?b1XP)YO&d**ngH>Gy6v^gaZmNI3-Mqs1RGFQ?vW7Ie9wU> zyG@U1QEk<(AyE2iPJPX8>PVp>I7OvR`&M0-dmGI#E+R8}6RH_p)3XUx=7=@L=RYwp zYTWtK{;3sJQ>+0!8msH=*E@CEqh1I(+{PmWGtE^@J$XtxsFkVigR;c)U}~03wRcoN zyL|^Lx2U<*ZK=)#DI~pXDp6XKeFe0Q)6?tJ^1E#bkXjdIwS@vq*8+C6h3&n=%1*z(5Z^8LUAR(DmIHNYx&|` z@qFQ~ZYC)nQep8zzPNlVU!1%!KP9%;S8i_?#QH`_tZ%Qb>X67A8qrR$#S0f65>A)p z>JIAl(#jgzuKe02xP03E6{b@PWk(MYS+jHtNL*+1`r8ur1!rdgt?YibVBU_ezRx)34N z(t0)ojpXQrI9dpdB@*Jp~tNS&YYd)vH_ZGWqcFhja;BKobmK^K8i8}ip zYFwomrm5E?h|}*9*=f_PwY-YDU~;P64TYYHD_4a|aBf}hL%XSWs=nYi2Q8R9eeU#( zpi3t!Vp@k>j_ywh)uC<2Ll>b*rtv{#ZcBRAE%X+=`sC?b)KJZcJAU9+Po37Wzi}v$ zRrD>SH+lNX()|29!od$8 zQc&Zc&Vp*4kFK2->()JtXY`;{O{b7?>AooWzI)G!%uS4por*F-kmVD%Q0W-u^oYJ5 z&~o>x9vYDW2N!l?gO%x*U_Hga*IdP@E83Uzt;_`>MyHrd(^1A$)J^9EtWD4PF#VZ% z*ucrG++NR4cJaQK2xYw@%Ly3+iYl3{Hel0yU38Ac>q1#XZos~RLyQoSi7n?;I)oj`VE*m{DwI5 zs+P|g(}Y|x{Rrb3S?i^^+73B62`k5xxcUh(NwZWHve;h{^JGB7lxCnz?NKKIMj3(asy%^M*4b1e{5t^`8zbkS4q#r8~V*`9jXeBYD`O3Y5xuH CxpET# literal 0 HcmV?d00001 diff --git a/database_render/.gitignore b/database_render/.gitignore new file mode 100644 index 0000000..5b90e79 --- /dev/null +++ b/database_render/.gitignore @@ -0,0 +1,27 @@ +# ---> Go +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + diff --git a/README.md b/database_render/README.md similarity index 100% rename from README.md rename to database_render/README.md diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..797badb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,75 @@ +version: '3.8' + +services: + # Database Render Application + app: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:8080" + volumes: + - ./config:/app/config + - ./data:/app/data + - ./logs:/app/logs + environment: + - CONFIG_FILE=/app/config/config.yaml + depends_on: + - mysql + - postgres + restart: unless-stopped + networks: + - app-network + + # MySQL Database + mysql: + image: mysql:8.0 + container_name: mysql-db + environment: + MYSQL_ROOT_PASSWORD: rootpassword + MYSQL_DATABASE: database_render + MYSQL_USER: appuser + MYSQL_PASSWORD: apppassword + ports: + - "3306:3306" + volumes: + - mysql-data:/var/lib/mysql + - ./config/schema.example.sql:/docker-entrypoint-initdb.d/01-schema.sql + restart: unless-stopped + networks: + - app-network + + # PostgreSQL Database + postgres: + image: postgres:15-alpine + container_name: postgres-db + environment: + POSTGRES_DB: database_render + POSTGRES_USER: appuser + POSTGRES_PASSWORD: apppassword + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + - ./config/schema.example.sql:/docker-entrypoint-initdb.d/01-schema.sql + restart: unless-stopped + networks: + - app-network + + # SQLite Database (for development) + sqlite-dev: + image: alpine:latest + container_name: sqlite-dev + volumes: + - ./data:/data + command: tail -f /dev/null + networks: + - app-network + +volumes: + mysql-data: + postgres-data: + +networks: + app-network: + driver: bridge \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ed23c49 --- /dev/null +++ b/go.mod @@ -0,0 +1,56 @@ +module github.com/rogeecn/database_render + +go 1.24.1 + +require ( + github.com/gofiber/fiber/v3 v3.0.0-beta.5 + github.com/spf13/cobra v1.9.1 + github.com/spf13/viper v1.18.2 + gorm.io/driver/mysql v1.5.2 + gorm.io/driver/postgres v1.5.4 + gorm.io/driver/sqlite v1.5.4 + gorm.io/gorm v1.25.5 +) + +require ( + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-sql-driver/mysql v1.7.1 // indirect + github.com/gofiber/schema v1.6.0 // indirect + github.com/gofiber/utils/v2 v2.0.0-beta.13 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.4.3 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.19 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.1.1 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/tinylib/msgp v1.3.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.64.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/text v0.27.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0c60006 --- /dev/null +++ b/go.sum @@ -0,0 +1,138 @@ +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= +github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/gofiber/fiber/v3 v3.0.0-beta.5 h1:MSGbiQZEYiYOqti2Ip2zMRkN4VvZw7Vo7dwZBa1Qjk8= +github.com/gofiber/fiber/v3 v3.0.0-beta.5/go.mod h1:XmI2Agulde26YcQrA2n8X499I1p98/zfCNbNObVUeP8= +github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY= +github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s= +github.com/gofiber/utils/v2 v2.0.0-beta.13 h1:dlpbGFLveQ9OduL2UHw4dtu4lXE+Gb3bHMc+8Yxp/dk= +github.com/gofiber/utils/v2 v2.0.0-beta.13/go.mod h1:qEZ175nSOkl5xciHmqxwNDsWzwiB39gB8RgU1d3U4mQ= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= +github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= +github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= +github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/shamaton/msgpack/v2 v2.2.3 h1:uDOHmxQySlvlUYfQwdjxyybAOzjlQsD1Vjy+4jmO9NM= +github.com/shamaton/msgpack/v2 v2.2.3/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= +github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.64.0 h1:QBygLLQmiAyiXuRhthf0tuRkqAFcrC42dckN2S+N3og= +github.com/valyala/fasthttp v1.64.0/go.mod h1:dGmFxwkWXSK0NbOSJuF7AMVzU+lkHz0wQVvVITv2UQA= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs= +gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8= +gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= +gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= +gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0= +gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4= +gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..aec23f8 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,236 @@ +package config + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/spf13/viper" +) + +// Config represents the application configuration +type Config struct { + App AppConfig `mapstructure:"app"` + Database DatabaseConfig `mapstructure:"database"` + Tables map[string]TableConfig `mapstructure:"tables"` +} + +// AppConfig holds application-level configuration +type AppConfig struct { + Name string `mapstructure:"name"` + Theme string `mapstructure:"theme"` + Language string `mapstructure:"language"` + Port int `mapstructure:"port"` +} + +// DatabaseConfig holds database connection settings +type DatabaseConfig struct { + Type string `mapstructure:"type"` + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + User string `mapstructure:"user"` + Password string `mapstructure:"password"` + DBName string `mapstructure:"dbname"` + Path string `mapstructure:"path"` + DSN string `mapstructure:"dsn"` +} + +// TableConfig holds table-specific configuration +type TableConfig struct { + Alias string `mapstructure:"alias"` + Layout string `mapstructure:"layout"` + PageSize int `mapstructure:"page_size"` + Fields map[string]FieldConfig `mapstructure:"fields"` + Features map[string]interface{} `mapstructure:"features"` + Options map[string]interface{} `mapstructure:"options"` +} + +// FieldConfig holds field-specific configuration +type FieldConfig struct { + Type string `mapstructure:"type"` + Hidden bool `mapstructure:"hidden"` + Searchable bool `mapstructure:"searchable"` + MaxLength int `mapstructure:"max_length"` + Length int `mapstructure:"length"` + Markdown bool `mapstructure:"markdown"` + Excerpt int `mapstructure:"excerpt"` + Size string `mapstructure:"size"` + Fit string `mapstructure:"fit"` + Format string `mapstructure:"format"` + Relative bool `mapstructure:"relative"` + Colors map[string]string `mapstructure:"colors"` + Separator string `mapstructure:"separator"` + Primary bool `mapstructure:"primary"` + AvatarField string `mapstructure:"avatar_field"` + Suffix string `mapstructure:"suffix"` + Prefix string `mapstructure:"prefix"` + Options map[string]interface{} `mapstructure:"options"` +} + +// LoadConfig loads configuration from file and environment variables +func LoadConfig(configPath string) (*Config, error) { + logger := slog.With("component", "config") + + // Set default values + viper.SetDefault("app.name", "Database Render") + viper.SetDefault("app.theme", "modern") + viper.SetDefault("app.language", "zh-CN") + viper.SetDefault("app.port", 8080) + + // Database defaults + viper.SetDefault("database.type", "sqlite") + viper.SetDefault("database.host", "localhost") + viper.SetDefault("database.port", 3306) + viper.SetDefault("database.dbname", "testdb") + + // Set config file + if configPath != "" { + viper.SetConfigFile(configPath) + } else { + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath(".") + viper.AddConfigPath("./config") + viper.AddConfigPath("/etc/database-render") + } + + // Environment variables + viper.SetEnvPrefix("DR") + viper.AutomaticEnv() + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + + // Read config + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + logger.Error("failed to read config file", "error", err) + return nil, fmt.Errorf("failed to read config: %w", err) + } + logger.Warn("config file not found, using defaults") + } + + // Parse database DSN if provided + if dsn := viper.GetString("database"); dsn != "" { + if err := parseDSN(dsn); err != nil { + return nil, err + } + } + + // Unmarshal config + var config Config + if err := viper.Unmarshal(&config); err != nil { + logger.Error("failed to unmarshal config", "error", err) + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + // Validate config + if err := validateConfig(&config); err != nil { + logger.Error("config validation failed", "error", err) + return nil, fmt.Errorf("config validation failed: %w", err) + } + + logger.Info("configuration loaded successfully", + "config_file", viper.ConfigFileUsed(), + "tables_count", len(config.Tables)) + + return &config, nil +} + +// parseDSN parses database connection string +func parseDSN(dsn string) error { + if strings.HasPrefix(dsn, "sqlite://") { + path := strings.TrimPrefix(dsn, "sqlite://") + viper.Set("database.type", "sqlite") + viper.Set("database.path", path) + return nil + } + + if strings.HasPrefix(dsn, "mysql://") { + dsn = strings.TrimPrefix(dsn, "mysql://") + parts := strings.Split(dsn, "@") + if len(parts) != 2 { + return fmt.Errorf("invalid mysql dsn format") + } + + userPass := strings.Split(parts[0], ":") + if len(userPass) != 2 { + return fmt.Errorf("invalid mysql user:pass format") + } + + hostPort := strings.Split(parts[1], "/") + if len(hostPort) != 2 { + return fmt.Errorf("invalid mysql host/db format") + } + + host := strings.Split(hostPort[0], ":") + if len(host) == 2 { + viper.Set("database.port", host[1]) + } + + viper.Set("database.type", "mysql") + viper.Set("database.user", userPass[0]) + viper.Set("database.password", userPass[1]) + viper.Set("database.host", host[0]) + viper.Set("database.dbname", hostPort[1]) + return nil + } + + if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") { + viper.Set("database.type", "postgres") + viper.Set("database.dsn", dsn) + return nil + } + + return fmt.Errorf("unsupported database dsn format") +} + +// validateConfig validates the loaded configuration +func validateConfig(config *Config) error { + if len(config.Tables) == 0 { + return fmt.Errorf("no tables configured") + } + + for name, table := range config.Tables { + if name == "" { + return fmt.Errorf("table name cannot be empty") + } + if table.Alias == "" { + table.Alias = name + } + if table.Layout == "" { + table.Layout = "card" + } + if table.PageSize == 0 { + table.PageSize = 12 + } + } + + return nil +} + +// GetTableConfig returns configuration for a specific table +func (c *Config) GetTableConfig(tableName string) (*TableConfig, bool) { + config, exists := c.Tables[tableName] + if !exists { + return nil, false + } + return &config, true +} + +// GetDatabaseDSN returns the database connection string +func (c *Config) GetDatabaseDSN() string { + switch c.Database.Type { + case "sqlite": + return c.Database.Path + case "mysql": + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", + c.Database.User, c.Database.Password, c.Database.Host, c.Database.Port, c.Database.DBName) + case "postgres": + if c.Database.DSN != "" { + return c.Database.DSN + } + return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + c.Database.Host, c.Database.Port, c.Database.User, c.Database.Password, c.Database.DBName) + default: + return c.Database.DSN + } +} diff --git a/internal/database/connection.go b/internal/database/connection.go new file mode 100644 index 0000000..f4e4777 --- /dev/null +++ b/internal/database/connection.go @@ -0,0 +1,482 @@ +package database + +import ( + "database/sql" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/rogeecn/database_render/internal/config" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// ConnectionManager manages database connections +type ConnectionManager struct { + db *gorm.DB + sqlDB *sql.DB + config *config.DatabaseConfig + logger *slog.Logger +} + +// NewConnectionManager creates a new database connection manager +func NewConnectionManager(config *config.Config) (*ConnectionManager, error) { + logger := slog.With("component", "database") + + cm := &ConnectionManager{ + config: &config.Database, + logger: logger, + } + + if err := cm.connect(); err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + + if err := cm.configure(); err != nil { + return nil, fmt.Errorf("failed to configure database: %w", err) + } + + logger.Info("database connection established", + "type", config.Database.Type, + "host", config.Database.Host, + "database", config.Database.DBName, + ) + + return cm, nil +} + +// connect establishes the database connection +func (cm *ConnectionManager) connect() error { + var dialector gorm.Dialector + + switch cm.config.Type { + case "sqlite": + dialector = sqlite.Open(cm.config.Path) + case "mysql": + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", + cm.config.User, cm.config.Password, cm.config.Host, cm.config.Port, cm.config.DBName) + dialector = mysql.Open(dsn) + case "postgres": + if cm.config.DSN != "" { + dialector = postgres.Open(cm.config.DSN) + } else { + dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + cm.config.Host, cm.config.Port, cm.config.User, cm.config.Password, cm.config.DBName) + dialector = postgres.Open(dsn) + } + default: + return fmt.Errorf("unsupported database type: %s", cm.config.Type) + } + + gormConfig := &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + NowFunc: func() time.Time { + return time.Now().Local() + }, + } + + db, err := gorm.Open(dialector, gormConfig) + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + + cm.db = db + + // Get underlying sql.DB for connection pooling + sqlDB, err := db.DB() + if err != nil { + return fmt.Errorf("failed to get sql.DB: %w", err) + } + cm.sqlDB = sqlDB + + return nil +} + +// configure sets up connection pool settings +func (cm *ConnectionManager) configure() error { + if cm.sqlDB == nil { + return fmt.Errorf("sql.DB is nil") + } + + // Connection pool settings + cm.sqlDB.SetMaxIdleConns(10) + cm.sqlDB.SetMaxOpenConns(100) + cm.sqlDB.SetConnMaxLifetime(time.Hour) + + // Ping to verify connection + if err := cm.sqlDB.Ping(); err != nil { + return fmt.Errorf("failed to ping database: %w", err) + } + + return nil +} + +// GetDB returns the GORM database instance +func (cm *ConnectionManager) GetDB() *gorm.DB { + return cm.db +} + +// GetSQLDB returns the underlying SQL database instance +func (cm *ConnectionManager) GetSQLDB() *sql.DB { + return cm.sqlDB +} + +// Close closes the database connection +func (cm *ConnectionManager) Close() error { + if cm.sqlDB != nil { + return cm.sqlDB.Close() + } + return nil +} + +// Health checks the database health +func (cm *ConnectionManager) Health() error { + if cm.sqlDB == nil { + return fmt.Errorf("database not initialized") + } + return cm.sqlDB.Ping() +} + +// GetTableNames returns all table names in the database +func (cm *ConnectionManager) GetTableNames() ([]string, error) { + var tableNames []string + + switch cm.config.Type { + case "sqlite": + rows, err := cm.db.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Rows() + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + tableNames = append(tableNames, name) + } + + case "mysql": + rows, err := cm.db.Raw("SHOW TABLES").Rows() + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + tableNames = append(tableNames, name) + } + + case "postgres": + rows, err := cm.db.Raw("SELECT tablename FROM pg_tables WHERE schemaname = 'public'").Rows() + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + tableNames = append(tableNames, name) + } + } + + return tableNames, nil +} + +// GetTableColumns returns column information for a table +func (cm *ConnectionManager) GetTableColumns(tableName string) ([]ColumnInfo, error) { + var columns []ColumnInfo + + switch cm.config.Type { + case "sqlite": + rows, err := cm.db.Raw(fmt.Sprintf("PRAGMA table_info(%s)", tableName)).Rows() + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var col ColumnInfo + var notused interface{} + if err := rows.Scan(&col.Position, &col.Name, &col.Type, &col.NotNull, ¬used, &col.DefaultValue); err != nil { + return nil, err + } + col.DatabaseType = cm.config.Type + columns = append(columns, col) + } + + case "mysql": + rows, err := cm.db.Raw(fmt.Sprintf("DESCRIBE %s", tableName)).Rows() + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var col ColumnInfo + var key, extra, nullStr string + if err := rows.Scan(&col.Name, &col.Type, &nullStr, &key, &col.DefaultValue, &extra); err != nil { + return nil, err + } + col.NotNull = nullStr == "NO" + col.DatabaseType = cm.config.Type + columns = append(columns, col) + } + + case "postgres": + query := ` + SELECT + column_name, + data_type, + is_nullable, + column_default + FROM information_schema.columns + WHERE table_name = $1 + ORDER BY ordinal_position + ` + rows, err := cm.db.Raw(query, tableName).Rows() + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var col ColumnInfo + var nullable string + if err := rows.Scan(&col.Name, &col.Type, &nullable, &col.DefaultValue); err != nil { + return nil, err + } + col.NotNull = nullable == "NO" + col.DatabaseType = cm.config.Type + columns = append(columns, col) + } + } + + return columns, nil +} + +// ColumnInfo represents database column information +type ColumnInfo struct { + Name string + Type string + NotNull bool + DefaultValue interface{} + Position int + DatabaseType string +} + +// GetTableData retrieves paginated data from a table +func (cm *ConnectionManager) GetTableData( + tableName string, + page, pageSize int, + search string, + sortField string, + sortOrder string, +) ([]map[string]interface{}, int64, error) { + var total int64 + var data []map[string]interface{} + + // Build count query for pagination + countQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s", tableName) + if search != "" { + // For search, we need to get column names first + columns, err := cm.GetTableColumns(tableName) + if err != nil { + return nil, 0, err + } + + // Build WHERE clause for text columns + var whereConditions []string + for _, col := range columns { + if cm.isSearchableColumn(col.Type) { + whereConditions = append(whereConditions, fmt.Sprintf("%s LIKE '%%%s%%'", col.Name, search)) + } + } + + if len(whereConditions) > 0 { + countQuery += " WHERE " + strings.Join(whereConditions, " OR ") + } + } + + if err := cm.db.Raw(countQuery).Scan(&total).Error; err != nil { + return nil, 0, err + } + + // Build data query + dataQuery := fmt.Sprintf("SELECT * FROM %s", tableName) + if search != "" { + columns, err := cm.GetTableColumns(tableName) + if err != nil { + return nil, 0, err + } + + var whereConditions []string + for _, col := range columns { + if cm.isSearchableColumn(col.Type) { + whereConditions = append(whereConditions, fmt.Sprintf("%s LIKE '%%%s%%'", col.Name, search)) + } + } + + if len(whereConditions) > 0 { + dataQuery += " WHERE " + strings.Join(whereConditions, " OR ") + } + } + + // Add sorting + if sortField != "" { + order := "ASC" + if sortOrder == "desc" { + order = "DESC" + } + dataQuery += fmt.Sprintf(" ORDER BY %s %s", sortField, order) + } + + // Add pagination + offset := (page - 1) * pageSize + dataQuery += fmt.Sprintf(" LIMIT %d OFFSET %d", pageSize, offset) + + // Execute query + rows, err := cm.db.Raw(dataQuery).Rows() + if err != nil { + return nil, 0, err + } + defer rows.Close() + + // Get column names + columnNames, err := rows.Columns() + if err != nil { + return nil, 0, err + } + + // Scan data + for rows.Next() { + values := make([]interface{}, len(columnNames)) + valuePtrs := make([]interface{}, len(columnNames)) + + for i := range values { + valuePtrs[i] = &values[i] + } + + if err := rows.Scan(valuePtrs...); err != nil { + return nil, 0, err + } + + row := make(map[string]interface{}) + for i, col := range columnNames { + val := values[i] + + // Handle NULL values + if val == nil { + row[col] = nil + continue + } + + // Convert []byte to string for JSON compatibility + if b, ok := val.([]byte); ok { + row[col] = string(b) + } else { + row[col] = val + } + } + + data = append(data, row) + } + + return data, total, nil +} + +// isSearchableColumn determines if a column type is searchable +func (cm *ConnectionManager) isSearchableColumn(columnType string) bool { + searchableTypes := []string{ + "VARCHAR", "TEXT", "CHAR", "STRING", + "varchar", "text", "char", "string", + } + + for _, t := range searchableTypes { + if strings.Contains(strings.ToUpper(columnType), strings.ToUpper(t)) { + return true + } + } + return false +} + +// GetTableDataByID retrieves a single record by ID +func (cm *ConnectionManager) GetTableDataByID(tableName string, id interface{}) (map[string]interface{}, error) { + // Find primary key column + columns, err := cm.GetTableColumns(tableName) + if err != nil { + return nil, err + } + + var primaryKey string + for _, col := range columns { + // Assume 'id' is the primary key if it exists + if col.Name == "id" || col.Name == "ID" { + primaryKey = col.Name + break + } + } + + if primaryKey == "" { + // Fallback to first column + primaryKey = columns[0].Name + } + + query := fmt.Sprintf("SELECT * FROM %s WHERE %s = ?", tableName, primaryKey) + + rows, err := cm.db.Raw(query, id).Rows() + if err != nil { + return nil, err + } + defer rows.Close() + + columnNames, err := rows.Columns() + if err != nil { + return nil, err + } + + if rows.Next() { + values := make([]interface{}, len(columnNames)) + valuePtrs := make([]interface{}, len(columnNames)) + + for i := range values { + valuePtrs[i] = &values[i] + } + + if err := rows.Scan(valuePtrs...); err != nil { + return nil, err + } + + row := make(map[string]interface{}) + for i, col := range columnNames { + val := values[i] + + if val == nil { + row[col] = nil + continue + } + + if b, ok := val.([]byte); ok { + row[col] = string(b) + } else { + row[col] = val + } + } + + return row, nil + } + + return nil, fmt.Errorf("record not found") +} diff --git a/internal/handler/data_handler.go b/internal/handler/data_handler.go new file mode 100644 index 0000000..ec1eddf --- /dev/null +++ b/internal/handler/data_handler.go @@ -0,0 +1,161 @@ +package handler + +import ( + "fmt" + "log/slog" + "net/http" + "strconv" + + "github.com/gofiber/fiber/v3" + "github.com/rogeecn/database_render/internal/service" +) + +// DataHandler handles HTTP requests for data operations +type DataHandler struct { + service *service.DataService + logger *slog.Logger +} + +// NewDataHandler creates a new data handler +func NewDataHandler(service *service.DataService) *DataHandler { + return &DataHandler{ + service: service, + logger: slog.With("component", "handler"), + } +} + +// GetTables returns all configured tables +func (h *DataHandler) GetTables(c fiber.Ctx) error { + tables, err := h.service.GetTables() + if err != nil { + h.logger.Error("failed to get tables", "error", err) + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to get tables", + }) + } + + h.logger.Debug("retrieved tables", "count", len(tables)) + return c.JSON(fiber.Map{ + "tables": tables, + }) +} + +// GetTableData returns paginated data for a table +func (h *DataHandler) GetTableData(c fiber.Ctx) error { + tableName := c.Params("table") + if tableName == "" { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "error": "Table name is required", + }) + } + + // Parse query parameters + page, err := strconv.Atoi(c.Query("page", "1")) + if err != nil || page <= 0 { + page = 1 + } + + pageSize, err := strconv.Atoi(c.Query("per_page", "10")) + if err != nil || pageSize <= 0 { + pageSize = 10 + } + + search := c.Query("search", "") + sortField := c.Query("sort", "") + sortOrder := c.Query("order", "asc") + + // Validate sort order + if sortOrder != "asc" && sortOrder != "desc" { + sortOrder = "asc" + } + + // Get table data + data, err := h.service.GetTableData(tableName, page, pageSize, search, sortField, sortOrder) + if err != nil { + h.logger.Error("failed to get table data", + "table", tableName, + "page", page, + "error", err) + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ + "error": fmt.Sprintf("Failed to get table data: %v", err), + }) + } + + return c.JSON(data) +} + +// GetTableDetail returns a single record detail +func (h *DataHandler) GetTableDetail(c fiber.Ctx) error { + tableName := c.Params("table") + if tableName == "" { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "error": "Table name is required", + }) + } + + id := c.Params("id") + if id == "" { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "error": "ID is required", + }) + } + + // Get table detail + detail, err := h.service.GetTableDetail(tableName, id) + if err != nil { + h.logger.Error("failed to get table detail", + "table", tableName, + "id", id, + "error", err) + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ + "error": fmt.Sprintf("Failed to get table detail: %v", err), + }) + } + + return c.JSON(detail) +} + +// GetTableConfig returns the configuration for a specific table +func (h *DataHandler) GetTableConfig(c fiber.Ctx) error { + tableName := c.Params("table") + if tableName == "" { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "error": "Table name is required", + }) + } + + config, err := h.service.GetTableConfig(tableName) + if err != nil { + h.logger.Error("failed to get table config", + "table", tableName, + "error", err) + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ + "error": fmt.Sprintf("Failed to get table config: %v", err), + }) + } + + return c.JSON(config) +} + +// Health returns the health status +func (h *DataHandler) Health(c fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "ok", + "message": "Service is healthy", + }) +} + +// SetupRoutes sets up all the routes for the data handler +func (h *DataHandler) SetupRoutes(app *fiber.App) { + // API routes + api := app.Group("/api") + + // Table routes + api.Get("/tables", h.GetTables) + api.Get("/data/:table", h.GetTableData) + api.Get("/data/:table/detail/:id", h.GetTableDetail) + api.Get("/config/:table", h.GetTableConfig) + + // Health check + api.Get("/health", h.Health) +} \ No newline at end of file diff --git a/internal/model/table_config.go b/internal/model/table_config.go new file mode 100644 index 0000000..30b787e --- /dev/null +++ b/internal/model/table_config.go @@ -0,0 +1,69 @@ +package model + +// TableConfig represents the configuration for a database table +type TableConfig struct { + Name string `json:"name" yaml:"name"` + Alias string `json:"alias" yaml:"alias"` + PageSize int `json:"page_size" yaml:"page_size"` + Columns []ColumnConfig `json:"columns" yaml:"columns"` + Filters []FilterConfig `json:"filters" yaml:"filters"` + SortFields []string `json:"sort_fields" yaml:"sort_fields"` + Options map[string]interface{} `json:"options" yaml:"options"` +} + +// ColumnConfig represents the configuration for a table column +type ColumnConfig struct { + Name string `json:"name" yaml:"name"` + Alias string `json:"alias" yaml:"alias"` + RenderType string `json:"render_type" yaml:"render_type"` + Sortable bool `json:"sortable" yaml:"sortable"` + Searchable bool `json:"searchable" yaml:"searchable"` + ShowInList bool `json:"show_in_list" yaml:"show_in_list"` + IsPrimaryContent bool `json:"is_primary_content" yaml:"is_primary_content"` + MaxLength int `json:"max_length" yaml:"max_length"` + Width string `json:"width" yaml:"width"` + Format string `json:"format" yaml:"format"` + Values map[string]TagValue `json:"values" yaml:"values"` + Options map[string]interface{} `json:"options" yaml:"options"` +} + +// FilterConfig represents the configuration for a table filter +type FilterConfig struct { + Name string `json:"name" yaml:"name"` + Type string `json:"type" yaml:"type"` + Options []interface{} `json:"options" yaml:"options"` +} + +// TagValue represents a tag value with label and color +type TagValue struct { + Label string `json:"label" yaml:"label"` + Color string `json:"color" yaml:"color"` +} + +// DataResponse represents the API response structure +type DataResponse struct { + Data []map[string]interface{} `json:"data"` + Total int64 `json:"total"` + Page int `json:"page"` + PerPage int `json:"per_page"` + Pages int `json:"pages"` + Table string `json:"table"` + Columns []ColumnConfig `json:"columns"` + Filters []FilterConfig `json:"filters"` +} + +// TableResponse represents the table list response +type TableResponse struct { + Tables []TableInfo `json:"tables"` +} + +// TableInfo represents basic table information +type TableInfo struct { + Name string `json:"name"` + Alias string `json:"alias"` +} + +// DetailResponse represents a single record detail response +type DetailResponse struct { + Data map[string]interface{} `json:"data"` +} diff --git a/internal/repository/data_repository.go b/internal/repository/data_repository.go new file mode 100644 index 0000000..705a685 --- /dev/null +++ b/internal/repository/data_repository.go @@ -0,0 +1,192 @@ +package repository + +import ( + "fmt" + "log/slog" + + "github.com/rogeecn/database_render/internal/config" + "github.com/rogeecn/database_render/internal/database" + "github.com/rogeecn/database_render/internal/model" +) + +// DataRepository handles data access operations +type DataRepository struct { + db *database.ConnectionManager + config *config.Config + logger *slog.Logger +} + +// NewDataRepository creates a new data repository +func NewDataRepository(db *database.ConnectionManager, cfg *config.Config) *DataRepository { + return &DataRepository{ + db: db, + config: cfg, + logger: slog.With("component", "repository"), + } +} + +// GetTables returns all configured tables +func (r *DataRepository) GetTables() ([]model.TableInfo, error) { + var tables []model.TableInfo + + for name, tableConfig := range r.config.Tables { + tables = append(tables, model.TableInfo{ + Name: name, + Alias: tableConfig.Alias, + }) + } + + return tables, nil +} + +// GetTableConfig returns the configuration for a specific table +func (r *DataRepository) GetTableConfig(tableName string) (*model.TableConfig, error) { + tableConfig, exists := r.config.Tables[tableName] + if !exists { + return nil, fmt.Errorf("table %s not found in configuration", tableName) + } + + // Convert internal config to model config + config := &model.TableConfig{ + Name: tableName, + Alias: tableConfig.Alias, + PageSize: tableConfig.PageSize, + Columns: []model.ColumnConfig{}, + Filters: []model.FilterConfig{}, + Options: tableConfig.Options, + } + + // Convert field configurations + for fieldName, fieldConfig := range tableConfig.Fields { + column := model.ColumnConfig{ + Name: fieldName, + Alias: fieldName, + RenderType: fieldConfig.Type, + Sortable: true, // Default to true + Searchable: fieldConfig.Searchable, + ShowInList: !fieldConfig.Hidden, + IsPrimaryContent: fieldConfig.Markdown, + MaxLength: fieldConfig.MaxLength, + Width: fieldConfig.Size, + Format: fieldConfig.Format, + Values: make(map[string]model.TagValue), + Options: fieldConfig.Options, + } + + // Handle tag values if colors are provided + if len(fieldConfig.Colors) > 0 { + for key, color := range fieldConfig.Colors { + label := key + column.Values[key] = model.TagValue{ + Label: label, + Color: color, + } + } + } + + config.Columns = append(config.Columns, column) + } + + return config, nil +} + +// GetTableData retrieves paginated data from a table +func (r *DataRepository) GetTableData(tableName string, page, pageSize int, search string, sortField string, sortOrder string) ([]map[string]interface{}, int64, error) { + // Validate table exists in config + _, exists := r.config.Tables[tableName] + if !exists { + return nil, 0, fmt.Errorf("table %s not found in configuration", tableName) + } + + // Get data from database + data, total, err := r.db.GetTableData(tableName, page, pageSize, search, sortField, sortOrder) + if err != nil { + r.logger.Error("failed to get table data", + "table", tableName, + "page", page, + "error", err) + return nil, 0, fmt.Errorf("failed to get table data: %w", err) + } + + r.logger.Debug("retrieved table data", + "table", tableName, + "page", page, + "pageSize", pageSize, + "total", total, + "records", len(data)) + + return data, total, nil +} + +// GetTableDataByID retrieves a single record by ID +func (r *DataRepository) GetTableDataByID(tableName string, id interface{}) (map[string]interface{}, error) { + // Validate table exists in config + _, exists := r.config.Tables[tableName] + if !exists { + return nil, fmt.Errorf("table %s not found in configuration", tableName) + } + + // Get data from database + data, err := r.db.GetTableDataByID(tableName, id) + if err != nil { + r.logger.Error("failed to get table data by ID", + "table", tableName, + "id", id, + "error", err) + return nil, fmt.Errorf("failed to get table data by ID: %w", err) + } + + r.logger.Debug("retrieved single record", + "table", tableName, + "id", id) + + return data, nil +} + +// GetTableColumns returns column information for a table +func (r *DataRepository) GetTableColumns(tableName string) ([]database.ColumnInfo, error) { + // Validate table exists in config + _, exists := r.config.Tables[tableName] + if !exists { + return nil, fmt.Errorf("table %s not found in configuration", tableName) + } + + // Get column information from database + columns, err := r.db.GetTableColumns(tableName) + if err != nil { + r.logger.Error("failed to get table columns", + "table", tableName, + "error", err) + return nil, fmt.Errorf("failed to get table columns: %w", err) + } + + return columns, nil +} + +// ValidateTableConfig validates if the configured tables exist in the database +func (r *DataRepository) ValidateTableConfig() error { + // Get all table names from database + dbTables, err := r.db.GetTableNames() + if err != nil { + return fmt.Errorf("failed to get table names from database: %w", err) + } + + // Create a map for quick lookup + dbTableMap := make(map[string]bool) + for _, table := range dbTables { + dbTableMap[table] = true + } + + // Check if all configured tables exist + for tableName := range r.config.Tables { + if !dbTableMap[tableName] { + return fmt.Errorf("table %s not found in database", tableName) + } + } + + r.logger.Info("table configuration validation completed", + "configured_tables", len(r.config.Tables), + "database_tables", len(dbTables)) + + return nil +} diff --git a/internal/service/data_service.go b/internal/service/data_service.go new file mode 100644 index 0000000..f6b9c2f --- /dev/null +++ b/internal/service/data_service.go @@ -0,0 +1,204 @@ +package service + +import ( + "fmt" + "log/slog" + + "github.com/rogeecn/database_render/internal/config" + "github.com/rogeecn/database_render/internal/database" + "github.com/rogeecn/database_render/internal/model" + "github.com/rogeecn/database_render/internal/repository" +) + +// DataService handles business logic for data operations +type DataService struct { + repo *repository.DataRepository + config *config.Config + logger *slog.Logger +} + +// NewDataService creates a new data service +func NewDataService(repo *repository.DataRepository, cfg *config.Config) *DataService { + return &DataService{ + repo: repo, + config: cfg, + logger: slog.With("component", "service"), + } +} + +// GetTables returns all configured tables +func (s *DataService) GetTables() ([]model.TableInfo, error) { + tables, err := s.repo.GetTables() + if err != nil { + s.logger.Error("failed to get tables", "error", err) + return nil, err + } + + s.logger.Debug("retrieved tables", "count", len(tables)) + return tables, nil +} + +// GetTableData returns paginated data for a table +func (s *DataService) GetTableData(tableName string, page, pageSize int, search string, sortField string, sortOrder string) (*model.DataResponse, error) { + // Validate table exists + tableConfig, err := s.repo.GetTableConfig(tableName) + if err != nil { + s.logger.Error("failed to get table config", "table", tableName, "error", err) + return nil, err + } + + // Use configured page size if not provided + if pageSize <= 0 { + pageSize = tableConfig.PageSize + } + + // Validate page and page size + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 100 { + pageSize = 100 // Limit maximum page size + } + + // Get data from repository + data, total, err := s.repo.GetTableData(tableName, page, pageSize, search, sortField, sortOrder) + if err != nil { + s.logger.Error("failed to get table data", + "table", tableName, + "page", page, + "pageSize", pageSize, + "search", search, + "error", err) + return nil, err + } + + // Calculate total pages + totalPages := int((total + int64(pageSize) - 1) / int64(pageSize)) + + response := &model.DataResponse{ + Data: data, + Total: total, + Page: page, + PerPage: pageSize, + Pages: totalPages, + Table: tableName, + Columns: tableConfig.Columns, + Filters: tableConfig.Filters, + } + + s.logger.Info("retrieved table data", + "table", tableName, + "page", page, + "pageSize", pageSize, + "total", total, + "pages", totalPages) + + return response, nil +} + +// GetTableDetail returns a single record detail +func (s *DataService) GetTableDetail(tableName string, id interface{}) (*model.DetailResponse, error) { + // Validate table exists + _, err := s.repo.GetTableConfig(tableName) + if err != nil { + s.logger.Error("failed to get table config", "table", tableName, "error", err) + return nil, err + } + + // Get data from repository + data, err := s.repo.GetTableDataByID(tableName, id) + if err != nil { + s.logger.Error("failed to get table detail", + "table", tableName, + "id", id, + "error", err) + return nil, err + } + + response := &model.DetailResponse{ + Data: data, + } + + s.logger.Debug("retrieved table detail", + "table", tableName, + "id", id) + + return response, nil +} + +// GetTableColumns returns column information for a table +func (s *DataService) GetTableColumns(tableName string) ([]database.ColumnInfo, error) { + // Validate table exists + _, err := s.repo.GetTableConfig(tableName) + if err != nil { + s.logger.Error("failed to get table config", "table", tableName, "error", err) + return nil, err + } + + // Get column information from repository + columns, err := s.repo.GetTableColumns(tableName) + if err != nil { + s.logger.Error("failed to get table columns", + "table", tableName, + "error", err) + return nil, err + } + + return columns, nil +} + +// ValidateConfiguration validates the entire configuration +func (s *DataService) ValidateConfiguration() error { + // Validate tables configuration + if len(s.config.Tables) == 0 { + return fmt.Errorf("no tables configured") + } + + // Validate table existence in database + if err := s.repo.ValidateTableConfig(); err != nil { + return err + } + + // Validate individual table configurations + for tableName, tableConfig := range s.config.Tables { + if tableConfig.Alias == "" { + return fmt.Errorf("table %s has empty alias", tableName) + } + + if tableConfig.PageSize <= 0 { + return fmt.Errorf("table %s has invalid page size", tableName) + } + + // Validate field configurations + for fieldName, fieldConfig := range tableConfig.Fields { + if fieldConfig.Type == "" { + return fmt.Errorf("field %s in table %s has empty type", fieldName, tableName) + } + } + } + + s.logger.Info("configuration validation completed successfully") + return nil +} + +// GetTableConfig returns the configuration for a specific table +func (s *DataService) GetTableConfig(tableName string) (*model.TableConfig, error) { + return s.repo.GetTableConfig(tableName) +} + +// GetDefaultTable returns the first configured table name +func (s *DataService) GetDefaultTable() (string, error) { + if len(s.config.Tables) == 0 { + return "", fmt.Errorf("no tables configured") + } + + // Return the first table name + for tableName := range s.config.Tables { + return tableName, nil + } + + return "", fmt.Errorf("no tables configured") +} diff --git a/internal/template/renderer.go b/internal/template/renderer.go new file mode 100644 index 0000000..21ea72e --- /dev/null +++ b/internal/template/renderer.go @@ -0,0 +1,250 @@ +package template + +import ( + "encoding/json" + "fmt" + "html/template" + "log/slog" + "net/http" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/static" + "github.com/rogeecn/database_render/internal/config" + "github.com/rogeecn/database_render/internal/service" +) + +// Renderer handles template rendering +type Renderer struct { + templates *template.Template + service *service.DataService + config *config.Config + logger *slog.Logger +} + +// NewRenderer creates a new template renderer +func NewRenderer(service *service.DataService, cfg *config.Config) (*Renderer, error) { + r := &Renderer{ + service: service, + config: cfg, + logger: slog.With("component", "renderer"), + } + + if err := r.loadTemplates(); err != nil { + return nil, fmt.Errorf("failed to load templates: %w", err) + } + + return r, nil +} + +// loadTemplates loads all templates from the templates directory +func (r *Renderer) loadTemplates() error { + // Define template functions + funcMap := template.FuncMap{ + "dict": func(values ...interface{}) map[string]interface{} { + dict := make(map[string]interface{}) + for i := 0; i < len(values); i += 2 { + if i+1 < len(values) { + key := values[i].(string) + dict[key] = values[i+1] + } + } + return dict + }, + "add": func(a, b int) int { return a + b }, + "sub": func(a, b int) int { return a - b }, + "mul": func(a, b int) int { return a * b }, + "min": func(a, b int) int { + if a < b { + return a + } + return b + }, + "max": func(a, b int) int { + if a > b { + return a + } + return b + }, + "json": func(v interface{}) string { + b, _ := json.Marshal(v) + return string(b) + }, + "split": func(s string, sep string) []string { + return strings.Split(s, sep) + }, + "formatTime": func(t interface{}) string { + switch v := t.(type) { + case time.Time: + return v.Format("2006-01-02 15:04:05") + case string: + return v + default: + return fmt.Sprintf("%v", v) + } + }, + "renderField": func(value interface{}, renderType string, column interface{}) template.HTML { + switch renderType { + case "time": + return template.HTML(r.formatTime(value)) + case "tag": + if columnMap, ok := column.(map[string]interface{}); ok { + if values, ok := columnMap["values"].(map[string]interface{}); ok { + if tag, ok := values[fmt.Sprintf("%v", value)].(map[string]interface{}); ok { + color := tag["color"].(string) + label := tag["label"].(string) + return template.HTML(fmt.Sprintf( + ` %s `, + color, label, + )) + } + } + } + return template.HTML(fmt.Sprintf("%v", value)) + case "markdown": + // Return raw content for client-side markdown rendering + return template.HTML(fmt.Sprintf("%v", value)) + default: + return template.HTML(fmt.Sprintf("%v", value)) + } + }, + "truncate": func(s string, length int) string { + if len(s) <= length { + return s + } + if length > 3 { + return s[:length-3] + "..." + } + return s[:length] + }, + "eq": func(a, b interface{}) bool { + return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b) + }, + } + + // Load templates + templateDir := "web/templates" + pattern := filepath.Join(templateDir, "*.html") + tmpl, err := template.New("").Funcs(funcMap).ParseGlob(pattern) + if err != nil { + return fmt.Errorf("failed to parse templates: %w", err) + } + + r.templates = tmpl + r.logger.Info("templates loaded successfully") + return nil +} + +// formatTime formats time values for display +func (r *Renderer) formatTime(value interface{}) string { + switch v := value.(type) { + case time.Time: + return v.Format("2006-01-02 15:04:05") + case string: + // Try to parse as time + if t, err := time.Parse("2006-01-02T15:04:05Z", v); err == nil { + return t.Format("2006-01-02 15:04:05") + } + return v + default: + return fmt.Sprintf("%v", v) + } +} + +// RenderList renders the list view for a table +func (r *Renderer) RenderList(c fiber.Ctx, tableName string) error { + // Parse query parameters + page, _ := strconv.Atoi(c.Query("page", "1")) + pageSize, _ := strconv.Atoi(c.Query("per_page", "10")) + search := c.Query("search", "") + sortField := c.Query("sort", "") + sortOrder := c.Query("order", "asc") + + // Get table data + data, err := r.service.GetTableData(tableName, page, pageSize, search, sortField, sortOrder) + if err != nil { + r.logger.Error("failed to get table data", "error", err) + return c.Status(http.StatusInternalServerError).SendString("Failed to load table data") + } + + // Get all tables for navigation + tables, err := r.service.GetTables() + if err != nil { + r.logger.Error("failed to get tables", "error", err) + return c.Status(http.StatusInternalServerError).SendString("Failed to load tables") + } + + // Get table alias from config + tableConfig, err := r.service.GetTableConfig(tableName) + if err != nil { + r.logger.Error("failed to get table config", "error", err) + return c.Status(http.StatusInternalServerError).SendString("Failed to load table configuration") + } + + // Prepare template data + templateData := map[string]interface{}{ + "Table": tableName, + "TableAlias": tableConfig.Alias, + "Columns": data.Columns, + "Data": data.Data, + "Total": data.Total, + "Page": data.Page, + "PerPage": data.PerPage, + "Pages": data.Pages, + "Search": search, + "SortField": sortField, + "SortOrder": sortOrder, + "Tables": tables, + "CurrentPath": c.Path(), + } + + // set content-type html + c.Response().Header.Set("Content-Type", "text/html; charset=utf-8") + + // Render template + return r.templates.ExecuteTemplate(c.Response().BodyWriter(), "list.html", templateData) +} + +// RenderIndex renders the index page +func (r *Renderer) RenderIndex(c fiber.Ctx) error { + // Get default table + defaultTable, err := r.service.GetDefaultTable() + if err != nil { + r.logger.Error("failed to get default table", "error", err) + return c.Status(http.StatusInternalServerError).SendString("No tables configured") + } + + // Redirect to default table + return c.Redirect().To(fmt.Sprintf("/?table=%s", defaultTable)) +} + +// ServeStatic serves static files +func (r *Renderer) ServeStatic(app *fiber.App) { + // Serve static files + app.Use("/static/*", static.New("web/static")) + app.Use("/css/*", static.New("web/static/css")) + app.Use("/js/*", static.New("web/static/js")) + app.Use("/images/*", static.New("web/static/images")) +} + +// NotFoundHandler handles 404 errors +func (r *Renderer) NotFoundHandler(c fiber.Ctx) error { + return c.Status(http.StatusNotFound).SendString("Page not found") +} + +// ErrorHandler handles errors +func (r *Renderer) ErrorHandler(c fiber.Ctx, err error) error { + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + + r.logger.Error("request error", "error", err, "code", code) + + return c.Status(code).JSON(fiber.Map{ + "error": err.Error(), + }) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..ac1c280 --- /dev/null +++ b/main.go @@ -0,0 +1,10 @@ +/* +Copyright © 2025 NAME HERE +*/ +package main + +import "github.com/rogeecn/database_render/cmd" + +func main() { + cmd.Execute() +} diff --git a/requirements.md b/requirements.md new file mode 100644 index 0000000..6a44b6b --- /dev/null +++ b/requirements.md @@ -0,0 +1,470 @@ +# 数据库动态渲染系统 - 完整需求文档 + +## 项目概述 + +构建一个可配置的数据渲染应用,通过读取 YAML 配置文件连接到指定数据库,根据配置查询不同数据表的内容,并按照预设的格式和模板进行处理,最终通过通用的列表页面向前端用户分页展示数据。 + +## 技术栈与框架选型 + +### 后端技术栈 + +- **HTTP 框架**: `github.com/gofiber/fiber/v3` - 高性能、零内存分配的 Go Web 框架 +- **数据库 ORM**: `gorm.io/gorm` - 强大的数据库 ORM,支持多种数据库 +- **日志系统**: `log/slog` - Go 官方结构化日志库 +- **配置管理**: `github.com/spf13/viper` - 支持动态监听配置文件修改 +- **依赖注入**: `github.com/google/wire` - Google 的依赖注入工具 + +### 数据库支持 + +- **SQLite**: `github.com/mattn/go-sqlite3` - 轻量级嵌入式数据库 +- **MySQL**: `github.com/go-sql-driver/mysql` - MySQL 驱动 +- **PostgreSQL**: `github.com/lib/pq` - PostgreSQL 驱动 + +### 前端技术栈 + +- **HTML 模板**: Go 标准库 `html/template` - 服务端模板渲染 +- **CSS 框架**: TailwindCSS - 实用优先的 CSS 框架 +- **JavaScript**: 原生 JavaScript (ES6+) - 轻量级交互 +- **Markdown 渲染**: `marked.js` - Markdown 转 HTML + +## 核心功能需求 + +### 1. 配置系统 (YAML) + +#### 1.1 配置文件结构 + +```yaml +database: + type: sqlite + path: ./data/content.db + # MySQL配置示例 + # type: mysql + # host: localhost + # port: 3306 + # user: root + # password: password + # dbname: testdb + +tables: + - name: "articles" + alias: "技术文章" + page_size: 15 + columns: + - name: "id" + alias: "ID" + render_type: "raw" + sortable: true + width: "80px" + + - name: "title" + alias: "标题" + render_type: "raw" + searchable: true + max_length: 50 + + - name: "content" + alias: "内容" + render_type: "markdown" + is_primary_content: true # 弹窗展示全文 + show_in_list: false + + - name: "category" + alias: "分类" + render_type: "category" + + - name: "tags" + alias: "标签" + render_type: "tag" + values: + 1: { label: "Go", color: "#00ADD8" } + 2: { label: "JavaScript", color: "#f7df1e" } + + - name: "created_at" + alias: "发布时间" + render_type: "time" + format: "2006-01-02 15:04:05" + sortable: true + + filters: + - name: "category" + type: "select" + options: ["全部", "技术", "生活"] + + - name: "logs" + alias: "系统日志" + page_size: 50 + columns: + - name: "id" + alias: "ID" + render_type: "raw" + + - name: "level" + alias: "级别" + render_type: "tag" + values: + 1: { label: "INFO", color: "#52c41a" } + 2: { label: "WARN", color: "#faad14" } + 3: { label: "ERROR", color: "#f5222d" } + + - name: "message" + alias: "日志信息" + render_type: "raw" + is_primary_content: true + + - name: "timestamp" + alias: "时间" + render_type: "time" + format: "2006-01-02 15:04:05" +``` + +### 2. 字段渲染类型规范 + +| 类型 | 描述 | 配置参数 | 示例 | +| ---------- | ------------- | ------------------ | ------------------- | +| `raw` | 原始文本 | 无 | 普通文本显示 | +| `markdown` | Markdown 渲染 | `max_length` | 文章内容 | +| `time` | 时间格式化 | `format` | 2024-01-01 12:00:00 | +| `tag` | 标签样式 | `values`, `colors` | 状态标签 | +| `category` | 分类显示 | 无 | 分类名称 | +| `html` | HTML 内容 | `sanitize` | 富文本内容 | + +### 3. 后端 API 接口 + +#### 3.1 获取表列表 + +```http +GET /api/tables +``` + +响应: + +```json +{ + "tables": ["技术文章", "系统日志"] +} +``` + +#### 3.2 获取分页数据 + +```http +GET /api/data/{table_alias}?page=1&per_page=20&search=keyword&sort=created_at&order=desc +``` + +响应: + +```json +{ + "data": [ + { + "id": 1, + "title": "Go语言入门", + "category": "技术", + "tags": "Go", + "created_at": "2024-01-01 12:00:00" + } + ], + "total": 100, + "page": 1, + "per_page": 20, + "pages": 5 +} +``` + +### 4. Go 模板引擎规范 + +#### 4.1 通用列表模板 + +```html + + + + + + {{.TableAlias}} - 数据管理 + + + + + +
+ +
+ +
+ + +
+ + + + {{range .Columns}} {{if .ShowInList}} + + {{end}} {{end}} + + + + + {{range .Data}} + + {{range $.Columns}} {{if .ShowInList}} + + {{end}} {{end}} + + + {{end}} + +
+ {{.Alias}} + + 操作 +
+ {{template "render_field" dict "Value" (index $.Data (printf + "%s" .Name)) "Type" .RenderType "Column" .}} + + +
+
+ + +
+
+ 共 {{.Total}} 条记录,第 {{.Page}} / {{.Pages}} 页 +
+
+ {{if gt .Page 1}} + 上一页 + {{end}} {{if lt .Page .Pages}} + 下一页 + {{end}} +
+
+
+ + + + + + + +``` + +#### 4.2 模板辅助函数 + +```go +// 模板函数注册 +func templateFuncs() template.FuncMap { + return template.FuncMap{ + "dict": func(values ...interface{}) map[string]interface{} { + dict := make(map[string]interface{}) + for i := 0; i < len(values); i += 2 { + key := values[i].(string) + dict[key] = values[i+1] + } + return dict + }, + "add": func(a, b int) int { return a + b }, + "sub": func(a, b int) int { return a - b }, + "json": func(v interface{}) string { + b, _ := json.Marshal(v) + return string(b) + }, + "renderField": func(value interface{}, renderType string, column interface{}) template.HTML { + switch renderType { + case "time": + if t, ok := value.(time.Time); ok { + return template.HTML(t.Format("2006-01-02 15:04:05")) + } + return template.HTML(fmt.Sprintf("%v", value)) + case "tag": + if columnMap, ok := column.(map[string]interface{}); ok { + if values, ok := columnMap["values"].(map[string]interface{}); ok { + if tag, ok := values[fmt.Sprintf("%v", value)].(map[string]interface{}); ok { + color := tag["color"].(string) + label := tag["label"].(string) + return template.HTML(fmt.Sprintf( + `%s`, + color, label, + )) + } + } + } + return template.HTML(fmt.Sprintf("%v", value)) + case "markdown": + // 服务端渲染Markdown + return template.HTML(fmt.Sprintf("%v", value)) + default: + return template.HTML(fmt.Sprintf("%v", value)) + } + }, + } +} +``` + +## 项目结构 + +``` +database-render/ +├── cmd/ +│ └── server/ +│ └── main.go +├── internal/ +│ ├── config/ +│ │ └── config.go +│ ├── database/ +│ │ └── connection.go +│ ├── handler/ +│ │ └── data_handler.go +│ ├── model/ +│ │ └── table_config.go +│ ├── repository/ +│ │ └── data_repository.go +│ ├── service/ +│ │ └── data_service.go +│ └── template/ +│ └── renderer.go +├── web/ +│ ├── static/ +│ │ ├── css/ +│ │ ├── js/ +│ │ └── images/ +│ └── templates/ +│ └── list.html +├── config/ +│ └── config.yaml +├── migrations/ +├── scripts/ +├── tests/ +├── Makefile +├── Dockerfile +├── go.mod +└── README.md +``` + +## 构建与部署 + +### 开发环境 + +```bash +# 安装依赖 +go mod tidy + +# 运行开发服务器 +make dev + +# 运行测试 +make test +``` + +### 生产部署 + +```bash +# 构建二进制 +make build + +# 构建Docker镜像 +docker build -t database-render . + +# 运行容器 +docker run -p 8080:8080 -v ./config:/app/config database-render +``` + +## 环境要求 + +- **开发环境**: Go 1.21+, Node.js 18+, SQLite3 +- **生产环境**: Linux/Windows/macOS, Docker, SQLite/MySQL 5.7+/PostgreSQL 12+ diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..92db5c1 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,345 @@ +#!/bin/bash + +# Database Render Application - Build Script +# This script provides comprehensive build functionality for the application + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +BINARY_NAME="database-render" +DIST_DIR="$PROJECT_DIR/dist" +BIN_DIR="$PROJECT_DIR/bin" + +# Functions +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if Go is installed +check_go() { + if ! command -v go >/dev/null 2>&1; then + log_error "Go is not installed. Please install Go first." + exit 1 + fi + + local go_version=$(go version | awk '{print $3}') + log_info "Go version: $go_version" +} + +# Check if required tools are installed +check_tools() { + local tools=("go") + + for tool in "${tools[@]}"; do + if command -v "$tool" >/dev/null 2>&1; then + log_success "$tool is available" + else + log_error "$tool is not installed" + exit 1 + fi + done +} + +# Clean build artifacts +clean() { + log_info "Cleaning build artifacts..." + + if [ -d "$BIN_DIR" ]; then + rm -rf "$BIN_DIR" + log_success "Removed $BIN_DIR" + fi + + if [ -d "$DIST_DIR" ]; then + rm -rf "$DIST_DIR" + log_success "Removed $DIST_DIR" + fi + + go clean + log_success "Clean completed" +} + +# Install dependencies +install_deps() { + log_info "Installing dependencies..." + + cd "$PROJECT_DIR" + go mod tidy + go mod download + + log_success "Dependencies installed" +} + +# Build for current platform +build_current() { + log_info "Building for current platform..." + + mkdir -p "$BIN_DIR" + cd "$PROJECT_DIR" + + local build_time=$(date -u '+%Y-%m-%d_%H:%M:%S') + local git_commit=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + + go build \ + -ldflags "-X main.Version=dev -X main.BuildTime=$build_time -X main.GitCommit=$git_commit" \ + -o "$BIN_DIR/$BINARY_NAME" \ + ./cmd/server + + log_success "Built for current platform: $BIN_DIR/$BINARY_NAME" +} + +# Build for multiple platforms +build_all() { + log_info "Building for multiple platforms..." + + mkdir -p "$DIST_DIR" + cd "$PROJECT_DIR" + + local build_time=$(date -u '+%Y-%m-%d_%H:%M:%S') + local git_commit=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + local ldflags="-X main.Version=dev -X main.BuildTime=$build_time -X main.GitCommit=$git_commit" + + # Build targets + local targets=( + "linux/amd64" + "linux/arm64" + "darwin/amd64" + "darwin/arm64" + "windows/amd64" + ) + + for target in "${targets[@]}"; do + local os=$(echo "$target" | cut -d'/' -f1) + local arch=$(echo "$target" | cut -d'/' -f2) + local ext="" + + if [ "$os" = "windows" ]; then + ext=".exe" + fi + + log_info "Building for $os/$arch..." + + GOOS=$os GOARCH=$arch go build \ + -ldflags "$ldflags" \ + -o "$DIST_DIR/${BINARY_NAME}-${os}-${arch}${ext}" \ + ./cmd/server + + log_success "Built: ${BINARY_NAME}-${os}-${arch}${ext}" + done + + log_success "Multi-platform build completed in $DIST_DIR/" +} + +# Run tests +run_tests() { + log_info "Running tests..." + + cd "$PROJECT_DIR" + go test -v ./... + + log_success "Tests completed" +} + +# Run tests with coverage +run_tests_coverage() { + log_info "Running tests with coverage..." + + cd "$PROJECT_DIR" + go test -v -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + + log_success "Tests completed with coverage report: coverage.html" +} + +# Format code +format_code() { + log_info "Formatting code..." + + cd "$PROJECT_DIR" + go fmt ./... + + log_success "Code formatted" +} + +# Check code formatting +check_format() { + log_info "Checking code formatting..." + + cd "$PROJECT_DIR" + local unformatted=$(gofmt -l .) + + if [ -n "$unformatted" ]; then + log_error "Code is not properly formatted. Run 'go fmt ./...' to fix." + echo "$unformatted" + exit 1 + else + log_success "Code formatting is correct" + fi +} + +# Run linter +run_linter() { + if command -v golangci-lint >/dev/null 2>&1; then + log_info "Running linter..." + cd "$PROJECT_DIR" + golangci-lint run + log_success "Linter completed" + else + log_warning "golangci-lint not found. Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest" + fi +} + +# Security scan +security_scan() { + if command -v gosec >/dev/null 2>&1; then + log_info "Running security scan..." + cd "$PROJECT_DIR" + gosec ./... + log_success "Security scan completed" + else + log_warning "gosec not found. Install with: go install github.com/securecodewarrior/gosec/v2/cmd/gosec@latest" + fi +} + +# Initialize project +init_project() { + log_info "Initializing project..." + + # Create directories + mkdir -p "$PROJECT_DIR/config" + mkdir -p "$PROJECT_DIR/data" + mkdir -p "$PROJECT_DIR/logs" + + # Copy example configuration if it doesn't exist + if [ ! -f "$PROJECT_DIR/config/config.yaml" ]; then + cp "$PROJECT_DIR/config/config.example.yaml" "$PROJECT_DIR/config/config.yaml" + log_success "Created config.yaml from example" + fi + + # Copy schema if it doesn't exist + if [ ! -f "$PROJECT_DIR/config/schema.sql" ]; then + cp "$PROJECT_DIR/config/schema.example.sql" "$PROJECT_DIR/config/schema.sql" + log_success "Created schema.sql from example" + fi + + install_deps + + log_success "Project initialized successfully!" + log_info "Please edit config/config.yaml to configure your database connection" + log_info "Then run: $0 build-current" +} + +# Show help +show_help() { + echo "Database Render Application - Build Script" + echo "" + echo "Usage: $0 [command]" + echo "" + echo "Commands:" + echo " init - Initialize project with sample configuration" + echo " deps - Install dependencies" + echo " clean - Clean build artifacts" + echo " build - Build for current platform" + echo " build-all - Build for multiple platforms" + echo " test - Run tests" + echo " test-cov - Run tests with coverage" + echo " fmt - Format code" + echo " fmt-check - Check code formatting" + echo " lint - Run linter" + echo " security - Run security scan" + echo " all - Run full build pipeline (clean, deps, test, build)" + echo " help - Show this help message" + echo "" + echo "Examples:" + echo " $0 init" + echo " $0 build" + echo " $0 test-cov" +} + +# Main function +main() { + case "${1:-help}" in + "init") + check_go + init_project + ;; + "deps") + check_go + install_deps + ;; + "clean") + clean + ;; + "build") + check_go + check_tools + install_deps + build_current + ;; + "build-all") + check_go + check_tools + install_deps + build_all + ;; + "test") + check_go + run_tests + ;; + "test-cov") + check_go + run_tests_coverage + ;; + "fmt") + check_go + format_code + ;; + "fmt-check") + check_go + check_format + ;; + "lint") + check_go + run_linter + ;; + "security") + security_scan + ;; + "all") + check_go + check_tools + clean + install_deps + check_format + run_tests + build_current + log_success "Full build pipeline completed!" + ;; + "help"|*) + show_help + ;; + esac +} + +# Run main function with all arguments +main "$@" \ No newline at end of file diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..0912a66 --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,360 @@ +#!/bin/bash + +# Database Render Application - Development Script +# This script provides development utilities for the application + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +BINARY_NAME="database-render" +CONFIG_FILE="$PROJECT_DIR/config/config.yaml" + +# Functions +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if service is running +check_service() { + local port=${1:-8080} + if curl -sf http://localhost:$port/health >/dev/null; then + return 0 + else + return 1 + fi +} + +# Wait for service to be ready +wait_for_service() { + local port=${1:-8080} + local max_attempts=30 + local attempt=1 + + log_info "Waiting for service to be ready on port $port..." + + while [ $attempt -le $max_attempts ]; do + if check_service $port; then + log_success "Service is ready!" + return 0 + fi + + log_info "Attempt $attempt/$max_attempts - waiting..." + sleep 2 + ((attempt++)) + done + + log_error "Service failed to start within $max_attempts attempts" + return 1 +} + +# Initialize SQLite database +init_sqlite() { + local db_file="$PROJECT_DIR/data/app.db" + + if [ ! -f "$db_file" ]; then + log_info "Creating SQLite database at $db_file..." + mkdir -p "$(dirname "$db_file")" + sqlite3 "$db_file" < "$PROJECT_DIR/config/schema.example.sql" + log_success "SQLite database initialized" + else + log_info "SQLite database already exists at $db_file" + fi +} + +# Generate sample data +generate_data() { + local db_file="$PROJECT_DIR/data/app.db" + + if [ -f "$db_file" ]; then + log_info "Generating sample data..." + sqlite3 "$db_file" </dev/null 2>&1; then + log_info "Using air for hot reload..." + cd "$PROJECT_DIR" + air + else + log_warning "air not found. Installing..." + go install github.com/cosmtrek/air@latest + + if command -v air >/dev/null 2>&1; then + cd "$PROJECT_DIR" + air + else + log_error "Failed to install air" + exit 1 + fi + fi +} + +# Database utilities +reset_db() { + local db_file="$PROJECT_DIR/data/app.db" + + if [ -f "$db_file" ]; then + log_info "Resetting database..." + rm -f "$db_file" + init_sqlite + generate_data + log_success "Database reset completed" + else + log_info "Database not found, creating new..." + init_sqlite + generate_data + fi +} + +# Health check +health_check() { + local port=${1:-8080} + + if check_service $port; then + log_success "Service is healthy on port $port" + curl -s http://localhost:$port/health | jq . 2>/dev/null || curl -s http://localhost:$port/health + else + log_error "Service is not responding on port $port" + return 1 + fi +} + +# Show logs +tail_logs() { + local log_file="$PROJECT_DIR/logs/app.log" + + if [ -f "$log_file" ]; then + log_info "Tailing application logs..." + tail -f "$log_file" + else + log_info "Log file not found, checking stdout..." + log_info "Application is logging to stdout/stderr" + fi +} + +# Open browser +open_browser() { + local port=${1:-8080} + local url="http://localhost:$port" + + log_info "Opening browser at $url..." + + case "$(uname -s)" in + Darwin*) + open "$url" + ;; + Linux*) + if command -v xdg-open >/dev/null 2>&1; then + xdg-open "$url" + elif command -v gnome-open >/dev/null 2>&1; then + gnome-open "$url" + else + log_info "Please open your browser and navigate to: $url" + fi + ;; + CYGWIN*|MINGW*|MSYS*) + start "$url" + ;; + *) + log_info "Please open your browser and navigate to: $url" + ;; + esac +} + +# Show help +show_help() { + echo "Database Render Application - Development Script" + echo "" + echo "Usage: $0 [command] [options]" + echo "" + echo "Commands:" + echo " start - Start development server" + echo " watch - Start with hot reload (using air)" + echo " init-db - Initialize SQLite database" + echo " reset-db - Reset database with sample data" + echo " health - Check service health" + echo " logs - Tail application logs" + echo " open - Open browser to application" + echo " help - Show this help message" + echo "" + echo "Options:" + echo " --port PORT - Specify port (default: 8080)" + echo "" + echo "Examples:" + echo " $0 start" + echo " $0 watch" + echo " $0 reset-db" + echo " $0 health --port 8080" + echo " $0 open --port 8080" +} + +# Parse command line arguments +parse_args() { + local command="$1" + local port=8080 + + while [[ $# -gt 0 ]]; do + case $1 in + --port) + port="$2" + shift 2 + ;; + *) + shift + ;; + esac + done + + case "$command" in + "start") + start_dev + ;; + "watch") + start_hot_reload + ;; + "init-db") + init_sqlite + generate_data + ;; + "reset-db") + reset_db + ;; + "health") + health_check $port + ;; + "logs") + tail_logs + ;; + "open") + open_browser $port + ;; + "help"|*) + show_help + ;; + esac +} + +# Main execution +main() { + cd "$PROJECT_DIR" + parse_args "$@" +} + +# Run main function +main "$@" \ No newline at end of file diff --git a/web/templates/list.html b/web/templates/list.html new file mode 100644 index 0000000..e85ac99 --- /dev/null +++ b/web/templates/list.html @@ -0,0 +1,278 @@ + + + + + + {{.TableAlias}} - 数据管理系统 + + + + + +
+ +
+
+
+
+

数据管理系统

+

{{.TableAlias}}

+
+
+ +
+ 共 {{.Total}} 条记录 +
+
+
+
+
+ + +
+ +
+
+
+ +
+
+ + +
+
+
+ + +
+ +
+ + + + {{range $col := .Columns}} + {{if $col.ShowInList}} + + {{end}} + {{end}} + + + + + {{range $row := .Data}} + + {{range $col := $.Columns}} + {{if $col.ShowInList}} + + {{end}} + {{end}} + + + {{end}} + +
+ {{$col.Alias}} + + 操作 +
+ {{if eq $col.RenderType "text"}} + {{truncate (index $row $col.Name) 50}} + {{else}} + {{index $row $col.Name}} + {{end}} + + +
+
+
+ + + {{if gt .Pages 1}} +
+
+ {{if gt .Page 1}} + + 上一页 + + {{end}} + {{if lt .Page .Pages}} + + 下一页 + + {{end}} +
+ +
+ {{end}} +
+
+ + + + + + + \ No newline at end of file