Compare commits

..
Author SHA1 Message Date
SG Command 5ba20e0581 feat: include SQL scripts in schema diff 2026-08-18 00:16:35 +02:00
7 changed files with 361 additions and 6 deletions
+17 -5
View File
@@ -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)
+28
View File
@@ -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)
}
}
+88
View File
@@ -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)
}
}
}
+94
View File
@@ -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 {
+66 -1
View File
@@ -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 = `<!DOCTYPE html>
</div>
</div>
{{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>
@@ -588,6 +624,35 @@ const htmlTemplate = `<!DOCTYPE html>
</ul>
{{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>
{{end}}
</div>
+45
View File
@@ -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 {
+23
View File
@@ -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"`
}