Compare commits

..
3 Commits
Author SHA1 Message Date
Hein e650406177 chore(release): update package version to 1.0.67
Release / test (push) Successful in 11s
Release / release (push) Successful in 1m49s
Release / pkg-aur (push) Successful in 58s
Release / pkg-rpm (push) Successful in 2m47s
Release / pkg-deb (push) Successful in 2m57s
2026-08-14 14:15:02 +02:00
Hein fc3409f324 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
2026-08-14 14:14:24 +02:00
Hein 97139723c9 feat(migration): add support for altering column nullability
* Implement ExecuteAlterColumnNullability function
* Create alter_column_nullability template
* Add test for altering column nullability behavior
2026-08-14 14:10:21 +02:00
9 changed files with 246 additions and 10 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
pkgname=relspec
pkgver=1.0.66
pkgver=1.0.67
pkgrel=1
pkgdesc="RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs."
arch=('x86_64' 'aarch64')
+1 -1
View File
@@ -1,5 +1,5 @@
Name: relspec
Version: 1.0.66
Version: 1.0.67
Release: 1%{?dist}
Summary: RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs.
+57 -6
View File
@@ -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
@@ -497,6 +499,29 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
}
scripts = append(scripts, script)
}
// Check nullability changes
if modelCol.NotNull != currentCol.NotNull {
sql, err := w.executor.ExecuteAlterColumnNullability(AlterColumnNullabilityData{
SchemaName: schema.Name,
TableName: modelTable.Name,
ColumnName: modelCol.Name,
NotNull: modelCol.NotNull,
})
if err != nil {
return nil, err
}
script := MigrationScript{
ObjectName: fmt.Sprintf("%s.%s.%s", schema.Name, modelTable.Name, modelCol.Name),
ObjectType: "alter column nullability",
Schema: schema.Name,
Priority: 145,
Sequence: len(scripts),
Body: sql,
}
scripts = append(scripts, script)
}
}
}
@@ -939,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: "<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 {
@@ -136,6 +136,86 @@ 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")
currentTable := models.InitTable("service_instance", "public")
currentType := models.InitColumn("rid_service_instance_type", "service_instance", "public")
currentType.Type = "text"
currentType.NotNull = true
currentTable.Columns["rid_service_instance_type"] = currentType
currentSchema.Tables = append(currentSchema.Tables, currentTable)
current.Schemas = append(current.Schemas, currentSchema)
model := models.InitDatabase("testdb")
modelSchema := models.InitSchema("public")
modelTable := models.InitTable("service_instance", "public")
modelType := models.InitColumn("rid_service_instance_type", "service_instance", "public")
modelType.Type = "text"
modelType.NotNull = false
modelTable.Columns["rid_service_instance_type"] = modelType
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, "ALTER COLUMN rid_service_instance_type DROP NOT NULL") {
t.Fatalf("expected migration to drop NOT NULL on existing column, got:\n%s", output)
}
}
func TestWriteMigration_UsesStorageTypeForSerialAlterStatements(t *testing.T) {
current := models.InitDatabase("testdb")
currentSchema := models.InitSchema("public")
+41
View File
@@ -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
@@ -116,6 +128,14 @@ type AlterColumnDefaultData struct {
DefaultValue string
}
// AlterColumnNullabilityData contains data for alter column nullability template
type AlterColumnNullabilityData struct {
SchemaName string
TableName string
ColumnName string
NotNull bool
}
// CreatePrimaryKeyData contains data for create primary key template
type CreatePrimaryKeyData struct {
SchemaName string
@@ -312,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)
@@ -331,6 +362,16 @@ func (te *TemplateExecutor) ExecuteAlterColumnDefault(data AlterColumnDefaultDat
return buf.String(), nil
}
// ExecuteAlterColumnNullability executes the alter column nullability template
func (te *TemplateExecutor) ExecuteAlterColumnNullability(data AlterColumnNullabilityData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "alter_column_nullability.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute alter_column_nullability template: %w", err)
}
return buf.String(), nil
}
// ExecuteCreatePrimaryKey executes the create primary key template
func (te *TemplateExecutor) ExecuteCreatePrimaryKey(data CreatePrimaryKeyData) (string, error) {
var buf bytes.Buffer
@@ -0,0 +1,7 @@
{{- 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 -}}
@@ -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;
$$;
@@ -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;
$$;
+36
View File
@@ -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")