From 73605da712d203c69a62d3e3fb6387dc0f9d7915 Mon Sep 17 00:00:00 2001 From: Marek Goc Date: Sat, 28 Mar 2026 17:45:22 +0100 Subject: [PATCH] initial --- .gitignore | 0 README.md | 250 ++++++++++++++++++++++++++++++++++++ cmd/gormcol/main.go | 182 ++++++++++++++++++++++++++ gen.go | 306 ++++++++++++++++++++++++++++++++++++++++++++ gencols.go | 286 +++++++++++++++++++++++++++++++++++++++++ go.mod | 25 ++++ go.sum | 70 ++++++++++ gormcol.go | 58 +++++++++ 8 files changed, 1177 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cmd/gormcol/main.go create mode 100644 gen.go create mode 100644 gencols.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 gormcol.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c673dd --- /dev/null +++ b/README.md @@ -0,0 +1,250 @@ +# gormcol + +Type-safe GORM column descriptors and model generation utilities. + +## Library + +The library provides [Field](#field) for type-safe column references and helper functions to extract column/table names. + +### Field + +`Field` represents a GORM column descriptor with table context. Define package-level variables to get type-safe column references: + +```go +var PsAccess = struct { + IDProfile gormcol.Field + IDAuthorizationRole gormcol.Field +}{ + IDProfile: gormcol.Field{Table: "ps_access", Column: "id_profile"}, + IDAuthorizationRole: gormcol.Field{Table: "ps_access", Column: "id_authorization_role"}, +} +``` + +### Helper Functions + +#### Column + +Returns just the column name from a Field descriptor. + +```go +gormcol.Column(dbmodel.PsAccess.IDAuthorizationRole) // "id_authorization_role" +``` + +#### ColumnOnTable + +Returns "table.column" format from a Field descriptor. + +```go +gormcol.ColumnOnTable(dbmodel.PsAccess.IDAuthorizationRole) // "ps_access.id_authorization_role" +``` + +#### TableField + +Returns the table name from a Field descriptor. + +```go +gormcol.TableField(dbmodel.PsAccess.IDAuthorizationRole) // "ps_access" +``` + +## CLI + +The `cmd/` directory contains a standalone tool that generates GORM model files with column descriptors. + +### Install + +```bash +go install git.ma-al.com/goc_marek/gormcol/cmd/gormcol@latest +``` + +Or from source: + +```bash +go install ./cmd/gormcol +``` + +### Prerequisites + +- [fzf](https://github.com/junegunn/fzf) - required for interactive mode (optional for `--all`) + +### Usage + +``` +gormcol-gen [options] +``` + +DSN can be provided via `--dsn` flag or `DSN` env var (from `.env` file). + +| Flag | Default | Description | +|----------|------------------------|--------------------------------------------------| +| `--dsn` | *(from DSN env)* | MySQL/MariaDB DSN, e.g. `user:pass@tcp(localhost:3306)/dbname` | +| `--filter` | `(ps_|b2b_).*` | Regex matching table names to generate | +| `--all` | *(interactive)* | Generate all tables matching filter (shows confirmation) | +| `--out` | `./app/model/dbmodel` | Output directory for generated files | +| `--pkg` | `dbmodel` | Go package name for generated files | + +### Interactive Mode (Default) + +Without flags, the tool launches an interactive table selector: + +```bash +gormcol-gen --dsn "user:pass@tcp(localhost:3306)/mydb" +``` + +Features: +- **Fuzzy search** as you type +- **Tab** - toggle table selection (multi-select) +- **Enter** - confirm selection +- **Esc** - cancel + +### Generate All Tables + +Use `--all` to generate all tables matching the filter: + +```bash +gormcol-gen --dsn "user:pass@tcp(localhost:3306)/mydb" --all +``` + +A confirmation prompt appears: +``` +WARNING: Generate all 325 tables? [Enter] confirm / [Esc] cancel +``` + +- **Enter** - confirm and generate +- **Esc** - cancel + +### Example + +```bash +./gormcol-gen --dsn "user:pass@tcp(localhost:3306)/mydb" --filter "ps_.*" --out ./internal/model --pkg model +``` + +This connects to the database, generates a `.go` model file for each matching table, and appends `Cols` variables with typed `gormcol.Field` descriptors to each file. + +### Configuration File (.env) + +Create a `.env` file in your project root for default values: + +```env +# Database connection +DSN=user:pass@tcp(localhost:3306)/mydb + +# Table filter (regex) +FILTER=(ps_|b2b_).* + +# Output settings +OUT=./app/model/dbmodel +PKG=dbmodel +``` + +Command-line flags override `.env` values. + +## Library Functions Reference + +### ConnectDSN + +Opens a MySQL/MariaDB connection from a DSN string. + +```go +db, err := gormcol.ConnectDSN("user:pass@tcp(localhost:3306)/dbname") +``` + +### New + +Creates a new GormGen with default configuration. + +```go +gg := gormcol.New(db) +``` + +### NewWithConfig + +Creates a new GormGen with custom configuration. + +```go +gg := gormcol.NewWithConfig(db, gormcol.GenConfig{ + OutputDir: "./models", + PkgName: "models", + TableFilter: "ps_.*", +}) +``` + +### GenModels + +Generates GORM model files and column descriptors for matched tables. + +```go +ctx := context.Background() +err := gg.GenModels(ctx) +``` + +## Generated Models + +After generation, each model file contains a struct and a `Cols` variable: + +```go +// model/product.go +type Product struct { + ID uint `gorm:"column:id_product;primaryKey"` + Name string `gorm:"column:name"` + Price float32 `gorm:"column:price;type:decimal(20,6)"` +} + +var ProductCols = struct { + ID Field + Name Field + Price Field +}{ + ID: Field{Table: "ps_product", Column: "id_product"}, + Name: Field{Table: "ps_product", Column: "name"}, + Price: Field{Table: "ps_product", Column: "price"}, +} +``` + +## Using Generated Models + +### GORM queries with type-safe columns + +Use `ColumnOnTable` for table-qualified column references in GORM clauses: + +```go +import "git.ma-al.com/goc_marek/gormcol" + +// Where clauses +db.Where( + gormcol.ColumnOnTable(model.ProductCols.Price) + " > ?", + 100.0, +).Find(&products) + +// Order +db.Order(gormcol.ColumnOnTable(model.ProductCols.Name) + " ASC").Find(&products) + +// Joins +db.Joins("JOIN ps_category ON " + + gormcol.ColumnOnTable(model.ProductCols.ID) + " = ps_category.id_product", +).Find(&products) +``` + +### Unqualified column names + +Use `Column` when the table is already scoped: + +```go +db.Select(gormcol.Column(model.ProductCols.Name)).Find(&products) + +// Raw queries +db.Raw("SELECT " + gormcol.Column(model.ProductCols.Name) + " FROM ps_product").Scan(&names) +``` + +### Table name + +Use `TableField` to get the table name from a column descriptor: + +```go +table := gormcol.TableField(model.ProductCols.ID) // "ps_product" +``` + +## Dependencies + +- `gorm.io/gorm` +- `gorm.io/gen` +- `gorm.io/driver/mysql` diff --git a/cmd/gormcol/main.go b/cmd/gormcol/main.go new file mode 100644 index 0000000..af9109d --- /dev/null +++ b/cmd/gormcol/main.go @@ -0,0 +1,182 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + "os/exec" + "strings" + "time" + + "git.ma-al.com/goc_marek/gormcol" + "github.com/joho/godotenv" + "gorm.io/gorm" +) + +// main is the entry point for the gormcol-gen CLI tool. +// It parses flags, loads configuration from .env, connects to the database, +// and generates GORM models with column descriptors. +func main() { + dsn := flag.String("dsn", "", "database DSN (e.g. user:pass@tcp(host:3306)/dbname)") + filter := flag.String("filter", "(ps_|b2b_).*", "regex to match table names") + all := flag.Bool("all", false, "generate all tables matching filter (shows confirmation)") + outDir := flag.String("out", "./app/model/dbmodel", "output directory for generated files") + pkgName := flag.String("pkg", "dbmodel", "Go package name for generated files") + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "gormcol-gen - generate GORM models with column descriptors\n\n") + fmt.Fprintf(os.Stderr, "Usage:\n") + fmt.Fprintf(os.Stderr, " gormcol-gen [options]\n\n") + fmt.Fprintf(os.Stderr, "DSN can be provided via --dsn flag or DSN env var (from .env)\n\n") + fmt.Fprintf(os.Stderr, "Flags:\n") + flag.PrintDefaults() + } + + // Load .env file if present in current directory. + godotenv.Load() + + flag.Parse() + + // Get DSN from flag or environment variable. + dsnValue := *dsn + if dsnValue == "" { + dsnValue = os.Getenv("DSN") + } + if dsnValue == "" { + flag.Usage() + fmt.Fprintln(os.Stderr, "\nerror: --dsn or DSN env is required") + os.Exit(1) + } + + // Connect to the database. + db, err := gormcol.ConnectDSN(dsnValue) + if err != nil { + slog.Error("failed to connect to database", "error", err) + os.Exit(1) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + cfg := gormcol.GenConfig{ + OutputDir: *outDir, + PkgName: *pkgName, + TableFilter: *filter, + } + + // Interactive mode (default): select tables using fzf. + // All mode: generate all tables matching filter with confirmation. + if *all { + if !confirmGenerateAll(db) { + fmt.Println("Aborted.") + os.Exit(0) + } + } else { + // Check if fzf is available for interactive mode. + if _, err := exec.LookPath("fzf"); err != nil { + fmt.Fprintln(os.Stderr, "error: fzf is required for interactive mode. Install from https://github.com/junegunn/fzf") + os.Exit(1) + } + selected, err := selectTablesInteractive(db) + if err != nil { + slog.Error("failed to select tables interactively", "error", err) + os.Exit(1) + } + if len(selected) == 0 { + fmt.Println("No tables selected, exiting.") + os.Exit(0) + } + cfg.SelectedTables = selected + } + + // Generate models with column descriptors. + if err := gormcol.NewWithConfig(db, cfg).GenModels(ctx); err != nil { + slog.Error("failed to generate models", "error", err) + os.Exit(1) + } +} + +// selectTablesInteractive displays all database tables in fzf for interactive selection. +// Returns a list of selected table names. +func selectTablesInteractive(db *gorm.DB) ([]string, error) { + type migratorWithGetTables interface { + GetTables() ([]string, error) + } + + tableNames, err := db.Migrator().(migratorWithGetTables).GetTables() + if err != nil { + return nil, fmt.Errorf("failed to get tables: %w", err) + } + + fmt.Printf("Found %d tables, launching fzf for selection...\n", len(tableNames)) + + selected, err := runFzf(tableNames) + if err != nil { + return nil, fmt.Errorf("fzf selection failed: %w", err) + } + + return selected, nil +} + +// confirmGenerateAll displays a confirmation prompt before generating all tables. +// Returns true if user confirms, false if cancelled or timeout. +func confirmGenerateAll(db *gorm.DB) bool { + type migratorWithGetTables interface { + GetTables() ([]string, error) + } + + tableNames, err := db.Migrator().(migratorWithGetTables).GetTables() + if err != nil { + return false + } + + if len(tableNames) == 0 { + fmt.Println("No tables in database.") + return false + } + + // Display yellow warning message and wait for user input. + yellow := "\033[33m" + reset := "\033[0m" + msg := fmt.Sprintf("%sWARNING:%s Generate all %d tables? [Enter] confirm / [Esc] cancel", yellow, reset, len(tableNames)) + + script := fmt.Sprintf(`echo; echo -e '%s'; read -t 10 -n 1 -s key; if [[ -z "$key" ]]; then exit 0; elif [[ "$key" == $'\e' ]]; then exit 1; else exit 0; fi`, msg) + cmd := exec.Command("bash", "-c", script) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + err = cmd.Run() + return err == nil +} + +// runFzf launches fzf for interactive table selection. +// Supports multi-select with Tab, fuzzy search, and keyboard navigation. +func runFzf(tableNames []string) ([]string, error) { + header := "Tab: toggle select | Enter: confirm | Esc: cancel" + cmd := exec.Command("fzf", "-m", "--height=50%", "--border", "--prompt", "Select tables> ", "--header", header) + cmd.Stdin = strings.NewReader(strings.Join(tableNames, "\n")) + + out, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + if exitErr.ExitCode() == 130 { + // User pressed Esc to cancel. + return []string{}, nil + } + } + return nil, fmt.Errorf("fzf failed: %w", err) + } + + // Parse selected lines from fzf output. + selected := strings.Split(strings.TrimSpace(string(out)), "\n") + var result []string + for _, s := range selected { + s = strings.TrimSpace(s) + if s != "" { + result = append(result, s) + } + } + return result, nil +} diff --git a/gen.go b/gen.go new file mode 100644 index 0000000..f2f79d9 --- /dev/null +++ b/gen.go @@ -0,0 +1,306 @@ +// Package gormcol provides GORM model generation with type-safe column descriptors. +// +// This file contains the core generation logic including: +// - Model generation from database tables +// - Configuration management +// - Output directory handling and file cleanup +package gormcol + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "gorm.io/driver/mysql" + "gorm.io/gen" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// GenConfig holds configuration for model generation. +type GenConfig struct { + // OutputDir is the directory where generated files are written. + OutputDir string + // PkgName is the Go package name for generated files. + PkgName string + // TableFilter is a regex pattern to match table names. + // Example: "(ps_|b2b_).*" matches tables starting with ps_ or b2b_. + TableFilter string + // SelectedTables is a list of specific table names to generate. + // When set, TableFilter is ignored. + SelectedTables []string +} + +// defaultConfig returns the default configuration values. +func defaultConfig() GenConfig { + return GenConfig{ + OutputDir: "./app/model/dbmodel", + PkgName: "dbmodel", + TableFilter: "ps_.*", + } +} + +// GormGen handles GORM model generation with column descriptors. +type GormGen struct { + db *gorm.DB + cfg GenConfig +} + +// New creates a new GormGen with default configuration. +func New(db *gorm.DB) *GormGen { + return &GormGen{db: db, cfg: defaultConfig()} +} + +// NewWithConfig creates a new GormGen with custom configuration. +func NewWithConfig(db *gorm.DB, cfg GenConfig) *GormGen { + d := defaultConfig() + if cfg.OutputDir != "" { + d.OutputDir = cfg.OutputDir + } + if cfg.PkgName != "" { + d.PkgName = cfg.PkgName + } + if cfg.TableFilter != "" { + d.TableFilter = cfg.TableFilter + } + if len(cfg.SelectedTables) > 0 { + d.SelectedTables = cfg.SelectedTables + } + return &GormGen{db: db, cfg: d} +} + +// ConnectDSN opens a MySQL/MariaDB connection from a DSN string. +func ConnectDSN(dsn string) (*gorm.DB, error) { + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Error), + }) + if err != nil { + return nil, fmt.Errorf("failed to connect with dsn: %w", err) + } + return db, nil +} + +// GenModels generates GORM model files and column descriptors for matched tables. +// It cleans the output directory, generates models using gorm.io/gen, +// and appends Cols variables with type-safe Field descriptors. +func (m *GormGen) GenModels(ctx context.Context) error { + if err := m.cleanOutputDir(); err != nil { + return fmt.Errorf("failed to clean output dir: %w", err) + } + + g := gen.NewGenerator(gen.Config{ + OutPath: m.cfg.OutputDir, + ModelPkgPath: m.cfg.PkgName, + FieldNullable: true, + FieldWithIndexTag: true, + }) + + g.UseDB(m.db) + + tableNames, err := m.db.Migrator().GetTables() + if err != nil { + return fmt.Errorf("failed to get table list: %w", err) + } + + var matched int + var re *regexp.Regexp + + if len(m.cfg.SelectedTables) > 0 { + tableSet := make(map[string]bool) + for _, t := range m.cfg.SelectedTables { + tableSet[t] = true + } + for _, tableName := range tableNames { + if tableSet[tableName] { + g.GenerateModel(tableName) + matched++ + } + } + fmt.Printf("Selected %d tables\n", matched) + } else { + re, err = regexp.Compile("^" + m.cfg.TableFilter + "$") + if err != nil { + return fmt.Errorf("invalid table filter regex %q: %w", m.cfg.TableFilter, err) + } + for _, tableName := range tableNames { + if re.MatchString(tableName) { + g.GenerateModel(tableName) + matched++ + } + } + fmt.Printf("Matched %d tables with filter %q\n", matched, m.cfg.TableFilter) + } + + g.Execute() + + if err := m.cleanupGeneratedFiles(); err != nil { + return fmt.Errorf("failed to cleanup generated files: %w", err) + } + + if err := m.generateCols(); err != nil { + return fmt.Errorf("failed to generate column descriptors: %w", err) + } + + return nil +} + +// cleanOutputDir removes existing .go files from the output directory +// or creates it if it doesn't exist. +func (m *GormGen) cleanOutputDir() error { + dir := m.cfg.OutputDir + if !strings.HasPrefix(dir, "./") { + dir = "./" + dir + } + + absDir, err := filepath.Abs(dir) + if err != nil { + return err + } + + if _, err := os.Stat(absDir); os.IsNotExist(err) { + if err := os.MkdirAll(absDir, 0755); err != nil { + return fmt.Errorf("failed to create output dir: %w", err) + } + fmt.Printf("Created: %s\n", absDir) + return nil + } + + entries, err := os.ReadDir(absDir) + if err != nil { + return err + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + if strings.HasSuffix(entry.Name(), ".go") { + path := filepath.Join(absDir, entry.Name()) + if err := os.Remove(path); err != nil { + return fmt.Errorf("failed to remove %s: %w", path, err) + } + fmt.Printf("Removed: %s\n", path) + } + } + + return nil +} + +// cleanupGeneratedFiles removes gorm.io/gen helper files and renames +// .gen.go files to .go with cleaned content. +func (m *GormGen) cleanupGeneratedFiles() error { + filesToRemove := []string{"gen.go", "do.go", "_gen.go"} + + dir := m.cfg.OutputDir + if !strings.HasPrefix(dir, "./") { + dir = "./" + dir + } + + absDir, err := filepath.Abs(dir) + if err != nil { + return err + } + + for _, fileName := range filesToRemove { + filePath := filepath.Join(absDir, fileName) + if _, err := os.Stat(filePath); err == nil { + if err := os.Remove(filePath); err != nil { + return fmt.Errorf("failed to remove %s: %w", filePath, err) + } + fmt.Printf("Removed: %s\n", filePath) + } + } + + files, err := os.ReadDir(absDir) + if err != nil { + return err + } + + var re *regexp.Regexp + if len(m.cfg.SelectedTables) > 0 { + pattern := "^(" + strings.Join(m.cfg.SelectedTables, "|") + ")\\.gen\\.go$" + re, err = regexp.Compile(pattern) + } else { + re, err = regexp.Compile("^(" + m.cfg.TableFilter + ")\\.gen\\.go$") + } + if err != nil { + return err + } + + for _, file := range files { + name := file.Name() + if re.MatchString(name) { + oldPath := filepath.Join(absDir, name) + baseName := strings.TrimSuffix(name, ".gen.go") + newPath := filepath.Join(absDir, baseName+".go") + + content, err := os.ReadFile(oldPath) + if err != nil { + return err + } + + content = m.cleanModelContent(content) + + if err := os.WriteFile(newPath, content, 0644); err != nil { + return err + } + + if err := os.Remove(oldPath); err != nil { + return err + } + } + } + + return nil +} + +// cleanModelContent removes gorm.io/gen-specific imports and type declarations +// from the generated model content. +func (m *GormGen) cleanModelContent(content []byte) []byte { + result := string(content) + + lines := strings.Split(result, "\n") + var newLines []string + importStarted := false + importEnded := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + if trimmed == "import (" { + importStarted = true + newLines = append(newLines, line) + continue + } + if importStarted && trimmed == ")" { + importEnded = true + importStarted = false + newLines = append(newLines, line) + continue + } + + if importStarted && !importEnded { + if strings.Contains(trimmed, "\"gorm.io/gen\"") || + strings.Contains(trimmed, "\"gorm.io/gen/field\"") || + strings.Contains(trimmed, "\"gorm.io/plugin/dbresolver\"") || + strings.Contains(trimmed, "gen.DO") { + continue + } + } + + if strings.Contains(trimmed, "type psCategoryDo struct") || + strings.HasPrefix(trimmed, "func (p psCategoryDo)") { + continue + } + + newLines = append(newLines, line) + } + + result = strings.Join(newLines, "\n") + result = strings.ReplaceAll(result, "psCategoryDo", "") + + return []byte(result) +} diff --git a/gencols.go b/gencols.go new file mode 100644 index 0000000..f00b63b --- /dev/null +++ b/gencols.go @@ -0,0 +1,286 @@ +// Package gormcol provides GORM model generation with type-safe column descriptors. +// +// This file handles the generation of Cols variables that provide +// type-safe column references for use in GORM queries. +package gormcol + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "regexp" + "strings" +) + +// fieldInfo holds information about a struct field. +type fieldInfo struct { + GoName string // Go struct field name + ColName string // database column name from gorm tag +} + +// structInfo holds information about a parsed Go struct. +type structInfo struct { + Name string // struct name + Table string // table name from TableName const + Fields []fieldInfo // list of fields + FilePath string // source file path +} + +// parseGormColumn extracts the column name value from a gorm tag string. +// The tag is expected to be in the format "column:name;otherTag" or similar. +// Returns the column name if found, empty string otherwise. +func parseGormColumn(tag string) string { + for _, part := range strings.Split(tag, ";") { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, "column:") { + return strings.TrimPrefix(part, "column:") + } + } + return "" +} + +// parseModelFile parses a Go model source file and extracts struct information. +// It uses go/ast to parse the file and extract: +// - Struct name from the type declaration +// - Table name from the TableName constant +// - Field names and their database column names from gorm tags +// +// Returns nil if no valid struct is found in the file. +func parseModelFile(path string) (*structInfo, error) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) + if err != nil { + return nil, err + } + + var si structInfo + si.FilePath = path + + for _, decl := range f.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok { + continue + } + + if gd.Tok == token.CONST { + for _, spec := range gd.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + for i, name := range vs.Names { + if strings.HasPrefix(name.Name, "TableName") { + if i < len(vs.Values) { + if bl, ok := vs.Values[i].(*ast.BasicLit); ok { + si.Table = strings.Trim(bl.Value, "\"") + } + } + } + } + } + } + + if gd.Tok != token.TYPE { + continue + } + for _, spec := range gd.Specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + st, ok := ts.Type.(*ast.StructType) + if !ok { + continue + } + + si.Name = ts.Name.Name + + for _, field := range st.Fields.List { + if len(field.Names) == 0 || field.Tag == nil { + continue + } + goName := field.Names[0].Name + tag := strings.Trim(field.Tag.Value, "`") + + var gormTag string + for _, t := range strings.Split(tag, " ") { + t = strings.TrimSpace(t) + if strings.HasPrefix(t, "gorm:") { + gormTag = strings.TrimPrefix(t, "gorm:") + gormTag = strings.Trim(gormTag, "\"") + break + } + } + + colName := parseGormColumn(gormTag) + if colName == "" { + continue + } + + si.Fields = append(si.Fields, fieldInfo{ + GoName: goName, + ColName: colName, + }) + } + } + } + + if si.Name == "" { + return nil, nil + } + + return &si, nil +} + +// generateColsVarBlock generates the Go source code for a Cols variable. +// The generated code defines a struct with Field typed members for each +// database column, providing type-safe column references. +func generateColsVarBlock(si *structInfo) string { + if len(si.Fields) == 0 { + return "" + } + + var b strings.Builder + b.WriteString(fmt.Sprintf("\nvar %sCols = struct {\n", si.Name)) + for _, f := range si.Fields { + b.WriteString(fmt.Sprintf("\t%s gormcol.Field\n", f.GoName)) + } + b.WriteString("}{\n") + for _, f := range si.Fields { + b.WriteString(fmt.Sprintf("\t%s: gormcol.Field{Table: %q, Column: %q},\n", f.GoName, si.Table, f.ColName)) + } + b.WriteString("}\n") + return b.String() +} + +// findGoMod searches upward from startDir for a go.mod file. +func findGoMod(startDir string) (string, error) { + dir := startDir + for { + path := filepath.Join(dir, "go.mod") + if _, err := os.Stat(path); err == nil { + return path, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("go.mod not found from %s", startDir) + } + dir = parent + } +} + +// readModulePath extracts the module path from a go.mod file. +func readModulePath(goModPath string) (string, error) { + content, err := os.ReadFile(goModPath) + if err != nil { + return "", err + } + for _, line := range strings.Split(string(content), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "module ") { + return strings.TrimSpace(strings.TrimPrefix(line, "module ")), nil + } + } + return "", fmt.Errorf("module directive not found in %s", goModPath) +} + +// generateCols appends Cols variables to generated model files. +// It parses each .go file in the output directory, extracts struct fields +// with gorm column tags, and generates type-safe Field descriptors. +func (m *GormGen) generateCols() error { + dir := m.cfg.OutputDir + if !strings.HasPrefix(dir, "./") { + dir = "./" + dir + } + + absDir, err := filepath.Abs(dir) + if err != nil { + return err + } + + entries, err := os.ReadDir(absDir) + if err != nil { + return err + } + + goModPath, err := findGoMod(absDir) + if err != nil { + return err + } + modulePath, err := readModulePath(goModPath) + if err != nil { + return err + } + gormcolImport := fmt.Sprintf("%q", modulePath+"") + + var fileFilter *regexp.Regexp + if len(m.cfg.SelectedTables) > 0 { + pattern := "^(" + strings.Join(m.cfg.SelectedTables, "|") + ")$" + fileFilter, err = regexp.Compile(pattern) + if err != nil { + return fmt.Errorf("invalid selected tables pattern: %w", err) + } + } else { + fileFilter, err = regexp.Compile("^" + m.cfg.TableFilter + "$") + if err != nil { + return fmt.Errorf("invalid table filter regex %q: %w", m.cfg.TableFilter, err) + } + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { + continue + } + + // Match files by the table filter regex (strip .go suffix for matching) + fileBase := strings.TrimSuffix(entry.Name(), ".go") + if !fileFilter.MatchString(fileBase) { + continue + } + + path := filepath.Join(absDir, entry.Name()) + + si, err := parseModelFile(path) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: skipping cols for %s: %v\n", entry.Name(), err) + continue + } + if si == nil || len(si.Fields) == 0 { + continue + } + + content, err := os.ReadFile(path) + if err != nil { + return err + } + + fileContent := string(content) + + if strings.Contains(fileContent, "gormcol.Field{") { + continue + } + + if !strings.Contains(fileContent, gormcolImport) { + if strings.Contains(fileContent, "import (") { + fileContent = strings.Replace(fileContent, "import (", "import (\n\t"+gormcolImport, 1) + } else if strings.Contains(fileContent, "package dbmodel") { + fileContent = strings.Replace(fileContent, "package dbmodel", + "package dbmodel\n\nimport "+gormcolImport, 1) + } + } + + colsBlock := generateColsVarBlock(si) + fileContent += colsBlock + + if err := os.WriteFile(path, []byte(fileContent), 0644); err != nil { + return err + } + + fmt.Printf("Cols: %s\n", entry.Name()) + } + + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..acdd342 --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module git.ma-al.com/goc_marek/gormcol + +go 1.26.0 + +require ( + gorm.io/driver/mysql v1.6.0 + gorm.io/gen v0.3.27 + gorm.io/gorm v1.31.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/joho/godotenv v1.5.1 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.20.0 // indirect + golang.org/x/tools v0.43.0 // indirect + gorm.io/datatypes v1.2.4 // indirect + gorm.io/hints v1.1.0 // indirect + gorm.io/plugin/dbresolver v1.6.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ad2da27 --- /dev/null +++ b/go.sum @@ -0,0 +1,70 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +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/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +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.2/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE= +github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/datatypes v1.2.4 h1:uZmGAcK/QZ0uyfCuVg0VQY1ZmV9h1fuG0tMwKByO1z4= +gorm.io/datatypes v1.2.4/go.mod h1:f4BsLcFAX67szSv8svwLRjklArSHAvHLeE3pXAS5DZI= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U= +gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A= +gorm.io/driver/sqlite v1.1.6/go.mod h1:W8LmC/6UvVbHKah0+QOC7Ja66EaZXHwUTjgXY8YNWX8= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0= +gorm.io/driver/sqlserver v1.4.1/go.mod h1:DJ4P+MeZbc5rvY58PnmN1Lnyvb5gw5NPzGshHDnJLig= +gorm.io/gen v0.3.27 h1:ziocAFLpE7e0g4Rum69pGfB9S6DweTxK8gAun7cU8as= +gorm.io/gen v0.3.27/go.mod h1:9zquz2xD1f3Eb/eHq4oLn2z6vDVvQlCY5S3uMBLv4EA= +gorm.io/gorm v1.21.15/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0= +gorm.io/gorm v1.22.2/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gorm.io/hints v1.1.0 h1:Lp4z3rxREufSdxn4qmkK3TLDltrM10FLTHiuqwDPvXw= +gorm.io/hints v1.1.0/go.mod h1:lKQ0JjySsPBj3uslFzY3JhYDtqEwzm+G1hv8rWujB6Y= +gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc= +gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= diff --git a/gormcol.go b/gormcol.go new file mode 100644 index 0000000..5e17b1d --- /dev/null +++ b/gormcol.go @@ -0,0 +1,58 @@ +// Package gormcol provides type-safe GORM column descriptors. +// +// This package enables defining struct-like variables that map Go field names +// to database table.column pairs, allowing type-safe queries in GORM. +// +// Example usage: +// +// var PsAccess = struct { +// IDProfile gormcol.Field +// IDAuthorizationRole gormcol.Field +// }{ +// IDProfile: gormcol.Field{Table: "ps_access", Column: "id_profile"}, +// IDAuthorizationRole: gormcol.Field{Table: "ps_access", Column: "id_authorization_role"}, +// } +package gormcol + +// Field represents a GORM column descriptor with table context. +// It holds the table name and column name for type-safe column references. +type Field struct { + Table string // Database table name + Column string // Database column name +} + +// Column returns the column name from a Field descriptor. +// +// Example: +// +// gormcol.Column(dbmodel.PsAccess.IDAuthorizationRole) // "id_authorization_role" +func Column(f Field) string { + return f.Column +} + +// ColumnOnTable returns a qualified "table.column" string from a Field descriptor. +// +// Example: +// +// gormcol.ColumnOnTable(dbmodel.PsAccess.IDAuthorizationRole) // "ps_access.id_authorization_role" +func ColumnOnTable(f Field) string { + return f.Table + "." + f.Column +} + +// TableField returns the table name from a Field descriptor. +// +// Example: +// +// gormcol.TableField(dbmodel.PsAccess.IDAuthorizationRole) // "ps_access" +func TableField(f Field) string { + return f.Table +} + +// Table is an alias for TableField, returning the table name from a Field descriptor. +// +// Example: +// +// gormcol.Table(dbmodel.PsAccess.IDAuthorizationRole) // "ps_access" +func Table(f Field) string { + return f.Table +}