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:
2026-10-02 22:52:59 +02:00
parent b6c0cd3d1b
commit 275424c605
7 changed files with 93 additions and 10 deletions
+27
View File
@@ -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)
}
})
}
}
+51 -1
View File
@@ -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
}
-3
View File
@@ -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
}