diff --git a/cmd/relspec/job.go b/cmd/relspec/job.go index 20c232e..9c26366 100644 --- a/cmd/relspec/job.go +++ b/cmd/relspec/job.go @@ -844,7 +844,7 @@ func writeJobOutput(rj *resolvedJob, db *models.Database, lg *jobLogger) error { } lg.logf("writing output to database env:%s", rj.outputConnEnv) writerOpts := newWriterOptions("", o.Package, o.FlattenSchema, o.Types, o.ArrayNullable, o.ContinueOnError) - writerOpts.Metadata = map[string]interface{}{"connection_string": rj.outputConn} + writerOpts.Metadata = map[string]interface{}{"connection_string": rj.outputConn, "full_ddl": o.FullDDL} return wpgsql.NewWriter(writerOpts).WriteDatabase(db) } diff --git a/cmd/relspec/merge.go b/cmd/relspec/merge.go index ccac526..7da5e67 100644 --- a/cmd/relspec/merge.go +++ b/cmd/relspec/merge.go @@ -59,6 +59,7 @@ var ( mergeSkipTables string // Comma-separated table names to skip mergeVerbose bool mergeReportPath string // Path to write merge report + mergeFullDDL bool // Execute full DDL instead of diffing the live pgsql database mergeFlattenSchema bool ) @@ -127,6 +128,7 @@ func init() { mergeCmd.Flags().BoolVar(&mergeSkipSequences, "skip-sequences", false, "Skip sequences during merge") mergeCmd.Flags().StringVar(&mergeSkipTables, "skip-tables", "", "Comma-separated list of table names to skip during merge") mergeCmd.Flags().BoolVar(&mergeVerbose, "verbose", false, "Show verbose output") + mergeCmd.Flags().BoolVar(&mergeFullDDL, "full-ddl", false, "pgsql database output: execute the full DDL instead of only the differences from the live database") mergeCmd.Flags().StringVar(&mergeReportPath, "merge-report", "", "Path to write merge report (JSON format)") mergeCmd.Flags().BoolVar(&mergeFlattenSchema, "flatten-schema", false, "Flatten schema.table names to schema_table (useful for databases like SQLite that do not support schemas)") } @@ -443,6 +445,7 @@ func writeDatabaseForMerge(dbType, filePath, connString string, db *models.Datab if connString != "" { writerOpts.Metadata = map[string]interface{}{ "connection_string": connString, + "full_ddl": mergeFullDDL, } // Add report path if merge report is enabled if mergeReportPath != "" { diff --git a/docs/JOB_FILES.md b/docs/JOB_FILES.md index 1a776ab..8424bc3 100644 --- a/docs/JOB_FILES.md +++ b/docs/JOB_FILES.md @@ -187,6 +187,8 @@ jobs: format: pgsql path: build/schema.sql # file output, OR: conn_env: TARGET_DB_URL # execute against DB (pgsql only) + # default: reads live DB, executes only the diff + # (file output = full DDL); set options.full_ddl to force full DDL overwrite: false # default false options: flatten_schema: false @@ -195,6 +197,7 @@ jobs: types: sqltypes # Bun/GORM nullable types: baselib|stdlib|sqltypes array_nullable: pointer_slice # Bun: slice|pointer_slice continue_on_error: false # pgsql / scripts-exec output + full_ddl: false # pgsql database output: true = run full DDL; default diffs against live DB skip_relations: false # merge only skip_enums: false skip_views: false diff --git a/pkg/jobs/jobs.go b/pkg/jobs/jobs.go index 75dcba0..6f12b92 100644 --- a/pkg/jobs/jobs.go +++ b/pkg/jobs/jobs.go @@ -285,11 +285,14 @@ type Options struct { Types string `yaml:"types"` ArrayNullable string `yaml:"array_nullable"` ContinueOnError bool `yaml:"continue_on_error"` - SkipRelations bool `yaml:"skip_relations"` - SkipEnums bool `yaml:"skip_enums"` - SkipViews bool `yaml:"skip_views"` - SkipDomains bool `yaml:"skip_domains"` - SkipSequences bool `yaml:"skip_sequences"` + // FullDDL makes direct pgsql output execute the full idempotent DDL instead of + // diffing against the live database first. + FullDDL bool `yaml:"full_ddl"` + SkipRelations bool `yaml:"skip_relations"` + SkipEnums bool `yaml:"skip_enums"` + SkipViews bool `yaml:"skip_views"` + SkipDomains bool `yaml:"skip_domains"` + SkipSequences bool `yaml:"skip_sequences"` } // Dir returns the directory that a job's relative paths resolve against: diff --git a/pkg/writers/pgsql/diff_statements_test.go b/pkg/writers/pgsql/diff_statements_test.go index eafd8b9..d936fb2 100644 --- a/pkg/writers/pgsql/diff_statements_test.go +++ b/pkg/writers/pgsql/diff_statements_test.go @@ -380,3 +380,30 @@ func TestDiffStatements_TruncatedConstraintNameMatches(t *testing.T) { t.Fatalf("expected truncated live name to match, got:\n%s", diffJoin(stmts)) } } + +func TestColumnDefaultsEqual_LiveRepresentations(t *testing.T) { + tests := []struct { + name string + model, cur interface{} + want bool + }{ + {"cast wrapped in parentheses", "gen_random_uuid()::text", "(gen_random_uuid())::text", true}, + {"jsonb re-spaced array", `'["openid","profile","email"]'`, `'["openid", "profile", "email"]'::jsonb`, true}, + {"backticked function", "`now()`", "now()", true}, + {"quoted literal with cast", "'[]'", "'[]'::jsonb", true}, + {"jsonb array already unquoted by the reader", `'["openid","profile"]'`, `["openid", "profile"]`, true}, + {"text with spaces and braces", "`'{actor} #{actor_id} - {chartofaccount}'`", "{actor} #{actor_id} - {chartofaccount}", true}, + {"cast on plain function stays equal", "gen_random_uuid()::text", "gen_random_uuid()::text", true}, + {"different jsonb content", `'["a"]'`, `'["b"]'::jsonb`, false}, + {"different function", "gen_random_uuid()::text", "(now())::text", false}, + {"different literal", "'active'", "'inactive'::text", false}, + {"nil vs value", nil, "now()", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := columnDefaultsEqual(tt.model, tt.cur); got != tt.want { + t.Errorf("columnDefaultsEqual(%v, %v) = %v, want %v", tt.model, tt.cur, got, tt.want) + } + }) + } +} diff --git a/pkg/writers/pgsql/migration_writer.go b/pkg/writers/pgsql/migration_writer.go index 5f2ca5c..e1f903e 100644 --- a/pkg/writers/pgsql/migration_writer.go +++ b/pkg/writers/pgsql/migration_writer.go @@ -1,6 +1,8 @@ package pgsql import ( + "bytes" + "encoding/json" "fmt" "io" "os" @@ -1044,9 +1046,57 @@ func normalizeDefaultLiteral(s string) string { if end := strings.LastIndex(s, "'"); end > 0 { rest := strings.TrimSpace(s[end+1:]) if rest == "" || strings.HasPrefix(rest, "::") { - return strings.ReplaceAll(s[1:end], "''", "'") + 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 } diff --git a/pkg/writers/pgsql/writer.go b/pkg/writers/pgsql/writer.go index 7e37ab6..4eb1e9b 100644 --- a/pkg/writers/pgsql/writer.go +++ b/pkg/writers/pgsql/writer.go @@ -2191,9 +2191,6 @@ func (w *Writer) diffStatements(model, current *models.Database) ([]string, erro } statements = append(statements, setvalStatements...) - if dump := os.Getenv("ZZDUMP"); dump != "" { - _ = os.WriteFile(dump, []byte(strings.Join(statements, "\n=====\n")), 0o644) - } return statements, nil }