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
This commit is contained in:
@@ -442,12 +442,14 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
|
|||||||
} else if !columnsEqual(modelCol, currentCol) {
|
} else if !columnsEqual(modelCol, currentCol) {
|
||||||
// Column exists but properties changed
|
// Column exists but properties changed
|
||||||
if !columnTypesEqual(modelCol, currentCol) {
|
if !columnTypesEqual(modelCol, currentCol) {
|
||||||
sql, err := w.executor.ExecuteAlterColumnType(AlterColumnTypeData{
|
newType := effectiveAlterColumnSQLType(modelCol)
|
||||||
|
sql, err := w.executor.ExecuteAlterColumnTypeWithFallback(AlterColumnTypeWithFallbackData{
|
||||||
SchemaName: schema.Name,
|
SchemaName: schema.Name,
|
||||||
TableName: modelTable.Name,
|
TableName: modelTable.Name,
|
||||||
ColumnName: modelCol.Name,
|
ColumnName: modelCol.Name,
|
||||||
NewType: effectiveAlterColumnSQLType(modelCol),
|
NewType: newType,
|
||||||
UsingExpr: buildAlterColumnUsingExpression(modelCol.Name, effectiveAlterColumnSQLType(modelCol)),
|
UsingExpr: buildAlterColumnUsingExpression(modelCol.Name, newType),
|
||||||
|
OldColumnName: renamedColumnName(modelCol.Name, effectiveAlterColumnSQLType(currentCol)),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -962,6 +964,32 @@ func (w *MigrationWriter) generateAuditScripts(schema *models.Schema, auditConfi
|
|||||||
|
|
||||||
// Helper functions for comparing database objects
|
// Helper functions for comparing database objects
|
||||||
|
|
||||||
|
// renamedColumnName builds the fallback column name used when an in-place
|
||||||
|
// type conversion fails: "<column>_<oldtype>", 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
|
// columnsEqual checks if two columns have the same definition
|
||||||
func columnsEqual(col1, col2 *models.Column) bool {
|
func columnsEqual(col1, col2 *models.Column) bool {
|
||||||
if col1 == nil || col2 == nil {
|
if col1 == nil || col2 == nil {
|
||||||
|
|||||||
@@ -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) {
|
func TestWriteMigration_AltersColumnNullabilityWhenNotNullDiffers(t *testing.T) {
|
||||||
current := models.InitDatabase("testdb")
|
current := models.InitDatabase("testdb")
|
||||||
currentSchema := models.InitSchema("public")
|
currentSchema := models.InitSchema("public")
|
||||||
|
|||||||
@@ -98,6 +98,18 @@ type AlterColumnTypeData struct {
|
|||||||
UsingExpr string
|
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 {
|
type AlterColumnTypeWithCheckData struct {
|
||||||
SchemaName string
|
SchemaName string
|
||||||
TableName string
|
TableName string
|
||||||
@@ -320,6 +332,17 @@ func (te *TemplateExecutor) ExecuteAlterColumnType(data AlterColumnTypeData) (st
|
|||||||
return buf.String(), nil
|
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) {
|
func (te *TemplateExecutor) ExecuteAlterColumnTypeWithCheck(data AlterColumnTypeWithCheckData) (string, error) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err := te.templates.ExecuteTemplate(&buf, "alter_column_type_with_check.tmpl", data)
|
err := te.templates.ExecuteTemplate(&buf, "alter_column_type_with_check.tmpl", data)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
DO $$
|
DO $$
|
||||||
DECLARE
|
DECLARE
|
||||||
current_type text;
|
current_type text;
|
||||||
|
renamed_column text;
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
|
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
|
||||||
INTO current_type
|
INTO current_type
|
||||||
@@ -15,8 +16,15 @@ BEGIN
|
|||||||
|
|
||||||
IF current_type IS NOT NULL
|
IF current_type IS NOT NULL
|
||||||
AND current_type <> ALL(ARRAY[{{.EquivalentTypes}}]) THEN
|
AND current_type <> ALL(ARRAY[{{.EquivalentTypes}}]) THEN
|
||||||
|
BEGIN
|
||||||
ALTER TABLE {{qual_table .SchemaName .TableName}}
|
ALTER TABLE {{qual_table .SchemaName .TableName}}
|
||||||
ALTER COLUMN {{quote_ident .ColumnName}} TYPE {{.NewType}}{{if .UsingExpr}} USING {{.UsingExpr}}{{end}};
|
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 IF;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
$$;
|
||||||
@@ -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) {
|
func TestWriteSchema_UsesStorageTypeForSerialAlterStatements(t *testing.T) {
|
||||||
db := models.InitDatabase("testdb")
|
db := models.InitDatabase("testdb")
|
||||||
schema := models.InitSchema("public")
|
schema := models.InitSchema("public")
|
||||||
|
|||||||
Reference in New Issue
Block a user