feat: init project

This commit is contained in:
yanghao05
2025-08-05 17:26:59 +08:00
parent c5d621ad03
commit e034a2e54e
30 changed files with 5159 additions and 0 deletions

236
internal/config/config.go Normal file
View File

@@ -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
}
}

View File

@@ -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, &notused, &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")
}

View File

@@ -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)
}

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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")
}

View File

@@ -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(
` <span class="tag" style="background-color: %s; color: white;">%s</span> `,
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(),
})
}