package pgsql import ( "bytes" "encoding/json" "fmt" "io" "os" "sort" "strings" "git.warky.dev/wdevs/relspecgo/pkg/models" "git.warky.dev/wdevs/relspecgo/pkg/pgsql" "git.warky.dev/wdevs/relspecgo/pkg/writers" ) // MigrationScript represents a single migration script with priority type MigrationScript struct { ObjectName string ObjectType string Schema string Priority int Sequence int Body string } // MigrationWriter generates differential migration SQL scripts using templates type MigrationWriter struct { options *writers.WriterOptions writer io.Writer executor *TemplateExecutor } // NewMigrationWriter creates a new templated migration writer func NewMigrationWriter(options *writers.WriterOptions) (*MigrationWriter, error) { if options == nil { options = &writers.WriterOptions{} } executor, err := NewTemplateExecutor(options.FlattenSchema) if err != nil { return nil, fmt.Errorf("failed to create template executor: %w", err) } return &MigrationWriter{ options: options, executor: executor, }, nil } // GenerateScripts computes the differential migration scripts between current and model, // sorted by priority and sequence. func (w *MigrationWriter) GenerateScripts(model, current *models.Database) ([]MigrationScript, error) { if model == nil { return nil, fmt.Errorf("model database is required") } if w.options == nil { w.options = &writers.WriterOptions{} } if current == nil { current = models.InitDatabase(model.Name) } // Check if audit is configured in metadata var auditConfig *AuditConfig if w.options.Metadata != nil { if ac, ok := w.options.Metadata["audit_config"].(*AuditConfig); ok { auditConfig = ac } } // Generate all migration scripts scripts := make([]MigrationScript, 0) // Generate audit tables if needed (priority 90) if auditConfig != nil && len(auditConfig.EnabledTables) > 0 { auditTableScript, err := w.generateAuditTablesScript(auditConfig) if err != nil { return nil, fmt.Errorf("failed to generate audit tables: %w", err) } scripts = append(scripts, auditTableScript...) } // Process each schema in the model for _, modelSchema := range model.Schemas { if modelSchema == nil { continue } // Find corresponding schema in current database var currentSchema *models.Schema for _, cs := range current.Schemas { if cs == nil { continue } if strings.EqualFold(cs.Name, modelSchema.Name) { currentSchema = cs break } } // Generate schema-level scripts schemaScripts, err := w.generateSchemaScripts(modelSchema, currentSchema) if err != nil { return nil, fmt.Errorf("failed to generate schema scripts: %w", err) } scripts = append(scripts, schemaScripts...) // Generate audit scripts for this schema (if configured) if auditConfig != nil { auditScripts, err := w.generateAuditScripts(modelSchema, auditConfig) if err != nil { return nil, fmt.Errorf("failed to generate audit scripts: %w", err) } scripts = append(scripts, auditScripts...) } } // Sort scripts by priority and sequence sort.Slice(scripts, func(i, j int) bool { if scripts[i].Priority != scripts[j].Priority { return scripts[i].Priority < scripts[j].Priority } return scripts[i].Sequence < scripts[j].Sequence }) return scripts, nil } // WriteMigration generates migration scripts using templates func (w *MigrationWriter) WriteMigration(model, current *models.Database) error { if model == nil { return fmt.Errorf("model database is required") } if w.options == nil { w.options = &writers.WriterOptions{} } if current == nil { current = models.InitDatabase(model.Name) } scripts, err := w.GenerateScripts(model, current) if err != nil { return err } var writer io.Writer var file *os.File // Use existing writer if already set (for testing) if w.writer != nil { writer = w.writer } else if w.options.OutputPath != "" { file, err = os.Create(w.options.OutputPath) if err != nil { return fmt.Errorf("failed to create output file: %w", err) } defer file.Close() writer = file } else { writer = os.Stdout } w.writer = writer // Write header fmt.Fprintf(w.writer, "-- PostgreSQL Migration Script\n") fmt.Fprintf(w.writer, "-- Generated by RelSpec\n") fmt.Fprintf(w.writer, "-- Source: %s -> %s\n", current.Name, model.Name) if w.options.ContinueOnError { fmt.Fprintf(w.writer, "\\set ON_ERROR_STOP off\n") } fmt.Fprintf(w.writer, "\n") // Write scripts for _, script := range scripts { fmt.Fprintf(w.writer, "-- Priority: %d | Type: %s | Object: %s\n", script.Priority, script.ObjectType, script.ObjectName) fmt.Fprintf(w.writer, "%s\n\n", script.Body) } return nil } // generateSchemaScripts generates migration scripts for a schema using templates func (w *MigrationWriter) generateSchemaScripts(model, current *models.Schema) ([]MigrationScript, error) { scripts := make([]MigrationScript, 0) for _, extension := range requiredExtensions(model) { scripts = append(scripts, MigrationScript{ ObjectName: "extension." + extension, ObjectType: "create extension", Schema: model.Name, Priority: 80, Sequence: len(scripts), Body: fmt.Sprintf("CREATE EXTENSION IF NOT EXISTS %s;", pgsql.QuoteExtensionName(extension)), }) } // Phase 1: Drop constraints and indexes that changed (Priority 5-50) var droppedFKs map[string]bool if current != nil { dropScripts, dropped, err := w.generateDropScripts(model, current) if err != nil { return nil, fmt.Errorf("failed to generate drop scripts: %w", err) } scripts = append(scripts, dropScripts...) droppedFKs = dropped } // Phase 3: Create/Alter tables and columns (Priority 100-145) tableScripts, err := w.generateTableScripts(model, current) if err != nil { return nil, fmt.Errorf("failed to generate table scripts: %w", err) } scripts = append(scripts, tableScripts...) // Phase 4: Create indexes (Priority 160-180) indexScripts, err := w.generateIndexScripts(model, current) if err != nil { return nil, fmt.Errorf("failed to generate index scripts: %w", err) } scripts = append(scripts, indexScripts...) // Phase 5: Create foreign keys (Priority 195) fkScripts, err := w.generateForeignKeyScripts(model, current, droppedFKs) if err != nil { return nil, fmt.Errorf("failed to generate foreign key scripts: %w", err) } scripts = append(scripts, fkScripts...) // Phase 6: Add comments (Priority 200+) commentScripts, err := w.generateCommentScripts(model, current) if err != nil { return nil, fmt.Errorf("failed to generate comment scripts: %w", err) } scripts = append(scripts, commentScripts...) return scripts, nil } // generateDropScripts generates DROP scripts using templates. // Returns the scripts and a set of FK constraint keys (schema.table.name) that were // explicitly dropped because their referenced PK was being dropped, so they can be force-recreated. func (w *MigrationWriter) generateDropScripts(model, current *models.Schema) ([]MigrationScript, map[string]bool, error) { scripts := make([]MigrationScript, 0) droppedFKs := make(map[string]bool) // Build map of model tables for quick lookup modelTables := make(map[string]*models.Table) for _, table := range model.Tables { modelTables[strings.ToLower(table.Name)] = table } // Find constraints to drop for _, currentTable := range current.Tables { modelTable, existsInModel := modelTables[strings.ToLower(currentTable.Name)] if !existsInModel { continue } // Check each constraint in current database for _, currentConstraint := range sortConstraints(currentTable.Constraints) { constraintName := currentConstraint.Name modelConstraint, existsInModel := lookupConstraint(modelTable.Constraints, constraintName) shouldDrop := false if currentConstraint.Type == models.PrimaryKeyConstraint { // Model primary keys usually live on the columns (not as a named constraint), so // compare by key columns instead of constraint name. shouldDrop = !primaryKeyColumnsMatch(modelTable, currentConstraint) } else if !existsInModel { shouldDrop = true } else if !constraintsEqual(modelConstraint, currentConstraint) { shouldDrop = true } if shouldDrop && currentConstraint.Type == models.PrimaryKeyConstraint { // Drop FK constraints that depend on this PK before dropping the PK itself. for _, otherTable := range current.Tables { for _, fkConstraint := range sortConstraints(otherTable.Constraints) { fkName := fkConstraint.Name if fkConstraint.Type != models.ForeignKeyConstraint { continue } refTable := fkConstraint.ReferencedTable refSchema := fkConstraint.ReferencedSchema if refSchema == "" { refSchema = current.Name } if strings.EqualFold(refTable, currentTable.Name) && strings.EqualFold(refSchema, current.Name) { fkKey := fmt.Sprintf("%s.%s.%s", current.Name, otherTable.Name, fkName) if !droppedFKs[fkKey] { droppedFKs[fkKey] = true sql, err := w.executor.ExecuteDropConstraint(DropConstraintData{ SchemaName: current.Name, TableName: otherTable.Name, ConstraintName: fkName, }) if err != nil { return nil, nil, err } scripts = append(scripts, MigrationScript{ ObjectName: fkKey, ObjectType: "drop constraint", Schema: current.Name, Priority: 5, Sequence: len(scripts), Body: sql, }) } } } } } if shouldDrop { sql, err := w.executor.ExecuteDropConstraint(DropConstraintData{ SchemaName: current.Name, TableName: currentTable.Name, ConstraintName: constraintName, }) if err != nil { return nil, nil, err } script := MigrationScript{ ObjectName: fmt.Sprintf("%s.%s.%s", current.Name, currentTable.Name, constraintName), ObjectType: "drop constraint", Schema: current.Name, Priority: 11, Sequence: len(scripts), Body: sql, } scripts = append(scripts, script) } } // Check indexes for _, currentIndex := range sortIndexes(currentTable.Indexes) { indexName := currentIndex.Name modelIndex, existsInModel := modelTable.Indexes[indexName] // A live database reports the index backing a unique/primary constraint as an // index too; it belongs to the constraint and is handled there. if _, backsConstraint := currentTable.Constraints[indexName]; backsConstraint { continue } shouldDrop := false if !existsInModel { shouldDrop = true } else if !indexesEqual(modelIndex, currentIndex) { shouldDrop = true } if shouldDrop { sql, err := w.executor.ExecuteDropIndex(DropIndexData{ SchemaName: current.Name, IndexName: indexName, }) if err != nil { return nil, nil, err } script := MigrationScript{ ObjectName: fmt.Sprintf("%s.%s.%s", current.Name, currentTable.Name, indexName), ObjectType: "drop index", Schema: current.Name, Priority: 20, Sequence: len(scripts), Body: sql, } scripts = append(scripts, script) } } } return scripts, droppedFKs, nil } // generateTableScripts generates CREATE/ALTER TABLE scripts using templates func (w *MigrationWriter) generateTableScripts(model, current *models.Schema) ([]MigrationScript, error) { scripts := make([]MigrationScript, 0) // Build map of current tables currentTables := make(map[string]*models.Table) if current != nil { for _, table := range current.Tables { currentTables[strings.ToLower(table.Name)] = table } } // Process each model table for _, modelTable := range model.Tables { currentTable, exists := currentTables[strings.ToLower(modelTable.Name)] if !exists { // Table doesn't exist, create it sql, err := w.executor.ExecuteCreateTable(BuildCreateTableData(model.Name, modelTable)) if err != nil { return nil, err } script := MigrationScript{ ObjectName: fmt.Sprintf("%s.%s", model.Name, modelTable.Name), ObjectType: "create table", Schema: model.Name, Priority: 100, Sequence: len(scripts), Body: sql, } scripts = append(scripts, script) } else { // Table exists, check for column changes alterScripts, err := w.generateAlterTableScripts(model, modelTable, currentTable) if err != nil { return nil, err } scripts = append(scripts, alterScripts...) } } return scripts, nil } // generateAlterTableScripts generates ALTER TABLE scripts using templates func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, modelTable, currentTable *models.Table) ([]MigrationScript, error) { scripts := make([]MigrationScript, 0) // Build map of current columns currentColumns := make(map[string]*models.Column) for name, col := range currentTable.Columns { currentColumns[strings.ToLower(name)] = col } // Check each model column for _, modelCol := range sortColumns(modelTable.Columns) { currentCol, exists := currentColumns[strings.ToLower(modelCol.Name)] if !exists { // Column doesn't exist, add it _, defaultVal := formatColumnDefaultSQL(modelCol) sql, err := w.executor.ExecuteAddColumn(AddColumnData{ SchemaName: schema.Name, TableName: modelTable.Name, ColumnName: modelCol.Name, ColumnType: effectiveColumnSQLType(modelCol), Default: defaultVal, NotNull: modelCol.NotNull, }) if err != nil { return nil, err } script := MigrationScript{ ObjectName: fmt.Sprintf("%s.%s.%s", schema.Name, modelTable.Name, modelCol.Name), ObjectType: "create column", Schema: schema.Name, Priority: 120, Sequence: len(scripts), Body: sql, } scripts = append(scripts, script) } else if currentCol.Generated || currentCol.Identity { // GENERATED ALWAYS ... STORED columns and GENERATED ... AS IDENTITY // columns are both driven by Postgres-side machinery (an expression, or // a backing sequence + attidentity flag) that DBML cannot express, so // the model side always looks like drift (missing type modifiers, no // default, NOT NULL mismatches, etc). None of their properties are // alterable the normal way (e.g. dropping a generated column's default // requires DROP EXPRESSION, not DROP DEFAULT; identity columns have no // default to alter at all), so these DB-side-only columns are left // untouched rather than diffed. } else if !columnsEqual(modelCol, currentCol) { // Column exists but properties changed if !columnTypesEqual(modelCol, currentCol) { newType := effectiveAlterColumnSQLType(modelCol) sql, err := w.executor.ExecuteAlterColumnTypeWithCheck(AlterColumnTypeWithCheckData{ SchemaName: schema.Name, TableName: modelTable.Name, ColumnName: modelCol.Name, NewType: newType, EquivalentTypes: equivalentTypeListSQL(newType), UsingExpr: buildAlterColumnUsingExpression(modelCol.Name, newType), }) if err != nil { return nil, err } script := MigrationScript{ ObjectName: fmt.Sprintf("%s.%s.%s", schema.Name, modelTable.Name, modelCol.Name), ObjectType: "alter column type", Schema: schema.Name, Priority: 120, Sequence: len(scripts), Body: sql, } scripts = append(scripts, script) } // Check default value changes if !isSerialWithoutDefault(modelCol) && !columnDefaultsEqual(modelCol.Default, currentCol.Default) { setDefault, defaultVal := formatColumnDefaultSQL(modelCol) sql, err := w.executor.ExecuteAlterColumnDefaultWithCheck(AlterColumnDefaultWithCheckData{ SchemaName: schema.Name, TableName: modelTable.Name, ColumnName: modelCol.Name, SetDefault: setDefault, DefaultValue: defaultVal, }) if err != nil { return nil, err } script := MigrationScript{ ObjectName: fmt.Sprintf("%s.%s.%s", schema.Name, modelTable.Name, modelCol.Name), ObjectType: "alter column default", Schema: schema.Name, Priority: 145, Sequence: len(scripts), Body: sql, } scripts = append(scripts, script) } // Check nullability changes if modelCol.NotNull != currentCol.NotNull { sql, err := w.executor.ExecuteAlterColumnNullabilityWithCheck(AlterColumnNullabilityWithCheckData{ SchemaName: schema.Name, TableName: modelTable.Name, ColumnName: modelCol.Name, NotNull: modelCol.NotNull, }) if err != nil { return nil, err } script := MigrationScript{ ObjectName: fmt.Sprintf("%s.%s.%s", schema.Name, modelTable.Name, modelCol.Name), ObjectType: "alter column nullability", Schema: schema.Name, Priority: 145, Sequence: len(scripts), Body: sql, } scripts = append(scripts, script) } } } return scripts, nil } // generateIndexScripts generates CREATE INDEX scripts using templates func (w *MigrationWriter) generateIndexScripts(model, current *models.Schema) ([]MigrationScript, error) { scripts := make([]MigrationScript, 0) // Build map of current tables currentTables := make(map[string]*models.Table) if current != nil { for _, table := range current.Tables { currentTables[strings.ToLower(table.Name)] = table } } // Process each model table for _, modelTable := range model.Tables { currentTable := currentTables[strings.ToLower(modelTable.Name)] // Process primary keys first - check explicit constraints foundExplicitPK := false for _, constraint := range sortConstraints(modelTable.Constraints) { constraintName := constraint.Name if constraint.Type == models.PrimaryKeyConstraint { foundExplicitPK = true shouldCreate := true if currentTable != nil { if currentConstraint, exists := currentTable.Constraints[constraintName]; exists { if constraintsEqual(constraint, currentConstraint) { shouldCreate = false } } } if shouldCreate { sql, err := w.executor.ExecuteCreatePrimaryKey(CreatePrimaryKeyData{ SchemaName: model.Name, TableName: modelTable.Name, ConstraintName: constraintName, Columns: strings.Join(constraint.Columns, ", "), }) if err != nil { return nil, err } script := MigrationScript{ ObjectName: fmt.Sprintf("%s.%s.%s", model.Name, modelTable.Name, constraintName), ObjectType: "create primary key", Schema: model.Name, Priority: 160, Sequence: len(scripts), Body: sql, } scripts = append(scripts, script) } } } // If no explicit PK constraint, check for columns with IsPrimaryKey = true if !foundExplicitPK { pkColumns := []string{} for _, col := range modelTable.Columns { if col.IsPrimaryKey { pkColumns = append(pkColumns, col.SQLName()) } } if len(pkColumns) > 0 { sort.Strings(pkColumns) constraintName := fmt.Sprintf("pk_%s_%s", model.SQLName(), modelTable.SQLName()) shouldCreate := true if currentTable != nil { // Check if a PK constraint already exists (by any name) for _, constraint := range currentTable.Constraints { if constraint.Type == models.PrimaryKeyConstraint { shouldCreate = false break } } } if shouldCreate { sql, err := w.executor.ExecuteCreatePrimaryKey(CreatePrimaryKeyData{ SchemaName: model.Name, TableName: modelTable.Name, ConstraintName: constraintName, Columns: strings.Join(pkColumns, ", "), }) if err != nil { return nil, err } script := MigrationScript{ ObjectName: fmt.Sprintf("%s.%s.%s", model.Name, modelTable.Name, constraintName), ObjectType: "create primary key", Schema: model.Name, Priority: 160, Sequence: len(scripts), Body: sql, } scripts = append(scripts, script) } } } // Process indexes for _, modelIndex := range sortIndexes(modelTable.Indexes) { indexName := modelIndex.Name // Skip primary key indexes if strings.HasPrefix(strings.ToLower(indexName), "pk_") { continue } shouldCreate := true if currentTable != nil { if currentIndex, exists := currentTable.Indexes[indexName]; exists { if indexesEqual(modelIndex, currentIndex) { shouldCreate = false } } } if shouldCreate { indexType := "btree" if modelIndex.Type != "" { indexType = modelIndex.Type } columnExprs := buildIndexColumnExpressions(modelTable, modelIndex, indexType) if len(columnExprs) == 0 { continue } sql, err := w.executor.ExecuteCreateIndex(CreateIndexData{ SchemaName: model.Name, TableName: modelTable.Name, IndexName: indexName, IndexType: indexType, Columns: strings.Join(columnExprs, ", "), Unique: modelIndex.Unique, Concurrent: modelIndex.Concurrent, StorageParameters: indexStorageParameters(modelIndex.Comment), }) if err != nil { return nil, err } script := MigrationScript{ ObjectName: fmt.Sprintf("%s.%s.%s", model.Name, modelTable.Name, indexName), ObjectType: "create index", Schema: model.Name, Priority: 180, Sequence: len(scripts), Body: sql, } scripts = append(scripts, script) } } } return scripts, nil } // buildIndexColumnExpressions renders the column list of an index, appending the operator // class each column needs for the access method (GIN opclasses, pgvector distance ops, // explicitly requested PostGIS opclasses). Columns that cannot be resolved on the table are // emitted verbatim. func buildIndexColumnExpressions(table *models.Table, index *models.Index, indexType string) []string { return buildIndexColumnExpressionsFiltered(table, index, indexType, false) } // buildIndexColumnExpressionsFiltered is buildIndexColumnExpressions with the option to drop // columns that do not exist on the table instead of emitting them verbatim. func buildIndexColumnExpressionsFiltered(table *models.Table, index *models.Index, indexType string, skipUnresolved bool) []string { columnExprs := make([]string, 0, len(index.Columns)) for _, colName := range index.Columns { col, ok := resolveIndexColumn(table, colName) if !ok || col == nil { if skipUnresolved { continue } columnExprs = append(columnExprs, colName) continue } colExpr := col.SQLName() if opClass := indexOperatorClassForColumn(col, indexType, index.Comment); opClass != "" { colExpr = fmt.Sprintf("%s %s", colExpr, opClass) } columnExprs = append(columnExprs, colExpr) } return columnExprs } // generateForeignKeyScripts generates ADD CONSTRAINT FOREIGN KEY scripts using templates. // forceRecreate is a set of FK constraint keys (schema.table.name) that must be recreated // even if unchanged, because their referenced PK was dropped and recreated. func (w *MigrationWriter) generateForeignKeyScripts(model, current *models.Schema, forceRecreate map[string]bool) ([]MigrationScript, error) { scripts := make([]MigrationScript, 0) // Build map of current tables currentTables := make(map[string]*models.Table) if current != nil { for _, table := range current.Tables { currentTables[strings.ToLower(table.Name)] = table } } // Process each model table for _, modelTable := range model.Tables { currentTable := currentTables[strings.ToLower(modelTable.Name)] // Process each constraint for _, constraint := range sortConstraints(modelTable.Constraints) { constraintName := constraint.Name if constraint.Type != models.ForeignKeyConstraint { continue } fkKey := fmt.Sprintf("%s.%s.%s", model.Name, modelTable.Name, constraintName) shouldCreate := forceRecreate[fkKey] if !shouldCreate { if currentTable == nil { shouldCreate = true } else if currentConstraint, exists := lookupConstraint(currentTable.Constraints, constraintName); !exists { shouldCreate = true } else if !constraintsEqual(constraint, currentConstraint) { shouldCreate = true } } if shouldCreate { onDelete := "NO ACTION" if constraint.OnDelete != "" { onDelete = strings.ToUpper(constraint.OnDelete) } onUpdate := "NO ACTION" if constraint.OnUpdate != "" { onUpdate = strings.ToUpper(constraint.OnUpdate) } sql, err := w.executor.ExecuteCreateForeignKey(CreateForeignKeyData{ SchemaName: model.Name, TableName: modelTable.Name, ConstraintName: constraintName, SourceColumns: strings.Join(constraint.Columns, ", "), TargetSchema: constraint.ReferencedSchema, TargetTable: constraint.ReferencedTable, TargetColumns: strings.Join(constraint.ReferencedColumns, ", "), OnDelete: onDelete, OnUpdate: onUpdate, }) if err != nil { return nil, err } script := MigrationScript{ ObjectName: fmt.Sprintf("%s.%s.%s", model.Name, modelTable.Name, constraintName), ObjectType: "create foreign key", Schema: model.Name, Priority: 195, Sequence: len(scripts), Body: sql, } scripts = append(scripts, script) } } } return scripts, nil } // generateCommentScripts generates COMMENT ON scripts using templates func (w *MigrationWriter) generateCommentScripts(model, current *models.Schema) ([]MigrationScript, error) { scripts := make([]MigrationScript, 0) currentTables := make(map[string]*models.Table) if current != nil { for _, table := range current.Tables { currentTables[strings.ToLower(table.Name)] = table } } // Process each model table for _, modelTable := range model.Tables { currentTable := currentTables[strings.ToLower(modelTable.Name)] // Table comment (skipped when the live table already carries the same comment) if modelTable.Description != "" && (currentTable == nil || strings.TrimSpace(currentTable.Description) != strings.TrimSpace(modelTable.Description)) { sql, err := w.executor.ExecuteCommentTable(CommentTableData{ SchemaName: model.Name, TableName: modelTable.Name, Comment: escapeQuote(modelTable.Description), }) if err != nil { return nil, err } script := MigrationScript{ ObjectName: fmt.Sprintf("%s.%s", model.Name, modelTable.Name), ObjectType: "comment on table", Schema: model.Name, Priority: 200, Sequence: len(scripts), Body: sql, } scripts = append(scripts, script) } // Column comments for _, col := range sortColumns(modelTable.Columns) { if col.Description != "" && !currentColumnHasDescription(currentTable, col) { sql, err := w.executor.ExecuteCommentColumn(CommentColumnData{ SchemaName: model.Name, TableName: modelTable.Name, ColumnName: col.Name, Comment: escapeQuote(col.Description), }) if err != nil { return nil, err } script := MigrationScript{ ObjectName: fmt.Sprintf("%s.%s.%s", model.Name, modelTable.Name, col.Name), ObjectType: "comment on column", Schema: model.Name, Priority: 200, Sequence: len(scripts), Body: sql, } scripts = append(scripts, script) } } } return scripts, nil } // generateAuditTablesScript generates audit table creation scripts using templates func (w *MigrationWriter) generateAuditTablesScript(auditConfig *AuditConfig) ([]MigrationScript, error) { scripts := make([]MigrationScript, 0) auditSchema := auditConfig.AuditSchema if auditSchema == "" { auditSchema = "public" } sql, err := w.executor.ExecuteAuditTables(AuditTablesData{ AuditSchema: auditSchema, }) if err != nil { return nil, err } script := MigrationScript{ ObjectName: fmt.Sprintf("%s.atevent+atdetail", auditSchema), ObjectType: "create audit tables", Schema: auditSchema, Priority: 90, Sequence: 0, Body: sql, } scripts = append(scripts, script) return scripts, nil } // generateAuditScripts generates audit functions and triggers using templates func (w *MigrationWriter) generateAuditScripts(schema *models.Schema, auditConfig *AuditConfig) ([]MigrationScript, error) { scripts := make([]MigrationScript, 0) // Process each table in the schema for _, table := range schema.Tables { if !auditConfig.IsTableAudited(schema.Name, table.Name) { continue } config := auditConfig.GetTableConfig(schema.Name, table.Name) if config == nil { continue } // Find primary key pk := table.GetPrimaryKey() if pk == nil { continue } auditSchema := auditConfig.AuditSchema if auditSchema == "" { auditSchema = schema.Name } // Generate audit function funcName := fmt.Sprintf("tf_audit_%s", table.Name) funcData := BuildAuditFunctionData(schema.Name, table, pk, config, auditSchema, auditConfig.UserFunction) funcSQL, err := w.executor.ExecuteAuditFunction(funcData) if err != nil { return nil, err } functionScript := MigrationScript{ ObjectName: fmt.Sprintf("%s.%s", schema.Name, funcName), ObjectType: "create audit function", Schema: schema.Name, Priority: 345, Sequence: len(scripts), Body: funcSQL, } scripts = append(scripts, functionScript) // Generate audit trigger triggerName := fmt.Sprintf("t_audit_%s", table.Name) events := make([]string, 0) if config.AuditInsert { events = append(events, "INSERT") } if config.AuditUpdate { events = append(events, "UPDATE") } if config.AuditDelete { events = append(events, "DELETE") } if len(events) == 0 { continue } triggerSQL, err := w.executor.ExecuteAuditTrigger(AuditTriggerData{ SchemaName: schema.Name, TableName: table.Name, TriggerName: triggerName, FunctionName: funcName, Events: strings.Join(events, " OR "), }) if err != nil { return nil, err } triggerScript := MigrationScript{ ObjectName: fmt.Sprintf("%s.%s", schema.Name, triggerName), ObjectType: "create audit trigger", Schema: schema.Name, Priority: 355, Sequence: len(scripts), Body: triggerSQL, } scripts = append(scripts, triggerScript) } return scripts, nil } // Helper functions for comparing database objects // columnsEqual checks if two columns have the same definition func columnsEqual(col1, col2 *models.Column) bool { if col1 == nil || col2 == nil { return false } return columnTypesEqual(col1, col2) && col1.NotNull == col2.NotNull && columnDefaultsEqual(col1.Default, col2.Default) } // columnDefaultsEqual compares column defaults for drift detection, stripping // MySQL-style backticks (e.g. from GORM tags) so a model default of // "`now()`" is recognised as equal to a live default of "now()". func columnDefaultsEqual(default1, default2 interface{}) bool { return normalizeDefaultForCompare(default1) == normalizeDefaultForCompare(default2) } func normalizeDefaultForCompare(value interface{}) string { if value == nil { return "" } if s, ok := value.(string); ok { return normalizeDefaultLiteral(strings.TrimSpace(stripBackticks(s))) } return fmt.Sprintf("%v", value) } // normalizeDefaultLiteral removes a trailing ::type cast and the quotes around a plain string // literal, so a DBML default ('[]') and the one a live database reports ('[]'::jsonb) compare equal. func normalizeDefaultLiteral(s string) string { if strings.HasPrefix(s, "'") { if end := strings.LastIndex(s, "'"); end > 0 { rest := strings.TrimSpace(s[end+1:]) if rest == "" || strings.HasPrefix(rest, "::") { return normalizeJSONLiteral(strings.ReplaceAll(s[1:end], "''", "'")) } } return s } if strings.HasPrefix(s, "(") && strings.Contains(s, ")::") { return normalizeCastExpression(s) } return normalizeJSONLiteral(s) } // normalizeJSONLiteral compacts JSON text, since jsonb reports defaults re-spaced // ('["a", "b"]') while the model usually holds them minified. func normalizeJSONLiteral(s string) string { if !strings.HasPrefix(s, "[") && !strings.HasPrefix(s, "{") { return s } var buf bytes.Buffer if err := json.Compact(&buf, []byte(s)); err != nil { return s } return buf.String() } // normalizeCastExpression strips whitespace and the redundant parentheses PostgreSQL adds // around an expression it casts: (gen_random_uuid())::text -> gen_random_uuid()::text. func normalizeCastExpression(s string) string { s = strings.Join(strings.Fields(s), "") for strings.HasPrefix(s, "(") { end := strings.LastIndex(s, ")::") if end < 0 { break } inner := s[1:end] depth := 0 balanced := true for _, r := range inner { if r == '(' { depth++ } else if r == ')' { depth-- if depth < 0 { balanced = false break } } } if !balanced || depth != 0 { break } s = inner + s[end+1:] } return s } func columnTypesEqual(col1, col2 *models.Column) bool { if col1 == nil || col2 == nil { return false } return strings.EqualFold( normalizeZeroScale(pgsql.NormalizeEquivalentSQLType(effectiveAlterColumnSQLType(col1))), normalizeZeroScale(pgsql.NormalizeEquivalentSQLType(effectiveAlterColumnSQLType(col2))), ) } // constraintsEqual checks if two constraints are equal func constraintsEqual(c1, c2 *models.Constraint) bool { if c1 == nil || c2 == nil { return false } if c1.Type != c2.Type { return false } // Compare columns if len(c1.Columns) != len(c2.Columns) { return false } for i, col := range c1.Columns { if !strings.EqualFold(col, c2.Columns[i]) { return false } } // For foreign keys, also compare referenced table and columns if c1.Type == models.ForeignKeyConstraint { if !strings.EqualFold(c1.ReferencedTable, c2.ReferencedTable) { return false } if len(c1.ReferencedColumns) != len(c2.ReferencedColumns) { return false } for i, col := range c1.ReferencedColumns { if !strings.EqualFold(col, c2.ReferencedColumns[i]) { return false } } if !fkActionsEqual(c1.OnDelete, c2.OnDelete) || !fkActionsEqual(c1.OnUpdate, c2.OnUpdate) { return false } } return true } // indexesEqual checks if two indexes are equal func indexesEqual(idx1, idx2 *models.Index) bool { if idx1 == nil || idx2 == nil { return false } if idx1.Unique != idx2.Unique { return false } if !strings.EqualFold(normalizeIndexMethod(idx1.Type), normalizeIndexMethod(idx2.Type)) { return false } if len(idx1.Columns) != len(idx2.Columns) { return false } for i, col := range idx1.Columns { if !strings.EqualFold(col, idx2.Columns[i]) { return false } } // Operator class and storage parameters ride along in the index comment. They only // signal a difference when both sides specify one, so an index whose model side omits // the hint is not recreated on every migration. if !indexHintsEqual(extractOperatorClass(idx1.Comment), extractOperatorClass(idx2.Comment)) { return false } return indexHintsEqual(indexStorageParameters(idx1.Comment), indexStorageParameters(idx2.Comment)) } // normalizeZeroScale turns numeric(p,0) into numeric(p); PostgreSQL treats them as the same type. func normalizeZeroScale(sqlType string) string { if i := strings.Index(sqlType, "("); i >= 0 && strings.HasSuffix(sqlType, ",0)") && !strings.Contains(sqlType[i:], "[") { return strings.TrimSuffix(sqlType, ",0)") + ")" } return sqlType } // maxIdentifierBytes is PostgreSQL's identifier length limit; longer names are truncated on creation. const maxIdentifierBytes = 63 // pgIdentifier returns name as PostgreSQL stores it (truncated to 63 bytes). func pgIdentifier(name string) string { if len(name) > maxIdentifierBytes { return name[:maxIdentifierBytes] } return name } // lookupConstraint finds a constraint by name, also matching names that differ only because // PostgreSQL truncated an over-long identifier. func lookupConstraint(constraints map[string]*models.Constraint, name string) (*models.Constraint, bool) { if c, ok := constraints[name]; ok { return c, true } want := pgIdentifier(name) for n, c := range constraints { if pgIdentifier(n) == want { return c, true } } return nil, false } // currentColumnHasDescription reports whether the live table's column already carries // the model column's comment. func currentColumnHasDescription(currentTable *models.Table, col *models.Column) bool { if currentTable == nil { return false } for _, cc := range currentTable.Columns { if strings.EqualFold(cc.Name, col.Name) { return strings.TrimSpace(cc.Description) == strings.TrimSpace(col.Description) } } return false } // primaryKeyColumnsMatch reports whether the model table's primary key (an explicit PK // constraint or columns flagged IsPrimaryKey) has the same columns, in order, as current. func primaryKeyColumnsMatch(modelTable *models.Table, current *models.Constraint) bool { var modelCols []string for _, c := range sortConstraints(modelTable.Constraints) { if c.Type == models.PrimaryKeyConstraint { modelCols = c.Columns break } } if modelCols == nil { for _, col := range getSortedColumns(modelTable.Columns) { if col.IsPrimaryKey { modelCols = append(modelCols, col.Name) } } } if len(modelCols) != len(current.Columns) { return false } for i, col := range modelCols { if !strings.EqualFold(col, current.Columns[i]) { return false } } return true } // normalizeIndexMethod maps an unspecified index method to PostgreSQL's default (btree), // which is what a live database reports for it. func normalizeIndexMethod(method string) string { if strings.TrimSpace(method) == "" { return "btree" } return method } // fkActionsEqual compares referential actions case-insensitively, treating an unspecified // action as PostgreSQL's default (NO ACTION). func fkActionsEqual(a, b string) bool { norm := func(s string) string { s = strings.ToUpper(strings.TrimSpace(s)) if s == "" { return "NO ACTION" } return s } return norm(a) == norm(b) } // indexHintsEqual compares two optional index hints, treating an unspecified hint as a match. func indexHintsEqual(hint1, hint2 string) bool { if hint1 == "" || hint2 == "" { return true } return strings.EqualFold(hint1, hint2) }