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 {