Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e650406177 | ||
|
|
fc3409f324 | ||
|
|
97139723c9 |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
|
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
|
||||||
pkgname=relspec
|
pkgname=relspec
|
||||||
pkgver=1.0.66
|
pkgver=1.0.67
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs."
|
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')
|
arch=('x86_64' 'aarch64')
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
Name: relspec
|
Name: relspec
|
||||||
Version: 1.0.66
|
Version: 1.0.67
|
||||||
Release: 1%{?dist}
|
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.
|
Summary: RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
SchemaName: schema.Name,
|
sql, err := w.executor.ExecuteAlterColumnTypeWithFallback(AlterColumnTypeWithFallbackData{
|
||||||
TableName: modelTable.Name,
|
SchemaName: schema.Name,
|
||||||
ColumnName: modelCol.Name,
|
TableName: modelTable.Name,
|
||||||
NewType: effectiveAlterColumnSQLType(modelCol),
|
ColumnName: modelCol.Name,
|
||||||
UsingExpr: buildAlterColumnUsingExpression(modelCol.Name, effectiveAlterColumnSQLType(modelCol)),
|
NewType: newType,
|
||||||
|
UsingExpr: buildAlterColumnUsingExpression(modelCol.Name, newType),
|
||||||
|
OldColumnName: renamedColumnName(modelCol.Name, effectiveAlterColumnSQLType(currentCol)),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -497,6 +499,29 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
|
|||||||
}
|
}
|
||||||
scripts = append(scripts, script)
|
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
|
// 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,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) {
|
func TestWriteMigration_UsesStorageTypeForSerialAlterStatements(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
|
||||||
@@ -116,6 +128,14 @@ type AlterColumnDefaultData struct {
|
|||||||
DefaultValue string
|
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
|
// CreatePrimaryKeyData contains data for create primary key template
|
||||||
type CreatePrimaryKeyData struct {
|
type CreatePrimaryKeyData struct {
|
||||||
SchemaName string
|
SchemaName string
|
||||||
@@ -312,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)
|
||||||
@@ -331,6 +362,16 @@ func (te *TemplateExecutor) ExecuteAlterColumnDefault(data AlterColumnDefaultDat
|
|||||||
return buf.String(), nil
|
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
|
// ExecuteCreatePrimaryKey executes the create primary key template
|
||||||
func (te *TemplateExecutor) ExecuteCreatePrimaryKey(data CreatePrimaryKeyData) (string, error) {
|
func (te *TemplateExecutor) ExecuteCreatePrimaryKey(data CreatePrimaryKeyData) (string, error) {
|
||||||
var buf bytes.Buffer
|
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 $$
|
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
|
||||||
ALTER TABLE {{qual_table .SchemaName .TableName}}
|
BEGIN
|
||||||
ALTER COLUMN {{quote_ident .ColumnName}} TYPE {{.NewType}}{{if .UsingExpr}} USING {{.UsingExpr}}{{end}};
|
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 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