From be08c8199f1e29d2c7d5816db723e28065adee1a Mon Sep 17 00:00:00 2001 From: Hein Date: Tue, 18 Aug 2026 13:42:34 +0200 Subject: [PATCH] fix(merge,pgsql): treat serial types as their base integer in diffs, unquote bare keyword defaults Merge conflict detection compared bigserial (DBML) against bigint (live PostgreSQL read of an existing serial column) as incompatible types, since serial is sugar over an integer column plus a sequence default and PostgreSQL always reports back the underlying integer type. Add SerialUnderlyingType and use it when comparing column types for conflicts. QuoteDefaultValue also wrapped bare keyword expressions like CURRENT_DATE in string quotes because they contain no parentheses, unlike function-call defaults such as now(). Recognize known bare keyword defaults and leave them unquoted across CREATE TABLE, ALTER TABLE ADD COLUMN, and ALTER COLUMN SET DEFAULT generation. --- pkg/merge/merge.go | 7 +++++- pkg/merge/merge_test.go | 44 ++++++++++++++++++++++++++++++++++++++ pkg/pgsql/datatypes.go | 22 +++++++++++++++++++ pkg/writers/writer.go | 26 ++++++++++++++++++++++ pkg/writers/writer_test.go | 18 ++++++++++++++++ 5 files changed, 116 insertions(+), 1 deletion(-) diff --git a/pkg/merge/merge.go b/pkg/merge/merge.go index 6a96180..6426688 100644 --- a/pkg/merge/merge.go +++ b/pkg/merge/merge.go @@ -492,7 +492,12 @@ func extractTypeParts(col *models.Column) (baseType string, length, precision, s } } - typeName = pgsql.NormalizePGType(typeName) + // serial/bigserial/smallserial are sugar over an integer column plus a + // sequence default; PostgreSQL itself reports the underlying integer + // type back for such columns, so treat them as equivalent here to avoid + // spurious conflicts between a DBML "bigserial" source and a live-read + // "bigint" target (or vice versa). + typeName = pgsql.SerialUnderlyingType(typeName) return typeName, length, precision, scale } diff --git a/pkg/merge/merge_test.go b/pkg/merge/merge_test.go index e5f18cb..7b29805 100644 --- a/pkg/merge/merge_test.go +++ b/pkg/merge/merge_test.go @@ -196,6 +196,50 @@ func TestMergeColumns_TypeConflictIsDetected(t *testing.T) { } } +func TestMergeColumns_SerialVsUnderlyingIntegerIsNotAConflict(t *testing.T) { + target := &models.Database{ + Schemas: []*models.Schema{ + { + Name: "public", + Tables: []*models.Table{ + { + Name: "users", + Schema: "public", + Columns: map[string]*models.Column{ + // As reported back by a live PostgreSQL read of an + // existing serial primary key column. + "id": {Name: "id", Type: "bigint"}, + }, + }, + }, + }, + }, + } + source := &models.Database{ + Schemas: []*models.Schema{ + { + Name: "public", + Tables: []*models.Table{ + { + Name: "users", + Schema: "public", + Columns: map[string]*models.Column{ + // As declared in a DBML source spec. + "id": {Name: "id", Type: "bigserial"}, + }, + }, + }, + }, + }, + } + + result := MergeDatabases(target, source, nil) + + if len(result.TypeConflicts) != 0 { + t.Fatalf("Expected no type conflicts for bigserial vs bigint, got %d: %+v", len(result.TypeConflicts), result.TypeConflicts) + } +} + func TestMergeConstraints_NewConstraint(t *testing.T) { target := &models.Database{ Schemas: []*models.Schema{ diff --git a/pkg/pgsql/datatypes.go b/pkg/pgsql/datatypes.go index cbdb380..de4fe19 100644 --- a/pkg/pgsql/datatypes.go +++ b/pkg/pgsql/datatypes.go @@ -193,6 +193,28 @@ func IsKnownPGBaseType(baseType string) bool { return ok } +// serialUnderlyingType maps each serial pseudo-type to the integer type +// PostgreSQL actually stores the column as. serial/bigserial/smallserial are +// not real types: they are sugar for an integer column plus a sequence +// default, and pg_catalog (and information_schema) always reports the +// underlying integer type back for such columns. +var serialUnderlyingType = map[string]string{ + "serial": "integer", + "bigserial": "bigint", + "smallserial": "smallint", +} + +// SerialUnderlyingType returns the underlying integer type for a serial +// pseudo-type (e.g. "bigserial" -> "bigint"). If baseType (after +// NormalizePGType) is not a serial type, it is returned unchanged. +func SerialUnderlyingType(baseType string) string { + normalized := NormalizePGType(baseType) + if underlying, ok := serialUnderlyingType[normalized]; ok { + return underlying + } + return normalized +} + func IsGoType(pTypeName string) bool { for k := range GoToStdTypes { if strings.EqualFold(pTypeName, k) { diff --git a/pkg/writers/writer.go b/pkg/writers/writer.go index 83b2a43..33f7e87 100644 --- a/pkg/writers/writer.go +++ b/pkg/writers/writer.go @@ -148,6 +148,26 @@ func SanitizeFilename(name string) string { // Examples (boolean): "true" → "true" // Examples (bigint): "0" → "0" // Examples (timestamp): "now()" → "now()" (function call – never quoted) +// bareKeywordDefaults are PostgreSQL default-value keywords that are +// expressions, not string literals, even though they contain no +// parentheses (e.g. "CURRENT_DATE" rather than "now()"). They must never be +// wrapped in quotes. +var bareKeywordDefaults = map[string]bool{ + "current_date": true, + "current_time": true, + "current_timestamp": true, + "localtime": true, + "localtimestamp": true, + "current_user": true, + "session_user": true, + "current_role": true, + "current_catalog": true, + "current_schema": true, + "null": true, + "true": true, + "false": true, +} + func QuoteDefaultValue(value, sqlType string) string { value = strings.TrimSpace(value) @@ -158,6 +178,12 @@ func QuoteDefaultValue(value, sqlType string) string { return value } + // Bare keyword expressions (e.g. CURRENT_DATE) are never quoted, + // regardless of column type. + if bareKeywordDefaults[strings.ToLower(value)] { + return value + } + // Normalise the SQL type: lowercase, strip length/precision suffix. baseType := strings.ToLower(strings.TrimSpace(sqlType)) if idx := strings.Index(baseType, "("); idx > 0 { diff --git a/pkg/writers/writer_test.go b/pkg/writers/writer_test.go index 63a2830..a9548bb 100644 --- a/pkg/writers/writer_test.go +++ b/pkg/writers/writer_test.go @@ -41,6 +41,24 @@ func TestQuoteDefaultValue(t *testing.T) { sqlType: "timestamptz", want: "now()", }, + { + name: "bare keyword default CURRENT_DATE is not quoted", + value: "CURRENT_DATE", + sqlType: "date", + want: "CURRENT_DATE", + }, + { + name: "bare keyword default is case insensitive", + value: "current_timestamp", + sqlType: "timestamptz", + want: "current_timestamp", + }, + { + name: "bare keyword default localtime is not quoted", + value: "LOCALTIME", + sqlType: "time", + want: "LOCALTIME", + }, } for _, tt := range tests {