Compare commits

...
4 Commits
Author SHA1 Message Date
Hein ae0efdc008 chore(release): update package version to 1.0.70
Release / test (push) Successful in 17s
Release / release (push) Successful in 2m43s
Release / pkg-deb (push) Failing after 37s
Release / pkg-aur (push) Successful in 52s
Release / pkg-rpm (push) Successful in 1m19s
2026-08-18 13:43:04 +02:00
Hein be08c8199f 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.
2026-08-18 13:42:34 +02:00
warkanum 19b592820c chore(release): update package version to 1.0.69
Release / test (push) Successful in 4m9s
Release / release (push) Successful in 2m11s
Release / pkg-deb (push) Failing after 1m46s
Release / pkg-aur (push) Successful in 2m5s
Release / pkg-rpm (push) Successful in 2m36s
2026-08-17 22:36:35 +02:00
warkanum b158a98acc fix(pgsql): strip backticks from column defaults in migration paths
Backtick-wrapped defaults (e.g. from GORM tags like `now()`) were only
stripped in the CREATE TABLE column-definition path, leaving raw
backticks in the ALTER COLUMN ... SET DEFAULT migration statement and
in the migration-generated CREATE TABLE template, producing invalid
SQL. Default-drift comparisons also compared raw values, so a
backtick-wrapped model default never matched the live DB default and
kept re-emitting redundant ALTER statements.
2026-08-17 22:36:14 +02:00
11 changed files with 170 additions and 7 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Hein (Warky Devs) <hein@warky.dev> # Maintainer: Hein (Warky Devs) <hein@warky.dev>
pkgname=relspec pkgname=relspec
pkgver=1.0.68 pkgver=1.0.70
pkgrel=1 pkgrel=1
pkgdesc="RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs." pkgdesc="RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs."
arch=('x86_64' 'aarch64') arch=('x86_64' 'aarch64')
+1 -1
View File
@@ -1,5 +1,5 @@
Name: relspec Name: relspec
Version: 1.0.68 Version: 1.0.70
Release: 1%{?dist} Release: 1%{?dist}
Summary: RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs. Summary: RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs.
+6 -1
View File
@@ -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 return typeName, length, precision, scale
} }
+44
View File
@@ -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) { func TestMergeConstraints_NewConstraint(t *testing.T) {
target := &models.Database{ target := &models.Database{
Schemas: []*models.Schema{ Schemas: []*models.Schema{
+22
View File
@@ -193,6 +193,28 @@ func IsKnownPGBaseType(baseType string) bool {
return ok 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 { func IsGoType(pTypeName string) bool {
for k := range GoToStdTypes { for k := range GoToStdTypes {
if strings.EqualFold(pTypeName, k) { if strings.EqualFold(pTypeName, k) {
+19 -2
View File
@@ -460,7 +460,7 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
} }
// Check default value changes // Check default value changes
if fmt.Sprintf("%v", modelCol.Default) != fmt.Sprintf("%v", currentCol.Default) { if !columnDefaultsEqual(modelCol.Default, currentCol.Default) {
setDefault, defaultVal := formatColumnDefaultSQL(modelCol) setDefault, defaultVal := formatColumnDefaultSQL(modelCol)
sql, err := w.executor.ExecuteAlterColumnDefaultWithCheck(AlterColumnDefaultWithCheckData{ sql, err := w.executor.ExecuteAlterColumnDefaultWithCheck(AlterColumnDefaultWithCheckData{
@@ -956,7 +956,24 @@ func columnsEqual(col1, col2 *models.Column) bool {
} }
return columnTypesEqual(col1, col2) && return columnTypesEqual(col1, col2) &&
col1.NotNull == col2.NotNull && col1.NotNull == col2.NotNull &&
fmt.Sprintf("%v", col1.Default) == fmt.Sprintf("%v", col2.Default) columnDefaultsEqual(col1.Default, col2.Default)
}
// columnDefaultsEqual compares column defaults for drift detection, stripping
// MySQL-style backticks (e.g. from GORM tags) so a model default of
// "`now()`" is recognised as equal to a live default of "now()".
func columnDefaultsEqual(default1, default2 interface{}) bool {
return normalizeDefaultForCompare(default1) == normalizeDefaultForCompare(default2)
}
func normalizeDefaultForCompare(value interface{}) string {
if value == nil {
return ""
}
if s, ok := value.(string); ok {
return strings.TrimSpace(stripBackticks(s))
}
return fmt.Sprintf("%v", value)
} }
func columnTypesEqual(col1, col2 *models.Column) bool { func columnTypesEqual(col1, col2 *models.Column) bool {
+1 -1
View File
@@ -530,7 +530,7 @@ func BuildCreateTableData(schemaName string, table *models.Table) CreateTableDat
} }
if col.Default != nil { if col.Default != nil {
if value, ok := col.Default.(string); ok { if value, ok := col.Default.(string); ok {
colData.Default = writers.QuoteDefaultValue(value, col.Type) colData.Default = writers.QuoteDefaultValue(stripBackticks(value), col.Type)
} else { } else {
colData.Default = fmt.Sprintf("%v", col.Default) colData.Default = fmt.Sprintf("%v", col.Default)
} }
+1 -1
View File
@@ -512,7 +512,7 @@ func formatColumnDefaultSQL(col *models.Column) (setDefault bool, defaultVal str
return false, "" return false, ""
} }
if value, ok := col.Default.(string); ok { if value, ok := col.Default.(string); ok {
return true, writers.QuoteDefaultValue(value, col.Type) return true, writers.QuoteDefaultValue(stripBackticks(value), col.Type)
} }
return true, fmt.Sprintf("%v", col.Default) return true, fmt.Sprintf("%v", col.Default)
} }
+31
View File
@@ -1140,6 +1140,37 @@ func TestWriteSchema_EmitsGuardedAlterColumnDefaultStatements(t *testing.T) {
} }
} }
func TestWriteSchema_AlterColumnDefaultStripsBackticksFromFunctionExpression(t *testing.T) {
db := models.InitDatabase("testdb")
schema := models.InitSchema("public")
table := models.InitTable("agent_skills", "public")
updatedAtCol := models.InitColumn("updatedat", "agent_skills", "public")
updatedAtCol.Type = "timestamp"
updatedAtCol.Default = "`now()`"
table.Columns["updatedat"] = updatedAtCol
schema.Tables = append(schema.Tables, table)
db.Schemas = append(db.Schemas, schema)
var buf bytes.Buffer
writer := NewWriter(&writers.WriterOptions{})
writer.writer = &buf
if err := writer.WriteDatabase(db); err != nil {
t.Fatalf("WriteDatabase failed: %v", err)
}
output := buf.String()
if strings.Contains(output, "`") {
t.Fatalf("expected no backticks in generated SQL, got:\n%s", output)
}
if !strings.Contains(output, "ALTER COLUMN updatedat SET DEFAULT now()") {
t.Fatalf("expected guarded SET DEFAULT now() without backticks, got:\n%s", output)
}
}
func TestWriteSchema_GuardedAlterColumnTypeFallsBackOnConversionFailure(t *testing.T) { func TestWriteSchema_GuardedAlterColumnTypeFallsBackOnConversionFailure(t *testing.T) {
db := models.InitDatabase("testdb") db := models.InitDatabase("testdb")
schema := models.InitSchema("public") schema := models.InitSchema("public")
+26
View File
@@ -148,6 +148,26 @@ func SanitizeFilename(name string) string {
// Examples (boolean): "true" → "true" // Examples (boolean): "true" → "true"
// Examples (bigint): "0" → "0" // Examples (bigint): "0" → "0"
// Examples (timestamp): "now()" → "now()" (function call never quoted) // 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 { func QuoteDefaultValue(value, sqlType string) string {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
@@ -158,6 +178,12 @@ func QuoteDefaultValue(value, sqlType string) string {
return value 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. // Normalise the SQL type: lowercase, strip length/precision suffix.
baseType := strings.ToLower(strings.TrimSpace(sqlType)) baseType := strings.ToLower(strings.TrimSpace(sqlType))
if idx := strings.Index(baseType, "("); idx > 0 { if idx := strings.Index(baseType, "("); idx > 0 {
+18
View File
@@ -41,6 +41,24 @@ func TestQuoteDefaultValue(t *testing.T) {
sqlType: "timestamptz", sqlType: "timestamptz",
want: "now()", 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 { for _, tt := range tests {