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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user