feat: init project
This commit is contained in:
236
internal/config/config.go
Normal file
236
internal/config/config.go
Normal 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
|
||||
}
|
||||
}
|
||||
482
internal/database/connection.go
Normal file
482
internal/database/connection.go
Normal 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, ¬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")
|
||||
}
|
||||
161
internal/handler/data_handler.go
Normal file
161
internal/handler/data_handler.go
Normal 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)
|
||||
}
|
||||
69
internal/model/table_config.go
Normal file
69
internal/model/table_config.go
Normal 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"`
|
||||
}
|
||||
192
internal/repository/data_repository.go
Normal file
192
internal/repository/data_repository.go
Normal 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
|
||||
}
|
||||
204
internal/service/data_service.go
Normal file
204
internal/service/data_service.go
Normal 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")
|
||||
}
|
||||
250
internal/template/renderer.go
Normal file
250
internal/template/renderer.go
Normal 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(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user