Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ba20e0581 | ||
|
|
19b592820c | ||
|
|
b158a98acc | ||
|
|
465db7643c | ||
|
|
d84306934a | ||
|
|
e650406177 | ||
|
|
fc3409f324 | ||
|
|
97139723c9 |
+17
-5
@@ -16,6 +16,7 @@ import (
|
|||||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/drawdb"
|
"git.warky.dev/wdevs/relspecgo/pkg/readers/drawdb"
|
||||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/json"
|
"git.warky.dev/wdevs/relspecgo/pkg/readers/json"
|
||||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/pgsql"
|
"git.warky.dev/wdevs/relspecgo/pkg/readers/pgsql"
|
||||||
|
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqldir"
|
||||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqlite"
|
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqlite"
|
||||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/yaml"
|
"git.warky.dev/wdevs/relspecgo/pkg/readers/yaml"
|
||||||
)
|
)
|
||||||
@@ -87,11 +88,11 @@ Examples:
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
diffCmd.Flags().StringVar(&sourceType, "from", "", "Source database format (dbml, dctx, drawdb, json, yaml, pgsql)")
|
diffCmd.Flags().StringVar(&sourceType, "from", "", "Source database format (dbml, dctx, drawdb, json, yaml, pgsql, sqldir)")
|
||||||
diffCmd.Flags().StringVar(&sourcePath, "from-path", "", "Source file path (for file-based formats)")
|
diffCmd.Flags().StringVar(&sourcePath, "from-path", "", "Source file path (for file-based formats)")
|
||||||
diffCmd.Flags().StringVar(&sourceConn, "from-conn", "", "Source connection string (for database formats)")
|
diffCmd.Flags().StringVar(&sourceConn, "from-conn", "", "Source connection string (for database formats)")
|
||||||
|
|
||||||
diffCmd.Flags().StringVar(&targetType, "to", "", "Target database format (dbml, dctx, drawdb, json, yaml, pgsql)")
|
diffCmd.Flags().StringVar(&targetType, "to", "", "Target database format (dbml, dctx, drawdb, json, yaml, pgsql, sqldir)")
|
||||||
diffCmd.Flags().StringVar(&targetPath, "to-path", "", "Target file path (for file-based formats)")
|
diffCmd.Flags().StringVar(&targetPath, "to-path", "", "Target file path (for file-based formats)")
|
||||||
diffCmd.Flags().StringVar(&targetConn, "to-conn", "", "Target connection string (for database formats)")
|
diffCmd.Flags().StringVar(&targetConn, "to-conn", "", "Target connection string (for database formats)")
|
||||||
|
|
||||||
@@ -129,10 +130,12 @@ func runDiff(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
fmt.Fprintf(os.Stderr, " ✓ Successfully read database '%s'\n", sourceDB.Name)
|
fmt.Fprintf(os.Stderr, " ✓ Successfully read database '%s'\n", sourceDB.Name)
|
||||||
sourceTables := 0
|
sourceTables := 0
|
||||||
|
sourceScripts := 0
|
||||||
for _, schema := range sourceDB.Schemas {
|
for _, schema := range sourceDB.Schemas {
|
||||||
sourceTables += len(schema.Tables)
|
sourceTables += len(schema.Tables)
|
||||||
|
sourceScripts += len(schema.Scripts)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, " Found: %d schema(s), %d table(s)\n\n", len(sourceDB.Schemas), sourceTables)
|
fmt.Fprintf(os.Stderr, " Found: %d schema(s), %d table(s), %d script(s)\n\n", len(sourceDB.Schemas), sourceTables, sourceScripts)
|
||||||
|
|
||||||
// Read target database
|
// Read target database
|
||||||
fmt.Fprintf(os.Stderr, "[2/3] Reading target schema...\n")
|
fmt.Fprintf(os.Stderr, "[2/3] Reading target schema...\n")
|
||||||
@@ -151,10 +154,12 @@ func runDiff(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
fmt.Fprintf(os.Stderr, " ✓ Successfully read database '%s'\n", targetDB.Name)
|
fmt.Fprintf(os.Stderr, " ✓ Successfully read database '%s'\n", targetDB.Name)
|
||||||
targetTables := 0
|
targetTables := 0
|
||||||
|
targetScripts := 0
|
||||||
for _, schema := range targetDB.Schemas {
|
for _, schema := range targetDB.Schemas {
|
||||||
targetTables += len(schema.Tables)
|
targetTables += len(schema.Tables)
|
||||||
|
targetScripts += len(schema.Scripts)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, " Found: %d schema(s), %d table(s)\n\n", len(targetDB.Schemas), targetTables)
|
fmt.Fprintf(os.Stderr, " Found: %d schema(s), %d table(s), %d script(s)\n\n", len(targetDB.Schemas), targetTables, targetScripts)
|
||||||
|
|
||||||
// Compare databases
|
// Compare databases
|
||||||
fmt.Fprintf(os.Stderr, "[3/3] Comparing schemas...\n")
|
fmt.Fprintf(os.Stderr, "[3/3] Comparing schemas...\n")
|
||||||
@@ -165,7 +170,8 @@ func runDiff(cmd *cobra.Command, args []string) error {
|
|||||||
summary.Tables.Missing + summary.Tables.Extra + summary.Tables.Modified +
|
summary.Tables.Missing + summary.Tables.Extra + summary.Tables.Modified +
|
||||||
summary.Columns.Missing + summary.Columns.Extra + summary.Columns.Modified +
|
summary.Columns.Missing + summary.Columns.Extra + summary.Columns.Modified +
|
||||||
summary.Indexes.Missing + summary.Indexes.Extra + summary.Indexes.Modified +
|
summary.Indexes.Missing + summary.Indexes.Extra + summary.Indexes.Modified +
|
||||||
summary.Constraints.Missing + summary.Constraints.Extra + summary.Constraints.Modified
|
summary.Constraints.Missing + summary.Constraints.Extra + summary.Constraints.Modified +
|
||||||
|
summary.Scripts.Missing + summary.Scripts.Extra + summary.Scripts.Modified
|
||||||
|
|
||||||
fmt.Fprintf(os.Stderr, " ✓ Comparison complete\n")
|
fmt.Fprintf(os.Stderr, " ✓ Comparison complete\n")
|
||||||
fmt.Fprintf(os.Stderr, " Found: %d difference(s)\n\n", totalDiffs)
|
fmt.Fprintf(os.Stderr, " Found: %d difference(s)\n\n", totalDiffs)
|
||||||
@@ -249,6 +255,12 @@ func readDatabase(dbType, filePath, connString, label string) (*models.Database,
|
|||||||
}
|
}
|
||||||
reader = yaml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
reader = yaml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||||
|
|
||||||
|
case "sqldir", "scripts", "scriptdir":
|
||||||
|
if filePath == "" {
|
||||||
|
return nil, fmt.Errorf("%s: file path is required for SQL directory format", label)
|
||||||
|
}
|
||||||
|
reader = sqldir.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||||
|
|
||||||
case "pgsql", "postgres", "postgresql":
|
case "pgsql", "postgres", "postgresql":
|
||||||
if connString == "" {
|
if connString == "" {
|
||||||
return nil, fmt.Errorf("%s: connection string is required for PostgreSQL format", label)
|
return nil, fmt.Errorf("%s: connection string is required for PostgreSQL format", label)
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadDatabaseSupportsSQLDir(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(tempDir, "1_001_create_users.sql"), []byte("CREATE TABLE users (id int);"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tempDir, "1_002_seed_users.pgsql"), []byte("INSERT INTO users (id) VALUES (1);"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := readDatabase("sqldir", tempDir, "", "source")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("readDatabase failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(db.Schemas) != 1 {
|
||||||
|
t.Fatalf("expected 1 schema, got %d", len(db.Schemas))
|
||||||
|
}
|
||||||
|
if got := len(db.Schemas[0].Scripts); got != 2 {
|
||||||
|
t.Fatalf("expected 2 scripts, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.69
|
||||||
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.69
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package diff
|
package diff
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
@@ -96,6 +97,13 @@ func compareSchemaDetails(source, target *models.Schema) *SchemaChange {
|
|||||||
hasChanges = true
|
hasChanges = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compare scripts
|
||||||
|
scriptDiff := compareScripts(source.Scripts, target.Scripts)
|
||||||
|
if !isEmpty(scriptDiff) {
|
||||||
|
change.Scripts = scriptDiff
|
||||||
|
hasChanges = true
|
||||||
|
}
|
||||||
|
|
||||||
if !hasChanges {
|
if !hasChanges {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -543,6 +551,79 @@ func compareSequenceDetails(source, target *models.Sequence) map[string]any {
|
|||||||
return changes
|
return changes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func compareScripts(source, target []*models.Script) *ScriptDiff {
|
||||||
|
diff := &ScriptDiff{
|
||||||
|
Missing: make([]*models.Script, 0),
|
||||||
|
Extra: make([]*models.Script, 0),
|
||||||
|
Modified: make([]*ScriptChange, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceMap := make(map[string]*models.Script)
|
||||||
|
targetMap := make(map[string]*models.Script)
|
||||||
|
|
||||||
|
for _, s := range source {
|
||||||
|
sourceMap[scriptCompareKey(s)] = s
|
||||||
|
}
|
||||||
|
for _, s := range target {
|
||||||
|
targetMap[scriptCompareKey(s)] = s
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range sortedKeys(sourceMap) {
|
||||||
|
srcScript := sourceMap[name]
|
||||||
|
if tgtScript, exists := targetMap[name]; !exists {
|
||||||
|
diff.Missing = append(diff.Missing, srcScript)
|
||||||
|
} else if changes := compareScriptDetails(srcScript, tgtScript); len(changes) > 0 {
|
||||||
|
diff.Modified = append(diff.Modified, &ScriptChange{
|
||||||
|
Name: srcScript.Name,
|
||||||
|
Source: srcScript,
|
||||||
|
Target: tgtScript,
|
||||||
|
Changes: changes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range sortedKeys(targetMap) {
|
||||||
|
tgtScript := targetMap[name]
|
||||||
|
if _, exists := sourceMap[name]; !exists {
|
||||||
|
diff.Extra = append(diff.Extra, tgtScript)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return diff
|
||||||
|
}
|
||||||
|
|
||||||
|
func scriptCompareKey(script *models.Script) string {
|
||||||
|
return fmt.Sprintf("%d:%d:%s", script.Priority, script.Sequence, script.SQLName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareScriptDetails(source, target *models.Script) map[string]any {
|
||||||
|
changes := make(map[string]any)
|
||||||
|
|
||||||
|
if source.SQL != target.SQL {
|
||||||
|
changes["sql"] = map[string]string{"source": source.SQL, "target": target.SQL}
|
||||||
|
}
|
||||||
|
if source.Rollback != target.Rollback {
|
||||||
|
changes["rollback"] = map[string]string{"source": source.Rollback, "target": target.Rollback}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(source.RunAfter, target.RunAfter) {
|
||||||
|
changes["run_after"] = map[string][]string{"source": source.RunAfter, "target": target.RunAfter}
|
||||||
|
}
|
||||||
|
if source.Schema != target.Schema {
|
||||||
|
changes["schema"] = map[string]string{"source": source.Schema, "target": target.Schema}
|
||||||
|
}
|
||||||
|
if source.Version != target.Version {
|
||||||
|
changes["version"] = map[string]string{"source": source.Version, "target": target.Version}
|
||||||
|
}
|
||||||
|
if source.Priority != target.Priority {
|
||||||
|
changes["priority"] = map[string]int{"source": source.Priority, "target": target.Priority}
|
||||||
|
}
|
||||||
|
if source.Sequence != target.Sequence {
|
||||||
|
changes["sequence"] = map[string]uint{"source": source.Sequence, "target": target.Sequence}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to check if a diff is empty
|
// Helper function to check if a diff is empty
|
||||||
func isEmpty(v any) bool {
|
func isEmpty(v any) bool {
|
||||||
switch d := v.(type) {
|
switch d := v.(type) {
|
||||||
@@ -560,6 +641,8 @@ func isEmpty(v any) bool {
|
|||||||
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
|
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
|
||||||
case *SequenceDiff:
|
case *SequenceDiff:
|
||||||
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
|
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
|
||||||
|
case *ScriptDiff:
|
||||||
|
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -616,6 +699,11 @@ func ComputeSummary(result *DiffResult) *Summary {
|
|||||||
summary.Sequences.Extra += len(schemaChange.Sequences.Extra)
|
summary.Sequences.Extra += len(schemaChange.Sequences.Extra)
|
||||||
summary.Sequences.Modified += len(schemaChange.Sequences.Modified)
|
summary.Sequences.Modified += len(schemaChange.Sequences.Modified)
|
||||||
}
|
}
|
||||||
|
if schemaChange.Scripts != nil {
|
||||||
|
summary.Scripts.Missing += len(schemaChange.Scripts.Missing)
|
||||||
|
summary.Scripts.Extra += len(schemaChange.Scripts.Extra)
|
||||||
|
summary.Scripts.Modified += len(schemaChange.Scripts.Modified)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -525,6 +525,78 @@ func TestCompareSchemas(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompareScripts(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
source []*models.Script
|
||||||
|
target []*models.Script
|
||||||
|
want func(*ScriptDiff) bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "identical scripts",
|
||||||
|
source: []*models.Script{{Name: "create_users", SQL: "CREATE TABLE users (id int);", Priority: 1, Sequence: 1}},
|
||||||
|
target: []*models.Script{{Name: "create_users", SQL: "CREATE TABLE users (id int);", Priority: 1, Sequence: 1}},
|
||||||
|
want: func(d *ScriptDiff) bool {
|
||||||
|
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing script",
|
||||||
|
source: []*models.Script{{Name: "create_users", SQL: "CREATE TABLE users (id int);"}},
|
||||||
|
target: []*models.Script{},
|
||||||
|
want: func(d *ScriptDiff) bool {
|
||||||
|
return len(d.Missing) == 1 && d.Missing[0].Name == "create_users"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extra script",
|
||||||
|
source: []*models.Script{},
|
||||||
|
target: []*models.Script{{Name: "create_users", SQL: "CREATE TABLE users (id int);"}},
|
||||||
|
want: func(d *ScriptDiff) bool {
|
||||||
|
return len(d.Extra) == 1 && d.Extra[0].Name == "create_users"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "modified script sql",
|
||||||
|
source: []*models.Script{{Name: "create_users", SQL: "CREATE TABLE users (id int);"}},
|
||||||
|
target: []*models.Script{{Name: "create_users", SQL: "CREATE TABLE users (id bigint);"}},
|
||||||
|
want: func(d *ScriptDiff) bool {
|
||||||
|
return len(d.Modified) == 1 && d.Modified[0].Name == "create_users" && d.Modified[0].Changes["sql"] != nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different script order is different identity",
|
||||||
|
source: []*models.Script{{Name: "create_users", SQL: "SELECT 1;", Priority: 1, Sequence: 1}},
|
||||||
|
target: []*models.Script{{Name: "create_users", SQL: "SELECT 1;", Priority: 2, Sequence: 3}},
|
||||||
|
want: func(d *ScriptDiff) bool {
|
||||||
|
return len(d.Missing) == 1 && len(d.Extra) == 1 && len(d.Modified) == 0
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same descriptive names remain distinct",
|
||||||
|
source: []*models.Script{
|
||||||
|
{Name: "alter_users", SQL: "SELECT 1;", Priority: 1, Sequence: 1},
|
||||||
|
{Name: "alter_users", SQL: "SELECT 2;", Priority: 1, Sequence: 2},
|
||||||
|
},
|
||||||
|
target: []*models.Script{
|
||||||
|
{Name: "alter_users", SQL: "SELECT 1;", Priority: 1, Sequence: 1},
|
||||||
|
},
|
||||||
|
want: func(d *ScriptDiff) bool {
|
||||||
|
return len(d.Missing) == 1 && d.Missing[0].Sequence == 2
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := compareScripts(tt.source, tt.target)
|
||||||
|
if !tt.want(got) {
|
||||||
|
t.Errorf("compareScripts() result doesn't match expectations")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsEmpty(t *testing.T) {
|
func TestIsEmpty(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -540,6 +612,8 @@ func TestIsEmpty(t *testing.T) {
|
|||||||
{"TableDiff with extra", &TableDiff{Missing: []*models.Table{}, Extra: []*models.Table{{Name: "users"}}, Modified: []*TableChange{}}, false},
|
{"TableDiff with extra", &TableDiff{Missing: []*models.Table{}, Extra: []*models.Table{{Name: "users"}}, Modified: []*TableChange{}}, false},
|
||||||
{"empty ConstraintDiff", &ConstraintDiff{Missing: []*models.Constraint{}, Extra: []*models.Constraint{}, Modified: []*ConstraintChange{}}, true},
|
{"empty ConstraintDiff", &ConstraintDiff{Missing: []*models.Constraint{}, Extra: []*models.Constraint{}, Modified: []*ConstraintChange{}}, true},
|
||||||
{"empty RelationshipDiff", &RelationshipDiff{Missing: []*models.Relationship{}, Extra: []*models.Relationship{}, Modified: []*RelationshipChange{}}, true},
|
{"empty RelationshipDiff", &RelationshipDiff{Missing: []*models.Relationship{}, Extra: []*models.Relationship{}, Modified: []*RelationshipChange{}}, true},
|
||||||
|
{"empty ScriptDiff", &ScriptDiff{Missing: []*models.Script{}, Extra: []*models.Script{}, Modified: []*ScriptChange{}}, true},
|
||||||
|
{"ScriptDiff with modified", &ScriptDiff{Missing: []*models.Script{}, Extra: []*models.Script{}, Modified: []*ScriptChange{{Name: "create_users"}}}, false},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -586,6 +660,26 @@ func TestComputeSummary(t *testing.T) {
|
|||||||
return s.Schemas.Missing == 1 && s.Schemas.Extra == 2 && s.Schemas.Modified == 1
|
return s.Schemas.Missing == 1 && s.Schemas.Extra == 2 && s.Schemas.Modified == 1
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "scripts with differences",
|
||||||
|
result: &DiffResult{
|
||||||
|
Schemas: &SchemaDiff{
|
||||||
|
Modified: []*SchemaChange{
|
||||||
|
{
|
||||||
|
Name: "public",
|
||||||
|
Scripts: &ScriptDiff{
|
||||||
|
Missing: []*models.Script{{Name: "missing_script"}},
|
||||||
|
Extra: []*models.Script{{Name: "extra_script"}, {Name: "seed_data"}},
|
||||||
|
Modified: []*ScriptChange{{Name: "changed_script"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: func(s *Summary) bool {
|
||||||
|
return s.Scripts.Missing == 1 && s.Scripts.Extra == 2 && s.Scripts.Modified == 1
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
+66
-1
@@ -158,6 +158,21 @@ func formatSummary(result *DiffResult, w io.Writer) error {
|
|||||||
fmt.Fprintf(w, "\n")
|
fmt.Fprintf(w, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scripts
|
||||||
|
if summary.Scripts.Missing > 0 || summary.Scripts.Extra > 0 || summary.Scripts.Modified > 0 {
|
||||||
|
fmt.Fprintf(w, "Scripts:\n")
|
||||||
|
if summary.Scripts.Missing > 0 {
|
||||||
|
fmt.Fprintf(w, " Missing: %d\n", summary.Scripts.Missing)
|
||||||
|
}
|
||||||
|
if summary.Scripts.Extra > 0 {
|
||||||
|
fmt.Fprintf(w, " Extra: %d\n", summary.Scripts.Extra)
|
||||||
|
}
|
||||||
|
if summary.Scripts.Modified > 0 {
|
||||||
|
fmt.Fprintf(w, " Modified: %d\n", summary.Scripts.Modified)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
// Check if there are no differences
|
// Check if there are no differences
|
||||||
if summary.Schemas.Missing == 0 && summary.Schemas.Extra == 0 && summary.Schemas.Modified == 0 &&
|
if summary.Schemas.Missing == 0 && summary.Schemas.Extra == 0 && summary.Schemas.Modified == 0 &&
|
||||||
summary.Tables.Missing == 0 && summary.Tables.Extra == 0 && summary.Tables.Modified == 0 &&
|
summary.Tables.Missing == 0 && summary.Tables.Extra == 0 && summary.Tables.Modified == 0 &&
|
||||||
@@ -166,7 +181,8 @@ func formatSummary(result *DiffResult, w io.Writer) error {
|
|||||||
summary.Constraints.Missing == 0 && summary.Constraints.Extra == 0 && summary.Constraints.Modified == 0 &&
|
summary.Constraints.Missing == 0 && summary.Constraints.Extra == 0 && summary.Constraints.Modified == 0 &&
|
||||||
summary.Relationships.Missing == 0 && summary.Relationships.Extra == 0 && summary.Relationships.Modified == 0 &&
|
summary.Relationships.Missing == 0 && summary.Relationships.Extra == 0 && summary.Relationships.Modified == 0 &&
|
||||||
summary.Views.Missing == 0 && summary.Views.Extra == 0 && summary.Views.Modified == 0 &&
|
summary.Views.Missing == 0 && summary.Views.Extra == 0 && summary.Views.Modified == 0 &&
|
||||||
summary.Sequences.Missing == 0 && summary.Sequences.Extra == 0 && summary.Sequences.Modified == 0 {
|
summary.Sequences.Missing == 0 && summary.Sequences.Extra == 0 && summary.Sequences.Modified == 0 &&
|
||||||
|
summary.Scripts.Missing == 0 && summary.Scripts.Extra == 0 && summary.Scripts.Modified == 0 {
|
||||||
fmt.Fprintf(w, "No differences found.\n")
|
fmt.Fprintf(w, "No differences found.\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,6 +464,26 @@ const htmlTemplate = `<!DOCTYPE html>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if or .Summary.Scripts.Missing .Summary.Scripts.Extra .Summary.Scripts.Modified}}
|
||||||
|
<div class="summary-item">
|
||||||
|
<h3>Scripts</h3>
|
||||||
|
<div class="count-group">
|
||||||
|
<div class="count">
|
||||||
|
<span class="count-label">Missing</span>
|
||||||
|
<span class="count-value missing">{{.Summary.Scripts.Missing}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="count">
|
||||||
|
<span class="count-label">Extra</span>
|
||||||
|
<span class="count-value extra">{{.Summary.Scripts.Extra}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="count">
|
||||||
|
<span class="count-label">Modified</span>
|
||||||
|
<span class="count-value modified">{{.Summary.Scripts.Modified}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -588,6 +624,35 @@ const htmlTemplate = `<!DOCTYPE html>
|
|||||||
</ul>
|
</ul>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Scripts}}
|
||||||
|
{{if .Scripts.Missing}}
|
||||||
|
<h4>Missing Scripts</h4>
|
||||||
|
<ul class="item-list">
|
||||||
|
{{range .Scripts.Missing}}
|
||||||
|
<li class="missing">{{.Name}}</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Scripts.Extra}}
|
||||||
|
<h4>Extra Scripts</h4>
|
||||||
|
<ul class="item-list">
|
||||||
|
{{range .Scripts.Extra}}
|
||||||
|
<li class="extra">{{.Name}}</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Scripts.Modified}}
|
||||||
|
<h4>Modified Scripts</h4>
|
||||||
|
<ul class="item-list">
|
||||||
|
{{range .Scripts.Modified}}
|
||||||
|
<li class="modified">{{.Name}}</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -104,6 +104,26 @@ func TestFormatSummary(t *testing.T) {
|
|||||||
},
|
},
|
||||||
wantStr: []string{"Tables:", "Missing: 1", "Extra: 1", "Modified: 1"},
|
wantStr: []string{"Tables:", "Missing: 1", "Extra: 1", "Modified: 1"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "with script differences",
|
||||||
|
result: &DiffResult{
|
||||||
|
Source: "source",
|
||||||
|
Target: "target",
|
||||||
|
Schemas: &SchemaDiff{
|
||||||
|
Modified: []*SchemaChange{
|
||||||
|
{
|
||||||
|
Name: "public",
|
||||||
|
Scripts: &ScriptDiff{
|
||||||
|
Missing: []*models.Script{{Name: "create_users"}},
|
||||||
|
Extra: []*models.Script{{Name: "seed_users"}},
|
||||||
|
Modified: []*ScriptChange{{Name: "add_indexes"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStr: []string{"Scripts:", "Missing: 1", "Extra: 1", "Modified: 1"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -237,6 +257,31 @@ func TestFormatHTML(t *testing.T) {
|
|||||||
"text",
|
"text",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "with script modifications",
|
||||||
|
result: &DiffResult{
|
||||||
|
Source: "source",
|
||||||
|
Target: "target",
|
||||||
|
Schemas: &SchemaDiff{
|
||||||
|
Modified: []*SchemaChange{
|
||||||
|
{
|
||||||
|
Name: "public",
|
||||||
|
Scripts: &ScriptDiff{
|
||||||
|
Missing: []*models.Script{{Name: "create_users"}},
|
||||||
|
Extra: []*models.Script{{Name: "seed_users"}},
|
||||||
|
Modified: []*ScriptChange{{Name: "add_indexes"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStr: []string{
|
||||||
|
"Scripts",
|
||||||
|
"create_users",
|
||||||
|
"seed_users",
|
||||||
|
"add_indexes",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type SchemaChange struct {
|
|||||||
Tables *TableDiff `json:"tables,omitempty"`
|
Tables *TableDiff `json:"tables,omitempty"`
|
||||||
Views *ViewDiff `json:"views,omitempty"`
|
Views *ViewDiff `json:"views,omitempty"`
|
||||||
Sequences *SequenceDiff `json:"sequences,omitempty"`
|
Sequences *SequenceDiff `json:"sequences,omitempty"`
|
||||||
|
Scripts *ScriptDiff `json:"scripts,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableDiff represents differences in tables
|
// TableDiff represents differences in tables
|
||||||
@@ -131,6 +132,21 @@ type SequenceChange struct {
|
|||||||
Changes map[string]any `json:"changes"`
|
Changes map[string]any `json:"changes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ScriptDiff represents differences in migration scripts.
|
||||||
|
type ScriptDiff struct {
|
||||||
|
Missing []*models.Script `json:"missing"` // Scripts in source but not in target
|
||||||
|
Extra []*models.Script `json:"extra"` // Scripts in target but not in source
|
||||||
|
Modified []*ScriptChange `json:"modified"` // Scripts that exist in both but differ
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScriptChange represents a modified migration script.
|
||||||
|
type ScriptChange struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Source *models.Script `json:"source"`
|
||||||
|
Target *models.Script `json:"target"`
|
||||||
|
Changes map[string]any `json:"changes"`
|
||||||
|
}
|
||||||
|
|
||||||
// Summary provides counts for quick overview
|
// Summary provides counts for quick overview
|
||||||
type Summary struct {
|
type Summary struct {
|
||||||
Schemas SchemaSummary `json:"schemas"`
|
Schemas SchemaSummary `json:"schemas"`
|
||||||
@@ -141,6 +157,7 @@ type Summary struct {
|
|||||||
Relationships RelationshipSummary `json:"relationships"`
|
Relationships RelationshipSummary `json:"relationships"`
|
||||||
Views ViewSummary `json:"views"`
|
Views ViewSummary `json:"views"`
|
||||||
Sequences SequenceSummary `json:"sequences"`
|
Sequences SequenceSummary `json:"sequences"`
|
||||||
|
Scripts ScriptSummary `json:"scripts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SchemaSummary struct {
|
type SchemaSummary struct {
|
||||||
@@ -190,3 +207,9 @@ type SequenceSummary struct {
|
|||||||
Extra int `json:"extra"`
|
Extra int `json:"extra"`
|
||||||
Modified int `json:"modified"`
|
Modified int `json:"modified"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ScriptSummary struct {
|
||||||
|
Missing int `json:"missing"`
|
||||||
|
Extra int `json:"extra"`
|
||||||
|
Modified int `json:"modified"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -409,14 +409,7 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
|
|||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
// Column doesn't exist, add it
|
// Column doesn't exist, add it
|
||||||
defaultVal := ""
|
_, defaultVal := formatColumnDefaultSQL(modelCol)
|
||||||
if modelCol.Default != nil {
|
|
||||||
if value, ok := modelCol.Default.(string); ok {
|
|
||||||
defaultVal = writers.QuoteDefaultValue(value, modelCol.Type)
|
|
||||||
} else {
|
|
||||||
defaultVal = fmt.Sprintf("%v", modelCol.Default)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sql, err := w.executor.ExecuteAddColumn(AddColumnData{
|
sql, err := w.executor.ExecuteAddColumn(AddColumnData{
|
||||||
SchemaName: schema.Name,
|
SchemaName: schema.Name,
|
||||||
@@ -442,12 +435,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.ExecuteAlterColumnTypeWithCheck(AlterColumnTypeWithCheckData{
|
||||||
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,
|
||||||
|
EquivalentTypes: equivalentTypeListSQL(newType),
|
||||||
|
UsingExpr: buildAlterColumnUsingExpression(modelCol.Name, newType),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -465,18 +460,10 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check default value changes
|
// Check default value changes
|
||||||
if fmt.Sprintf("%v", modelCol.Default) != fmt.Sprintf("%v", currentCol.Default) {
|
if !columnDefaultsEqual(modelCol.Default, currentCol.Default) {
|
||||||
setDefault := modelCol.Default != nil
|
setDefault, defaultVal := formatColumnDefaultSQL(modelCol)
|
||||||
defaultVal := ""
|
|
||||||
if setDefault {
|
|
||||||
if value, ok := modelCol.Default.(string); ok {
|
|
||||||
defaultVal = writers.QuoteDefaultValue(value, modelCol.Type)
|
|
||||||
} else {
|
|
||||||
defaultVal = fmt.Sprintf("%v", modelCol.Default)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sql, err := w.executor.ExecuteAlterColumnDefault(AlterColumnDefaultData{
|
sql, err := w.executor.ExecuteAlterColumnDefaultWithCheck(AlterColumnDefaultWithCheckData{
|
||||||
SchemaName: schema.Name,
|
SchemaName: schema.Name,
|
||||||
TableName: modelTable.Name,
|
TableName: modelTable.Name,
|
||||||
ColumnName: modelCol.Name,
|
ColumnName: modelCol.Name,
|
||||||
@@ -497,6 +484,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.ExecuteAlterColumnNullabilityWithCheck(AlterColumnNullabilityWithCheckData{
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -946,7 +956,24 @@ func columnsEqual(col1, col2 *models.Column) bool {
|
|||||||
}
|
}
|
||||||
return columnTypesEqual(col1, col2) &&
|
return columnTypesEqual(col1, col2) &&
|
||||||
col1.NotNull == col2.NotNull &&
|
col1.NotNull == col2.NotNull &&
|
||||||
fmt.Sprintf("%v", col1.Default) == fmt.Sprintf("%v", col2.Default)
|
columnDefaultsEqual(col1.Default, col2.Default)
|
||||||
|
}
|
||||||
|
|
||||||
|
// columnDefaultsEqual compares column defaults for drift detection, stripping
|
||||||
|
// MySQL-style backticks (e.g. from GORM tags) so a model default of
|
||||||
|
// "`now()`" is recognised as equal to a live default of "now()".
|
||||||
|
func columnDefaultsEqual(default1, default2 interface{}) bool {
|
||||||
|
return normalizeDefaultForCompare(default1) == normalizeDefaultForCompare(default2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDefaultForCompare(value interface{}) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if s, ok := value.(string); ok {
|
||||||
|
return strings.TrimSpace(stripBackticks(s))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%v", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func columnTypesEqual(col1, col2 *models.Column) bool {
|
func columnTypesEqual(col1, col2 *models.Column) bool {
|
||||||
|
|||||||
@@ -136,6 +136,89 @@ 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 %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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
|||||||
@@ -89,15 +89,10 @@ type AddColumnData struct {
|
|||||||
NotNull bool
|
NotNull bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlterColumnTypeData contains data for alter column type template
|
// AlterColumnTypeWithCheckData contains data for the guarded alter column
|
||||||
type AlterColumnTypeData struct {
|
// type template, which only alters existing columns whose live type
|
||||||
SchemaName string
|
// differs from the desired one, and falls back to renaming the old column
|
||||||
TableName string
|
// and adding a fresh one when the in-place conversion is not possible.
|
||||||
ColumnName string
|
|
||||||
NewType string
|
|
||||||
UsingExpr string
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlterColumnTypeWithCheckData struct {
|
type AlterColumnTypeWithCheckData struct {
|
||||||
SchemaName string
|
SchemaName string
|
||||||
TableName string
|
TableName string
|
||||||
@@ -107,8 +102,10 @@ type AlterColumnTypeWithCheckData struct {
|
|||||||
UsingExpr string
|
UsingExpr string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlterColumnDefaultData contains data for alter column default template
|
// AlterColumnDefaultWithCheckData contains data for the guarded alter
|
||||||
type AlterColumnDefaultData struct {
|
// column default template, which only alters existing columns whose live
|
||||||
|
// default differs from the desired one.
|
||||||
|
type AlterColumnDefaultWithCheckData struct {
|
||||||
SchemaName string
|
SchemaName string
|
||||||
TableName string
|
TableName string
|
||||||
ColumnName string
|
ColumnName string
|
||||||
@@ -116,6 +113,16 @@ type AlterColumnDefaultData struct {
|
|||||||
DefaultValue string
|
DefaultValue string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
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
|
||||||
@@ -302,16 +309,9 @@ func (te *TemplateExecutor) ExecuteAddColumn(data AddColumnData) (string, error)
|
|||||||
return buf.String(), nil
|
return buf.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecuteAlterColumnType executes the alter column type template
|
// ExecuteAlterColumnTypeWithCheck executes the guarded alter column type
|
||||||
func (te *TemplateExecutor) ExecuteAlterColumnType(data AlterColumnTypeData) (string, error) {
|
// template shared by the full-schema writer and the diff-based migration
|
||||||
var buf bytes.Buffer
|
// writer.
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -321,12 +321,25 @@ func (te *TemplateExecutor) ExecuteAlterColumnTypeWithCheck(data AlterColumnType
|
|||||||
return buf.String(), nil
|
return buf.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecuteAlterColumnDefault executes the alter column default template
|
// ExecuteAlterColumnDefaultWithCheck executes the guarded alter column
|
||||||
func (te *TemplateExecutor) ExecuteAlterColumnDefault(data AlterColumnDefaultData) (string, error) {
|
// 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
|
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 {
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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_with_check.tmpl", data)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to execute alter_column_nullability_with_check template: %w", err)
|
||||||
}
|
}
|
||||||
return buf.String(), nil
|
return buf.String(), nil
|
||||||
}
|
}
|
||||||
@@ -517,7 +530,7 @@ func BuildCreateTableData(schemaName string, table *models.Table) CreateTableDat
|
|||||||
}
|
}
|
||||||
if col.Default != nil {
|
if col.Default != nil {
|
||||||
if value, ok := col.Default.(string); ok {
|
if value, ok := col.Default.(string); ok {
|
||||||
colData.Default = writers.QuoteDefaultValue(value, col.Type)
|
colData.Default = writers.QuoteDefaultValue(stripBackticks(value), col.Type)
|
||||||
} else {
|
} else {
|
||||||
colData.Default = fmt.Sprintf("%v", col.Default)
|
colData.Default = fmt.Sprintf("%v", col.Default)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 -}}
|
|
||||||
@@ -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;
|
||||||
|
$$;
|
||||||
@@ -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;
|
||||||
|
$$;
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE {{qual_table .SchemaName .TableName}}
|
|
||||||
ALTER COLUMN {{quote_ident .ColumnName}} TYPE {{.NewType}}{{if .UsingExpr}} USING {{.UsingExpr}}{{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;
|
||||||
$$;
|
$$;
|
||||||
|
|||||||
@@ -475,6 +475,75 @@ func (w *Writer) GenerateAlterColumnTypeStatements(schema *models.Schema) ([]str
|
|||||||
return statements, nil
|
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(stripBackticks(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
|
// GenerateAddColumnsForDatabase generates ALTER TABLE ADD COLUMN statements for the entire database
|
||||||
func (w *Writer) GenerateAddColumnsForDatabase(db *models.Database) ([]string, error) {
|
func (w *Writer) GenerateAddColumnsForDatabase(db *models.Database) ([]string, error) {
|
||||||
statements := []string{}
|
statements := []string{}
|
||||||
@@ -641,6 +710,14 @@ func (w *Writer) WriteSchema(schema *models.Schema) error {
|
|||||||
return err
|
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)
|
// Phase 4: Create primary keys (priority 160)
|
||||||
if err := w.writePrimaryKeys(schema); err != nil {
|
if err := w.writePrimaryKeys(schema); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -859,6 +936,36 @@ func (w *Writer) writeAlterColumnTypes(schema *models.Schema) error {
|
|||||||
return nil
|
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
|
// writePrimaryKeys generates ALTER TABLE statements for primary keys
|
||||||
func (w *Writer) writePrimaryKeys(schema *models.Schema) error {
|
func (w *Writer) writePrimaryKeys(schema *models.Schema) error {
|
||||||
fmt.Fprintf(w.writer, "-- Primary keys for schema: %s\n", schema.Name)
|
fmt.Fprintf(w.writer, "-- Primary keys for schema: %s\n", schema.Name)
|
||||||
|
|||||||
@@ -1106,6 +1106,144 @@ 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_AlterColumnDefaultStripsBackticksFromFunctionExpression(t *testing.T) {
|
||||||
|
db := models.InitDatabase("testdb")
|
||||||
|
schema := models.InitSchema("public")
|
||||||
|
|
||||||
|
table := models.InitTable("agent_skills", "public")
|
||||||
|
|
||||||
|
updatedAtCol := models.InitColumn("updatedat", "agent_skills", "public")
|
||||||
|
updatedAtCol.Type = "timestamp"
|
||||||
|
updatedAtCol.Default = "`now()`"
|
||||||
|
table.Columns["updatedat"] = updatedAtCol
|
||||||
|
|
||||||
|
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, "`") {
|
||||||
|
t.Fatalf("expected no backticks in generated SQL, got:\n%s", output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "ALTER COLUMN updatedat SET DEFAULT now()") {
|
||||||
|
t.Fatalf("expected guarded SET DEFAULT now() without backticks, got:\n%s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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_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) {
|
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