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.
This commit is contained in:
Hein
2026-08-14 16:17:17 +02:00
parent e650406177
commit d84306934a
11 changed files with 275 additions and 137 deletions
+11 -52
View File
@@ -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: "<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
func columnsEqual(col1, col2 *models.Column) bool {
if col1 == nil || col2 == nil {