From d84306934a8aecb14627310c90ba3b0670e0acaf Mon Sep 17 00:00:00 2001 From: Hein Date: Fri, 14 Aug 2026 16:17:17 +0200 Subject: [PATCH] fix(pgsql): handle nullability/type/default drift on existing columns Existing databases that already ran an old migration kept stale NOT NULL constraints and mismatched column types/defaults, because the schema writer only emitted idempotent ADD COLUMN IF NOT EXISTS guards and never altered columns that already existed. - Emit guarded ALTER COLUMN ... SET/DROP NOT NULL when a column's nullability differs from the model. - Emit guarded ALTER COLUMN ... TYPE, falling back to renaming the old column and adding a fresh one when the in-place conversion fails. - Emit guarded ALTER COLUMN ... SET/DROP DEFAULT for default drift. - Collapse the previously duplicated plain/guarded templates so WriteSchema (full-schema, live-state-checking) and WriteMigration (diff-based) share the same guarded SQL templates and Go helpers instead of maintaining the logic twice. --- pkg/writers/pgsql/migration_writer.go | 63 ++--------- pkg/writers/pgsql/migration_writer_test.go | 7 +- pkg/writers/pgsql/templates.go | 80 +++++-------- .../pgsql/templates/alter_column_default.tmpl | 7 -- .../alter_column_default_with_check.tmpl | 29 +++++ .../templates/alter_column_nullability.tmpl | 7 -- .../alter_column_nullability_with_check.tmpl | 26 +++++ .../pgsql/templates/alter_column_type.tmpl | 2 - .../alter_column_type_with_fallback.tmpl | 13 --- pkg/writers/pgsql/writer.go | 107 ++++++++++++++++++ pkg/writers/pgsql/writer_test.go | 71 ++++++++++++ 11 files changed, 275 insertions(+), 137 deletions(-) delete mode 100644 pkg/writers/pgsql/templates/alter_column_default.tmpl create mode 100644 pkg/writers/pgsql/templates/alter_column_default_with_check.tmpl delete mode 100644 pkg/writers/pgsql/templates/alter_column_nullability.tmpl create mode 100644 pkg/writers/pgsql/templates/alter_column_nullability_with_check.tmpl delete mode 100644 pkg/writers/pgsql/templates/alter_column_type.tmpl delete 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 df2f7f8..a5d0587 100644 --- a/pkg/writers/pgsql/migration_writer.go +++ b/pkg/writers/pgsql/migration_writer.go @@ -409,14 +409,7 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model if !exists { // Column doesn't exist, add it - defaultVal := "" - if modelCol.Default != nil { - if value, ok := modelCol.Default.(string); ok { - defaultVal = writers.QuoteDefaultValue(value, modelCol.Type) - } else { - defaultVal = fmt.Sprintf("%v", modelCol.Default) - } - } + _, defaultVal := formatColumnDefaultSQL(modelCol) sql, err := w.executor.ExecuteAddColumn(AddColumnData{ SchemaName: schema.Name, @@ -443,13 +436,13 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model // Column exists but properties changed if !columnTypesEqual(modelCol, currentCol) { 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)), + sql, err := w.executor.ExecuteAlterColumnTypeWithCheck(AlterColumnTypeWithCheckData{ + SchemaName: schema.Name, + TableName: modelTable.Name, + ColumnName: modelCol.Name, + NewType: newType, + EquivalentTypes: equivalentTypeListSQL(newType), + UsingExpr: buildAlterColumnUsingExpression(modelCol.Name, newType), }) if err != nil { return nil, err @@ -468,17 +461,9 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model // Check default value changes if fmt.Sprintf("%v", modelCol.Default) != fmt.Sprintf("%v", currentCol.Default) { - setDefault := modelCol.Default != nil - defaultVal := "" - if setDefault { - if value, ok := modelCol.Default.(string); ok { - defaultVal = writers.QuoteDefaultValue(value, modelCol.Type) - } else { - defaultVal = fmt.Sprintf("%v", modelCol.Default) - } - } + setDefault, defaultVal := formatColumnDefaultSQL(modelCol) - sql, err := w.executor.ExecuteAlterColumnDefault(AlterColumnDefaultData{ + sql, err := w.executor.ExecuteAlterColumnDefaultWithCheck(AlterColumnDefaultWithCheckData{ SchemaName: schema.Name, TableName: modelTable.Name, ColumnName: modelCol.Name, @@ -502,7 +487,7 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model // Check nullability changes if modelCol.NotNull != currentCol.NotNull { - sql, err := w.executor.ExecuteAlterColumnNullability(AlterColumnNullabilityData{ + sql, err := w.executor.ExecuteAlterColumnNullabilityWithCheck(AlterColumnNullabilityWithCheckData{ SchemaName: schema.Name, TableName: modelTable.Name, ColumnName: modelCol.Name, @@ -964,32 +949,6 @@ 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 8021349..bfe630a 100644 --- a/pkg/writers/pgsql/migration_writer_test.go +++ b/pkg/writers/pgsql/migration_writer_test.go @@ -170,8 +170,11 @@ func TestWriteMigration_AltersColumnTypeFallsBackToRenameAndAddOnConversionFailu 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, "RENAME COLUMN details TO %I") { + t.Fatalf("expected migration to rename the old column (derived from the live type) on conversion failure, got:\n%s", output) + } + if !strings.Contains(output, "renamed_column := 'details_' || trim(both '_' from regexp_replace(lower(current_type)") { + t.Fatalf("expected migration to derive the renamed column name from the live type, 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) diff --git a/pkg/writers/pgsql/templates.go b/pkg/writers/pgsql/templates.go index 9998335..c51f170 100644 --- a/pkg/writers/pgsql/templates.go +++ b/pkg/writers/pgsql/templates.go @@ -89,27 +89,10 @@ type AddColumnData struct { NotNull bool } -// AlterColumnTypeData contains data for alter column type template -type AlterColumnTypeData struct { - SchemaName string - TableName string - ColumnName string - NewType 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 -} - +// AlterColumnTypeWithCheckData contains data for the guarded alter column +// type template, which only alters existing columns whose live type +// differs from the desired one, and falls back to renaming the old column +// and adding a fresh one when the in-place conversion is not possible. type AlterColumnTypeWithCheckData struct { SchemaName string TableName string @@ -119,8 +102,10 @@ type AlterColumnTypeWithCheckData struct { UsingExpr string } -// AlterColumnDefaultData contains data for alter column default template -type AlterColumnDefaultData struct { +// AlterColumnDefaultWithCheckData contains data for the guarded alter +// column default template, which only alters existing columns whose live +// default differs from the desired one. +type AlterColumnDefaultWithCheckData struct { SchemaName string TableName string ColumnName string @@ -128,8 +113,10 @@ type AlterColumnDefaultData struct { DefaultValue string } -// AlterColumnNullabilityData contains data for alter column nullability template -type AlterColumnNullabilityData struct { +// AlterColumnNullabilityWithCheckData contains data for the guarded alter +// column nullability template, which only alters existing columns whose +// live NOT NULL state differs from the desired one. +type AlterColumnNullabilityWithCheckData struct { SchemaName string TableName string ColumnName string @@ -322,27 +309,9 @@ func (te *TemplateExecutor) ExecuteAddColumn(data AddColumnData) (string, error) return buf.String(), nil } -// ExecuteAlterColumnType executes the alter column type template -func (te *TemplateExecutor) ExecuteAlterColumnType(data AlterColumnTypeData) (string, error) { - var buf bytes.Buffer - err := te.templates.ExecuteTemplate(&buf, "alter_column_type.tmpl", data) - if err != nil { - return "", fmt.Errorf("failed to execute alter_column_type template: %w", err) - } - 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 -} - +// ExecuteAlterColumnTypeWithCheck executes the guarded alter column type +// template shared by the full-schema writer and the diff-based migration +// writer. func (te *TemplateExecutor) ExecuteAlterColumnTypeWithCheck(data AlterColumnTypeWithCheckData) (string, error) { var buf bytes.Buffer err := te.templates.ExecuteTemplate(&buf, "alter_column_type_with_check.tmpl", data) @@ -352,22 +321,25 @@ func (te *TemplateExecutor) ExecuteAlterColumnTypeWithCheck(data AlterColumnType return buf.String(), nil } -// ExecuteAlterColumnDefault executes the alter column default template -func (te *TemplateExecutor) ExecuteAlterColumnDefault(data AlterColumnDefaultData) (string, error) { +// ExecuteAlterColumnDefaultWithCheck executes the guarded alter column +// default template shared by the full-schema writer and the diff-based +// migration writer. +func (te *TemplateExecutor) ExecuteAlterColumnDefaultWithCheck(data AlterColumnDefaultWithCheckData) (string, error) { var buf bytes.Buffer - err := te.templates.ExecuteTemplate(&buf, "alter_column_default.tmpl", data) + err := te.templates.ExecuteTemplate(&buf, "alter_column_default_with_check.tmpl", data) if err != nil { - return "", fmt.Errorf("failed to execute alter_column_default template: %w", err) + return "", fmt.Errorf("failed to execute alter_column_default_with_check template: %w", err) } return buf.String(), nil } -// ExecuteAlterColumnNullability executes the alter column nullability template -func (te *TemplateExecutor) ExecuteAlterColumnNullability(data AlterColumnNullabilityData) (string, error) { +// ExecuteAlterColumnNullabilityWithCheck executes the guarded alter column +// nullability template. +func (te *TemplateExecutor) ExecuteAlterColumnNullabilityWithCheck(data AlterColumnNullabilityWithCheckData) (string, error) { var buf bytes.Buffer - err := te.templates.ExecuteTemplate(&buf, "alter_column_nullability.tmpl", data) + err := te.templates.ExecuteTemplate(&buf, "alter_column_nullability_with_check.tmpl", data) if err != nil { - return "", fmt.Errorf("failed to execute alter_column_nullability template: %w", err) + return "", fmt.Errorf("failed to execute alter_column_nullability_with_check template: %w", err) } return buf.String(), nil } diff --git a/pkg/writers/pgsql/templates/alter_column_default.tmpl b/pkg/writers/pgsql/templates/alter_column_default.tmpl deleted file mode 100644 index f6a19a6..0000000 --- a/pkg/writers/pgsql/templates/alter_column_default.tmpl +++ /dev/null @@ -1,7 +0,0 @@ -{{- if .SetDefault -}} -ALTER TABLE {{qual_table .SchemaName .TableName}} - ALTER COLUMN {{quote_ident .ColumnName}} SET DEFAULT {{.DefaultValue}}; -{{- else -}} -ALTER TABLE {{qual_table .SchemaName .TableName}} - ALTER COLUMN {{quote_ident .ColumnName}} DROP DEFAULT; -{{- end -}} \ No newline at end of file diff --git a/pkg/writers/pgsql/templates/alter_column_default_with_check.tmpl b/pkg/writers/pgsql/templates/alter_column_default_with_check.tmpl new file mode 100644 index 0000000..27e977c --- /dev/null +++ b/pkg/writers/pgsql/templates/alter_column_default_with_check.tmpl @@ -0,0 +1,29 @@ +DO $$ +DECLARE + current_default text; +BEGIN + SELECT pg_catalog.pg_get_expr(d.adbin, d.adrelid) + INTO current_default + FROM pg_attribute a + JOIN pg_class t ON t.oid = a.attrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum + WHERE n.nspname = '{{.SchemaName}}' + AND t.relname = '{{.TableName}}' + AND a.attname = '{{.ColumnName}}' + AND a.attnum > 0 + AND NOT a.attisdropped; + +{{- if .SetDefault }} + IF current_default IS DISTINCT FROM {{quote .DefaultValue}} THEN + ALTER TABLE {{qual_table .SchemaName .TableName}} + ALTER COLUMN {{quote_ident .ColumnName}} SET DEFAULT {{.DefaultValue}}; + END IF; +{{- else }} + IF current_default IS NOT NULL THEN + ALTER TABLE {{qual_table .SchemaName .TableName}} + ALTER COLUMN {{quote_ident .ColumnName}} DROP DEFAULT; + END IF; +{{- end }} +END; +$$; diff --git a/pkg/writers/pgsql/templates/alter_column_nullability.tmpl b/pkg/writers/pgsql/templates/alter_column_nullability.tmpl deleted file mode 100644 index ee81e26..0000000 --- a/pkg/writers/pgsql/templates/alter_column_nullability.tmpl +++ /dev/null @@ -1,7 +0,0 @@ -{{- if .NotNull -}} -ALTER TABLE {{qual_table .SchemaName .TableName}} - ALTER COLUMN {{quote_ident .ColumnName}} SET NOT NULL; -{{- else -}} -ALTER TABLE {{qual_table .SchemaName .TableName}} - ALTER COLUMN {{quote_ident .ColumnName}} DROP NOT NULL; -{{- end -}} diff --git a/pkg/writers/pgsql/templates/alter_column_nullability_with_check.tmpl b/pkg/writers/pgsql/templates/alter_column_nullability_with_check.tmpl new file mode 100644 index 0000000..d560d6e --- /dev/null +++ b/pkg/writers/pgsql/templates/alter_column_nullability_with_check.tmpl @@ -0,0 +1,26 @@ +DO $$ +DECLARE + current_not_null boolean; +BEGIN + SELECT a.attnotnull + INTO current_not_null + FROM pg_attribute a + JOIN pg_class t ON t.oid = a.attrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + WHERE n.nspname = '{{.SchemaName}}' + AND t.relname = '{{.TableName}}' + AND a.attname = '{{.ColumnName}}' + AND a.attnum > 0 + AND NOT a.attisdropped; + + IF current_not_null IS NOT NULL AND current_not_null IS DISTINCT FROM {{.NotNull}} THEN +{{- if .NotNull }} + ALTER TABLE {{qual_table .SchemaName .TableName}} + ALTER COLUMN {{quote_ident .ColumnName}} SET NOT NULL; +{{- else }} + ALTER TABLE {{qual_table .SchemaName .TableName}} + ALTER COLUMN {{quote_ident .ColumnName}} DROP NOT NULL; +{{- end }} + END IF; +END; +$$; diff --git a/pkg/writers/pgsql/templates/alter_column_type.tmpl b/pkg/writers/pgsql/templates/alter_column_type.tmpl deleted file mode 100644 index aaccd28..0000000 --- a/pkg/writers/pgsql/templates/alter_column_type.tmpl +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE {{qual_table .SchemaName .TableName}} - ALTER COLUMN {{quote_ident .ColumnName}} TYPE {{.NewType}}{{if .UsingExpr}} USING {{.UsingExpr}}{{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 deleted file mode 100644 index 32ec599..0000000 --- a/pkg/writers/pgsql/templates/alter_column_type_with_fallback.tmpl +++ /dev/null @@ -1,13 +0,0 @@ -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.go b/pkg/writers/pgsql/writer.go index 110d10c..b09ef3a 100644 --- a/pkg/writers/pgsql/writer.go +++ b/pkg/writers/pgsql/writer.go @@ -475,6 +475,75 @@ func (w *Writer) GenerateAlterColumnTypeStatements(schema *models.Schema) ([]str return statements, nil } +// GenerateAlterColumnDefaultStatements generates guarded ALTER TABLE +// statements to bring existing columns' DEFAULT clause in line with the +// model, safe to run against a database that already has the columns. +func (w *Writer) GenerateAlterColumnDefaultStatements(schema *models.Schema) ([]string, error) { + statements := []string{} + + statements = append(statements, fmt.Sprintf("-- Alter column defaults for schema: %s", schema.Name)) + + for _, table := range schema.Tables { + columns := getSortedColumns(table.Columns) + for _, col := range columns { + setDefault, defaultVal := formatColumnDefaultSQL(col) + stmt, err := w.executor.ExecuteAlterColumnDefaultWithCheck(AlterColumnDefaultWithCheckData{ + SchemaName: schema.Name, + TableName: table.Name, + ColumnName: col.Name, + SetDefault: setDefault, + DefaultValue: defaultVal, + }) + if err != nil { + return nil, fmt.Errorf("failed to generate alter column default for %s.%s.%s: %w", schema.Name, table.Name, col.Name, err) + } + statements = append(statements, stmt) + } + } + + return statements, nil +} + +// formatColumnDefaultSQL renders a column's model-level default into the +// SQL literal/expression used by ALTER COLUMN ... SET DEFAULT, shared by +// the full-schema writer and the diff-based migration writer. +func formatColumnDefaultSQL(col *models.Column) (setDefault bool, defaultVal string) { + if col.Default == nil { + return false, "" + } + if value, ok := col.Default.(string); ok { + return true, writers.QuoteDefaultValue(value, col.Type) + } + return true, fmt.Sprintf("%v", col.Default) +} + +// GenerateAlterColumnNullabilityStatements generates guarded ALTER TABLE +// statements to bring existing columns' NOT NULL state in line with the +// model, safe to run against a database that already has the columns. +func (w *Writer) GenerateAlterColumnNullabilityStatements(schema *models.Schema) ([]string, error) { + statements := []string{} + + statements = append(statements, fmt.Sprintf("-- Alter column nullability for schema: %s", schema.Name)) + + for _, table := range schema.Tables { + columns := getSortedColumns(table.Columns) + for _, col := range columns { + stmt, err := w.executor.ExecuteAlterColumnNullabilityWithCheck(AlterColumnNullabilityWithCheckData{ + SchemaName: schema.Name, + TableName: table.Name, + ColumnName: col.Name, + NotNull: col.NotNull, + }) + if err != nil { + return nil, fmt.Errorf("failed to generate alter column nullability for %s.%s.%s: %w", schema.Name, table.Name, col.Name, err) + } + statements = append(statements, stmt) + } + } + + return statements, nil +} + // GenerateAddColumnsForDatabase generates ALTER TABLE ADD COLUMN statements for the entire database func (w *Writer) GenerateAddColumnsForDatabase(db *models.Database) ([]string, error) { statements := []string{} @@ -641,6 +710,14 @@ func (w *Writer) WriteSchema(schema *models.Schema) error { return err } + if err := w.writeAlterColumnDefaults(schema); err != nil { + return err + } + + if err := w.writeAlterColumnNullability(schema); err != nil { + return err + } + // Phase 4: Create primary keys (priority 160) if err := w.writePrimaryKeys(schema); err != nil { return err @@ -859,6 +936,36 @@ func (w *Writer) writeAlterColumnTypes(schema *models.Schema) error { return nil } +func (w *Writer) writeAlterColumnDefaults(schema *models.Schema) error { + fmt.Fprintf(w.writer, "-- Alter column defaults for schema: %s\n", schema.Name) + + statements, err := w.GenerateAlterColumnDefaultStatements(schema) + if err != nil { + return err + } + for _, stmt := range statements[1:] { + fmt.Fprint(w.writer, stmt) + fmt.Fprint(w.writer, "\n") + } + + return nil +} + +func (w *Writer) writeAlterColumnNullability(schema *models.Schema) error { + fmt.Fprintf(w.writer, "-- Alter column nullability for schema: %s\n", schema.Name) + + statements, err := w.GenerateAlterColumnNullabilityStatements(schema) + if err != nil { + return err + } + for _, stmt := range statements[1:] { + fmt.Fprint(w.writer, stmt) + fmt.Fprint(w.writer, "\n") + } + + return nil +} + // writePrimaryKeys generates ALTER TABLE statements for primary keys func (w *Writer) writePrimaryKeys(schema *models.Schema) error { fmt.Fprintf(w.writer, "-- Primary keys for schema: %s\n", schema.Name) diff --git a/pkg/writers/pgsql/writer_test.go b/pkg/writers/pgsql/writer_test.go index ea6ac0f..254facb 100644 --- a/pkg/writers/pgsql/writer_test.go +++ b/pkg/writers/pgsql/writer_test.go @@ -1106,6 +1106,40 @@ func TestWriteSchema_EmitsGuardedAlterColumnTypeStatements(t *testing.T) { } } +func TestWriteSchema_EmitsGuardedAlterColumnDefaultStatements(t *testing.T) { + db := models.InitDatabase("testdb") + schema := models.InitSchema("public") + + table := models.InitTable("agent_skills", "public") + + statusCol := models.InitColumn("status", "agent_skills", "public") + statusCol.Type = "text" + statusCol.Default = "active" + table.Columns["status"] = statusCol + + 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, "-- Alter column defaults for schema: public") { + t.Fatalf("expected alter column default section, got:\n%s", output) + } + if !strings.Contains(output, "pg_get_expr(d.adbin, d.adrelid)") { + t.Fatalf("expected guarded live-default check, got:\n%s", output) + } + if !strings.Contains(output, "ALTER COLUMN status SET DEFAULT 'active'") { + t.Fatalf("expected guarded SET DEFAULT for status column, got:\n%s", output) + } +} + func TestWriteSchema_GuardedAlterColumnTypeFallsBackOnConversionFailure(t *testing.T) { db := models.InitDatabase("testdb") schema := models.InitSchema("public") @@ -1142,6 +1176,43 @@ func TestWriteSchema_GuardedAlterColumnTypeFallsBackOnConversionFailure(t *testi } } +func TestWriteSchema_EmitsGuardedAlterColumnNullabilityStatements(t *testing.T) { + db := models.InitDatabase("testdb") + schema := models.InitSchema("origin") + + table := models.InitTable("service_instance", "origin") + + typeCol := models.InitColumn("rid_service_instance_type", "service_instance", "origin") + typeCol.Type = "text" + typeCol.NotNull = false + table.Columns["rid_service_instance_type"] = typeCol + + 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, "-- Alter column nullability for schema: origin") { + t.Fatalf("expected alter column nullability section, got:\n%s", output) + } + if !strings.Contains(output, "a.attnotnull") { + t.Fatalf("expected guarded live-nullability check, got:\n%s", output) + } + if !strings.Contains(output, "current_not_null IS DISTINCT FROM false") { + t.Fatalf("expected guard comparing live nullability against desired value, got:\n%s", output) + } + if !strings.Contains(output, "ALTER COLUMN rid_service_instance_type DROP NOT NULL") { + t.Fatalf("expected guarded DROP NOT NULL for nullable column, got:\n%s", output) + } +} + func TestWriteSchema_UsesStorageTypeForSerialAlterStatements(t *testing.T) { db := models.InitDatabase("testdb") schema := models.InitSchema("public")