From 5ba20e0581a5f9a484cdf048fac7c1a2b1a534c3 Mon Sep 17 00:00:00 2001 From: SG Command Date: Tue, 18 Aug 2026 00:16:35 +0200 Subject: [PATCH] feat: include SQL scripts in schema diff --- cmd/relspec/diff.go | 22 ++++++-- cmd/relspec/diff_sqldir_test.go | 28 ++++++++++ pkg/diff/diff.go | 88 ++++++++++++++++++++++++++++++ pkg/diff/diff_test.go | 94 +++++++++++++++++++++++++++++++++ pkg/diff/formatters.go | 67 ++++++++++++++++++++++- pkg/diff/formatters_test.go | 45 ++++++++++++++++ pkg/diff/types.go | 23 ++++++++ 7 files changed, 361 insertions(+), 6 deletions(-) create mode 100644 cmd/relspec/diff_sqldir_test.go diff --git a/cmd/relspec/diff.go b/cmd/relspec/diff.go index ade8081..ed9b1e6 100644 --- a/cmd/relspec/diff.go +++ b/cmd/relspec/diff.go @@ -16,6 +16,7 @@ import ( "git.warky.dev/wdevs/relspecgo/pkg/readers/drawdb" "git.warky.dev/wdevs/relspecgo/pkg/readers/json" "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/yaml" ) @@ -87,11 +88,11 @@ Examples: } 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(&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(&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) sourceTables := 0 + sourceScripts := 0 for _, schema := range sourceDB.Schemas { 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 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) targetTables := 0 + targetScripts := 0 for _, schema := range targetDB.Schemas { 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 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.Columns.Missing + summary.Columns.Extra + summary.Columns.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, " 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}) + 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": if connString == "" { return nil, fmt.Errorf("%s: connection string is required for PostgreSQL format", label) diff --git a/cmd/relspec/diff_sqldir_test.go b/cmd/relspec/diff_sqldir_test.go new file mode 100644 index 0000000..5db183e --- /dev/null +++ b/cmd/relspec/diff_sqldir_test.go @@ -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) + } +} diff --git a/pkg/diff/diff.go b/pkg/diff/diff.go index 6638cc1..0f5a763 100644 --- a/pkg/diff/diff.go +++ b/pkg/diff/diff.go @@ -1,6 +1,7 @@ package diff import ( + "fmt" "reflect" "sort" @@ -96,6 +97,13 @@ func compareSchemaDetails(source, target *models.Schema) *SchemaChange { hasChanges = true } + // Compare scripts + scriptDiff := compareScripts(source.Scripts, target.Scripts) + if !isEmpty(scriptDiff) { + change.Scripts = scriptDiff + hasChanges = true + } + if !hasChanges { return nil } @@ -543,6 +551,79 @@ func compareSequenceDetails(source, target *models.Sequence) map[string]any { 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 func isEmpty(v any) bool { 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 case *SequenceDiff: 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: return false } @@ -616,6 +699,11 @@ func ComputeSummary(result *DiffResult) *Summary { summary.Sequences.Extra += len(schemaChange.Sequences.Extra) 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) + } } } diff --git a/pkg/diff/diff_test.go b/pkg/diff/diff_test.go index ab4db3b..babb400 100644 --- a/pkg/diff/diff_test.go +++ b/pkg/diff/diff_test.go @@ -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) { tests := []struct { 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}, {"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 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 { @@ -586,6 +660,26 @@ func TestComputeSummary(t *testing.T) { 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 { diff --git a/pkg/diff/formatters.go b/pkg/diff/formatters.go index 1850dcb..b9a47f2 100644 --- a/pkg/diff/formatters.go +++ b/pkg/diff/formatters.go @@ -158,6 +158,21 @@ func formatSummary(result *DiffResult, w io.Writer) error { 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 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 && @@ -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.Relationships.Missing == 0 && summary.Relationships.Extra == 0 && summary.Relationships.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") } @@ -448,6 +464,26 @@ const htmlTemplate = ` {{end}} + + {{if or .Summary.Scripts.Missing .Summary.Scripts.Extra .Summary.Scripts.Modified}} +
+

Scripts

+
+
+ Missing + {{.Summary.Scripts.Missing}} +
+
+ Extra + {{.Summary.Scripts.Extra}} +
+
+ Modified + {{.Summary.Scripts.Modified}} +
+
+
+ {{end}} @@ -588,6 +624,35 @@ const htmlTemplate = ` {{end}} {{end}} + + {{if .Scripts}} + {{if .Scripts.Missing}} +

Missing Scripts

+ + {{end}} + + {{if .Scripts.Extra}} +

Extra Scripts

+ + {{end}} + + {{if .Scripts.Modified}} +

Modified Scripts

+ + {{end}} + {{end}} {{end}} diff --git a/pkg/diff/formatters_test.go b/pkg/diff/formatters_test.go index bde749b..79d0743 100644 --- a/pkg/diff/formatters_test.go +++ b/pkg/diff/formatters_test.go @@ -104,6 +104,26 @@ func TestFormatSummary(t *testing.T) { }, 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 { @@ -237,6 +257,31 @@ func TestFormatHTML(t *testing.T) { "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 { diff --git a/pkg/diff/types.go b/pkg/diff/types.go index 26beda9..659ff85 100644 --- a/pkg/diff/types.go +++ b/pkg/diff/types.go @@ -22,6 +22,7 @@ type SchemaChange struct { Tables *TableDiff `json:"tables,omitempty"` Views *ViewDiff `json:"views,omitempty"` Sequences *SequenceDiff `json:"sequences,omitempty"` + Scripts *ScriptDiff `json:"scripts,omitempty"` } // TableDiff represents differences in tables @@ -131,6 +132,21 @@ type SequenceChange struct { 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 type Summary struct { Schemas SchemaSummary `json:"schemas"` @@ -141,6 +157,7 @@ type Summary struct { Relationships RelationshipSummary `json:"relationships"` Views ViewSummary `json:"views"` Sequences SequenceSummary `json:"sequences"` + Scripts ScriptSummary `json:"scripts"` } type SchemaSummary struct { @@ -190,3 +207,9 @@ type SequenceSummary struct { Extra int `json:"extra"` Modified int `json:"modified"` } + +type ScriptSummary struct { + Missing int `json:"missing"` + Extra int `json:"extra"` + Modified int `json:"modified"` +} -- 2.54.0