feat(pgsql): diff against the live database by default for direct output
Direct pgsql output (job output.conn_env, merge --output-conn) now reads the live schema and executes only the differences. full_ddl: true (job option) or --full-ddl (merge) restores the full idempotent DDL; file output is unchanged. - compare PKs by columns, skip constraint-backed indexes, normalize index method, FK actions, serial/numeric types, default literals/casts and truncated names - diff table and column comments instead of re-emitting them - remove leftover ZZDUMP debug code
This commit is contained in:
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
+8
-5
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user