From fc3409f3241c6e15ea0c424280eae362276a1cc3 Mon Sep 17 00:00:00 2001 From: Hein Date: Fri, 14 Aug 2026 14:14:24 +0200 Subject: [PATCH] feat(migration): add fallback for column type conversion failures * implement renaming of old column and adding new column on conversion failure * update templates and tests to support new behavior --- pkg/writers/pgsql/migration_writer.go | 40 +++++++++++++++--- pkg/writers/pgsql/migration_writer_test.go | 42 +++++++++++++++++++ pkg/writers/pgsql/templates.go | 23 ++++++++++ .../alter_column_type_with_check.tmpl | 12 +++++- .../alter_column_type_with_fallback.tmpl | 13 ++++++ pkg/writers/pgsql/writer_test.go | 36 ++++++++++++++++ 6 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 pkg/writers/pgsql/templates/alter_column_type_with_fallback.tmpl diff --git a/pkg/writers/pgsql/migration_writer.go b/pkg/writers/pgsql/migration_writer.go index 6640817..df2f7f8 100644 --- a/pkg/writers/pgsql/migration_writer.go +++ b/pkg/writers/pgsql/migration_writer.go @@ -442,12 +442,14 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model } else if !columnsEqual(modelCol, currentCol) { // Column exists but properties changed if !columnTypesEqual(modelCol, currentCol) { - sql, err := w.executor.ExecuteAlterColumnType(AlterColumnTypeData{ - SchemaName: schema.Name, - TableName: modelTable.Name, - ColumnName: modelCol.Name, - NewType: effectiveAlterColumnSQLType(modelCol), - UsingExpr: buildAlterColumnUsingExpression(modelCol.Name, effectiveAlterColumnSQLType(modelCol)), + newType := effectiveAlterColumnSQLType(modelCol) + sql, err := w.executor.ExecuteAlterColumnTypeWithFallback(AlterColumnTypeWithFallbackData{ + SchemaName: schema.Name, + TableName: modelTable.Name, + ColumnName: modelCol.Name, + NewType: newType, + UsingExpr: buildAlterColumnUsingExpression(modelCol.Name, newType), + OldColumnName: renamedColumnName(modelCol.Name, effectiveAlterColumnSQLType(currentCol)), }) if err != nil { return nil, err @@ -962,6 +964,32 @@ func (w *MigrationWriter) generateAuditScripts(schema *models.Schema, auditConfi // Helper functions for comparing database objects +// renamedColumnName builds the fallback column name used when an in-place +// type conversion fails: "_", with the old type sanitized +// to a valid identifier fragment (e.g. "varchar(50)" -> "varchar_50"). +func renamedColumnName(columnName, oldType string) string { + sanitized := strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + return r + case r >= 'A' && r <= 'Z': + return r + ('a' - 'A') + default: + return '_' + } + }, oldType) + + for strings.Contains(sanitized, "__") { + sanitized = strings.ReplaceAll(sanitized, "__", "_") + } + sanitized = strings.Trim(sanitized, "_") + + if sanitized == "" { + return columnName + "_old" + } + return columnName + "_" + sanitized +} + // columnsEqual checks if two columns have the same definition func columnsEqual(col1, col2 *models.Column) bool { if col1 == nil || col2 == nil { diff --git a/pkg/writers/pgsql/migration_writer_test.go b/pkg/writers/pgsql/migration_writer_test.go index 9f89978..8021349 100644 --- a/pkg/writers/pgsql/migration_writer_test.go +++ b/pkg/writers/pgsql/migration_writer_test.go @@ -136,6 +136,48 @@ func TestWriteMigration_AltersColumnTypeWhenActualTypeDiffers(t *testing.T) { } } +func TestWriteMigration_AltersColumnTypeFallsBackToRenameAndAddOnConversionFailure(t *testing.T) { + current := models.InitDatabase("testdb") + currentSchema := models.InitSchema("public") + currentTable := models.InitTable("learnings", "public") + currentDetails := models.InitColumn("details", "learnings", "public") + currentDetails.Type = "varchar(50)" + currentTable.Columns["details"] = currentDetails + currentSchema.Tables = append(currentSchema.Tables, currentTable) + current.Schemas = append(current.Schemas, currentSchema) + + model := models.InitDatabase("testdb") + modelSchema := models.InitSchema("public") + modelTable := models.InitTable("learnings", "public") + modelDetails := models.InitColumn("details", "learnings", "public") + modelDetails.Type = "integer" + modelTable.Columns["details"] = modelDetails + modelSchema.Tables = append(modelSchema.Tables, modelTable) + model.Schemas = append(model.Schemas, modelSchema) + + var buf bytes.Buffer + writer, err := NewMigrationWriter(&writers.WriterOptions{}) + if err != nil { + t.Fatalf("Failed to create writer: %v", err) + } + writer.writer = &buf + + if err := writer.WriteMigration(model, current); err != nil { + t.Fatalf("WriteMigration failed: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "EXCEPTION WHEN OTHERS THEN") { + t.Fatalf("expected migration to guard the type conversion with an exception handler, got:\n%s", output) + } + if !strings.Contains(output, "RENAME COLUMN details TO details_varchar_50") { + t.Fatalf("expected migration to rename the old column on conversion failure, got:\n%s", output) + } + if !strings.Contains(output, "ADD COLUMN details integer") { + t.Fatalf("expected migration to add a fresh column with the new type on conversion failure, got:\n%s", output) + } +} + func TestWriteMigration_AltersColumnNullabilityWhenNotNullDiffers(t *testing.T) { current := models.InitDatabase("testdb") currentSchema := models.InitSchema("public") diff --git a/pkg/writers/pgsql/templates.go b/pkg/writers/pgsql/templates.go index ecb71b5..9998335 100644 --- a/pkg/writers/pgsql/templates.go +++ b/pkg/writers/pgsql/templates.go @@ -98,6 +98,18 @@ type AlterColumnTypeData struct { UsingExpr string } +// AlterColumnTypeWithFallbackData contains data for the alter column type +// template that falls back to renaming the old column and adding a fresh +// one when the in-place type conversion is not possible. +type AlterColumnTypeWithFallbackData struct { + SchemaName string + TableName string + ColumnName string + NewType string + UsingExpr string + OldColumnName string +} + type AlterColumnTypeWithCheckData struct { SchemaName string TableName string @@ -320,6 +332,17 @@ func (te *TemplateExecutor) ExecuteAlterColumnType(data AlterColumnTypeData) (st return buf.String(), nil } +// ExecuteAlterColumnTypeWithFallback executes the alter column type template +// that renames the old column and adds a new one when the conversion fails. +func (te *TemplateExecutor) ExecuteAlterColumnTypeWithFallback(data AlterColumnTypeWithFallbackData) (string, error) { + var buf bytes.Buffer + err := te.templates.ExecuteTemplate(&buf, "alter_column_type_with_fallback.tmpl", data) + if err != nil { + return "", fmt.Errorf("failed to execute alter_column_type_with_fallback template: %w", err) + } + return buf.String(), nil +} + func (te *TemplateExecutor) ExecuteAlterColumnTypeWithCheck(data AlterColumnTypeWithCheckData) (string, error) { var buf bytes.Buffer err := te.templates.ExecuteTemplate(&buf, "alter_column_type_with_check.tmpl", data) diff --git a/pkg/writers/pgsql/templates/alter_column_type_with_check.tmpl b/pkg/writers/pgsql/templates/alter_column_type_with_check.tmpl index 6c2ed8e..719c108 100644 --- a/pkg/writers/pgsql/templates/alter_column_type_with_check.tmpl +++ b/pkg/writers/pgsql/templates/alter_column_type_with_check.tmpl @@ -1,6 +1,7 @@ DO $$ DECLARE current_type text; + renamed_column text; BEGIN SELECT pg_catalog.format_type(a.atttypid, a.atttypmod) INTO current_type @@ -15,8 +16,15 @@ BEGIN IF current_type IS NOT NULL AND current_type <> ALL(ARRAY[{{.EquivalentTypes}}]) THEN - ALTER TABLE {{qual_table .SchemaName .TableName}} - ALTER COLUMN {{quote_ident .ColumnName}} TYPE {{.NewType}}{{if .UsingExpr}} USING {{.UsingExpr}}{{end}}; + BEGIN + ALTER TABLE {{qual_table .SchemaName .TableName}} + ALTER COLUMN {{quote_ident .ColumnName}} TYPE {{.NewType}}{{if .UsingExpr}} USING {{.UsingExpr}}{{end}}; + EXCEPTION WHEN OTHERS THEN + renamed_column := '{{.ColumnName}}_' || trim(both '_' from regexp_replace(lower(current_type), '[^a-z0-9]+', '_', 'g')); + EXECUTE format('ALTER TABLE {{qual_table .SchemaName .TableName}} RENAME COLUMN {{quote_ident .ColumnName}} TO %I', renamed_column); + ALTER TABLE {{qual_table .SchemaName .TableName}} + ADD COLUMN {{quote_ident .ColumnName}} {{.NewType}}; + END; END IF; END; $$; diff --git a/pkg/writers/pgsql/templates/alter_column_type_with_fallback.tmpl b/pkg/writers/pgsql/templates/alter_column_type_with_fallback.tmpl new file mode 100644 index 0000000..32ec599 --- /dev/null +++ b/pkg/writers/pgsql/templates/alter_column_type_with_fallback.tmpl @@ -0,0 +1,13 @@ +DO $$ +BEGIN + BEGIN + ALTER TABLE {{qual_table .SchemaName .TableName}} + ALTER COLUMN {{quote_ident .ColumnName}} TYPE {{.NewType}}{{if .UsingExpr}} USING {{.UsingExpr}}{{end}}; + EXCEPTION WHEN OTHERS THEN + ALTER TABLE {{qual_table .SchemaName .TableName}} + RENAME COLUMN {{quote_ident .ColumnName}} TO {{quote_ident .OldColumnName}}; + ALTER TABLE {{qual_table .SchemaName .TableName}} + ADD COLUMN {{quote_ident .ColumnName}} {{.NewType}}; + END; +END; +$$; diff --git a/pkg/writers/pgsql/writer_test.go b/pkg/writers/pgsql/writer_test.go index eeed3f2..ea6ac0f 100644 --- a/pkg/writers/pgsql/writer_test.go +++ b/pkg/writers/pgsql/writer_test.go @@ -1106,6 +1106,42 @@ func TestWriteSchema_EmitsGuardedAlterColumnTypeStatements(t *testing.T) { } } +func TestWriteSchema_GuardedAlterColumnTypeFallsBackOnConversionFailure(t *testing.T) { + db := models.InitDatabase("testdb") + schema := models.InitSchema("public") + + table := models.InitTable("agent_skills", "public") + + nameCol := models.InitColumn("name", "agent_skills", "public") + nameCol.Type = "integer" + table.Columns["name"] = nameCol + + 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, "EXCEPTION WHEN OTHERS THEN") { + t.Fatalf("expected guarded alter to fall back on conversion failure, got:\n%s", output) + } + if !strings.Contains(output, "renamed_column := 'name_' || trim(both '_' from regexp_replace(lower(current_type)") { + t.Fatalf("expected fallback to derive a renamed column name from the live type, got:\n%s", output) + } + if !strings.Contains(output, "RENAME COLUMN name TO %I") { + t.Fatalf("expected fallback to rename the existing column, got:\n%s", output) + } + if !strings.Contains(output, "ADD COLUMN name integer") { + t.Fatalf("expected fallback to add a fresh column with the new type, got:\n%s", output) + } +} + func TestWriteSchema_UsesStorageTypeForSerialAlterStatements(t *testing.T) { db := models.InitDatabase("testdb") schema := models.InitSchema("public")