From 3b88c386a1ca7f1fa47c237ea604144b68504a49 Mon Sep 17 00:00:00 2001 From: Hein Date: Mon, 10 Aug 2026 20:54:40 +0200 Subject: [PATCH] fix(codegen): sort map iteration to make generated output deterministic Table.Columns/Constraints/Indexes/Relationships are Go maps, and every writer, reader, diff, inspector, and merge code path that iterated them directly was subject to Go's randomized map order, so identical input could produce different output (or a different in-report violation/diff order) on every run. Most visibly this showed up as bun/gorm `unique:` struct tags changing order across consecutive `make models` runs with no source change. Fixed by sorting map iteration (by Sequence then Name, or alphabetically for string-keyed maps) everywhere the order affects generated output or first-match tie-break logic, across the bun, gorm, sqlite, dbml, drawdb, pgsql, prisma, graphql, typeorm, drizzle, and dctx writers; the dctx, prisma, and typeorm readers; the shared models.GetPrimaryKey/ GetForeignKeys helpers; pkg/diff, pkg/inspector, and pkg/merge; and the TUI column/relationship pickers in pkg/ui. --- pkg/diff/diff.go | 60 +++++++++++++++++------- pkg/diff/diff_test.go | 41 +++++++++++++++++ pkg/inspector/inspector.go | 12 ++++- pkg/inspector/inspector_test.go | 39 ++++++++++++++++ pkg/inspector/report.go | 11 ++++- pkg/inspector/validators.go | 66 ++++++++++++++++++++++----- pkg/merge/merge.go | 14 +++++- pkg/models/flatview.go | 24 +++++++++- pkg/models/models.go | 28 ++++++++++-- pkg/readers/dctx/reader.go | 7 +++ pkg/readers/prisma/reader.go | 22 +++++++-- pkg/readers/typeorm/reader.go | 22 +++++++-- pkg/ui/editor.go | 2 + pkg/ui/relation_dataops.go | 7 ++- pkg/writers/bun/template_data.go | 22 +++++++-- pkg/writers/bun/type_mapper.go | 2 +- pkg/writers/bun/writer_test.go | 37 +++++++++++++++ pkg/writers/dbml/writer.go | 52 +++++++++++++++++++-- pkg/writers/dctx/writer.go | 9 +++- pkg/writers/drawdb/writer.go | 52 +++++++++++++++++++-- pkg/writers/drizzle/writer.go | 38 +++++++++++++-- pkg/writers/gorm/template_data.go | 22 +++++++-- pkg/writers/gorm/type_mapper.go | 4 +- pkg/writers/gorm/writer_test.go | 45 ++++++++++++++++++ pkg/writers/graphql/writer.go | 3 +- pkg/writers/pgsql/migration_writer.go | 22 +++++---- pkg/writers/pgsql/templates.go | 2 +- pkg/writers/pgsql/writer.go | 60 ++++++++++++++++++++---- pkg/writers/prisma/writer.go | 34 +++++++++++++- pkg/writers/sqlite/templates.go | 56 ++++++++++++++++++++--- pkg/writers/sqlite/writer.go | 10 ++-- pkg/writers/typeorm/writer.go | 8 +++- 32 files changed, 733 insertions(+), 100 deletions(-) diff --git a/pkg/diff/diff.go b/pkg/diff/diff.go index fbfd6e2..6638cc1 100644 --- a/pkg/diff/diff.go +++ b/pkg/diff/diff.go @@ -2,10 +2,22 @@ package diff import ( "reflect" + "sort" "git.warky.dev/wdevs/relspecgo/pkg/models" ) +// sortedKeys returns a map's keys sorted alphabetically, so callers get a +// deterministic iteration order instead of Go's randomized map order. +func sortedKeys[T any](m map[string]T) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + // CompareDatabases compares two database models and returns the differences func CompareDatabases(source, target *models.Database) *DiffResult { result := &DiffResult{ @@ -34,7 +46,8 @@ func compareSchemas(source, target []*models.Schema) *SchemaDiff { } // Find missing and modified schemas - for name, srcSchema := range sourceMap { + for _, name := range sortedKeys(sourceMap) { + srcSchema := sourceMap[name] if tgtSchema, exists := targetMap[name]; !exists { diff.Missing = append(diff.Missing, srcSchema) } else { @@ -45,7 +58,8 @@ func compareSchemas(source, target []*models.Schema) *SchemaDiff { } // Find extra schemas - for name, tgtSchema := range targetMap { + for _, name := range sortedKeys(targetMap) { + tgtSchema := targetMap[name] if _, exists := sourceMap[name]; !exists { diff.Extra = append(diff.Extra, tgtSchema) } @@ -106,7 +120,8 @@ func compareTables(source, target []*models.Table) *TableDiff { } // Find missing and modified tables - for name, srcTable := range sourceMap { + for _, name := range sortedKeys(sourceMap) { + srcTable := sourceMap[name] if tgtTable, exists := targetMap[name]; !exists { diff.Missing = append(diff.Missing, srcTable) } else { @@ -117,7 +132,8 @@ func compareTables(source, target []*models.Table) *TableDiff { } // Find extra tables - for name, tgtTable := range targetMap { + for _, name := range sortedKeys(targetMap) { + tgtTable := targetMap[name] if _, exists := sourceMap[name]; !exists { diff.Extra = append(diff.Extra, tgtTable) } @@ -176,7 +192,8 @@ func compareColumns(source, target map[string]*models.Column) *ColumnDiff { } // Find missing and modified columns - for name, srcCol := range source { + for _, name := range sortedKeys(source) { + srcCol := source[name] if tgtCol, exists := target[name]; !exists { diff.Missing = append(diff.Missing, srcCol) } else { @@ -192,7 +209,8 @@ func compareColumns(source, target map[string]*models.Column) *ColumnDiff { } // Find extra columns - for name, tgtCol := range target { + for _, name := range sortedKeys(target) { + tgtCol := target[name] if _, exists := source[name]; !exists { diff.Extra = append(diff.Extra, tgtCol) } @@ -240,7 +258,8 @@ func compareIndexes(source, target map[string]*models.Index) *IndexDiff { } // Find missing and modified indexes - for name, srcIdx := range source { + for _, name := range sortedKeys(source) { + srcIdx := source[name] if tgtIdx, exists := target[name]; !exists { diff.Missing = append(diff.Missing, srcIdx) } else { @@ -256,7 +275,8 @@ func compareIndexes(source, target map[string]*models.Index) *IndexDiff { } // Find extra indexes - for name, tgtIdx := range target { + for _, name := range sortedKeys(target) { + tgtIdx := target[name] if _, exists := source[name]; !exists { diff.Extra = append(diff.Extra, tgtIdx) } @@ -292,7 +312,8 @@ func compareConstraints(source, target map[string]*models.Constraint) *Constrain } // Find missing and modified constraints - for name, srcCon := range source { + for _, name := range sortedKeys(source) { + srcCon := source[name] if tgtCon, exists := target[name]; !exists { diff.Missing = append(diff.Missing, srcCon) } else { @@ -308,7 +329,8 @@ func compareConstraints(source, target map[string]*models.Constraint) *Constrain } // Find extra constraints - for name, tgtCon := range target { + for _, name := range sortedKeys(target) { + tgtCon := target[name] if _, exists := source[name]; !exists { diff.Extra = append(diff.Extra, tgtCon) } @@ -350,7 +372,8 @@ func compareRelationships(source, target map[string]*models.Relationship) *Relat } // Find missing and modified relationships - for name, srcRel := range source { + for _, name := range sortedKeys(source) { + srcRel := source[name] if tgtRel, exists := target[name]; !exists { diff.Missing = append(diff.Missing, srcRel) } else { @@ -366,7 +389,8 @@ func compareRelationships(source, target map[string]*models.Relationship) *Relat } // Find extra relationships - for name, tgtRel := range target { + for _, name := range sortedKeys(target) { + tgtRel := target[name] if _, exists := source[name]; !exists { diff.Extra = append(diff.Extra, tgtRel) } @@ -415,7 +439,8 @@ func compareViews(source, target []*models.View) *ViewDiff { } // Find missing and modified views - for name, srcView := range sourceMap { + for _, name := range sortedKeys(sourceMap) { + srcView := sourceMap[name] if tgtView, exists := targetMap[name]; !exists { diff.Missing = append(diff.Missing, srcView) } else { @@ -431,7 +456,8 @@ func compareViews(source, target []*models.View) *ViewDiff { } // Find extra views - for name, tgtView := range targetMap { + for _, name := range sortedKeys(targetMap) { + tgtView := targetMap[name] if _, exists := sourceMap[name]; !exists { diff.Extra = append(diff.Extra, tgtView) } @@ -468,7 +494,8 @@ func compareSequences(source, target []*models.Sequence) *SequenceDiff { } // Find missing and modified sequences - for name, srcSeq := range sourceMap { + for _, name := range sortedKeys(sourceMap) { + srcSeq := sourceMap[name] if tgtSeq, exists := targetMap[name]; !exists { diff.Missing = append(diff.Missing, srcSeq) } else { @@ -484,7 +511,8 @@ func compareSequences(source, target []*models.Sequence) *SequenceDiff { } // Find extra sequences - for name, tgtSeq := range targetMap { + for _, name := range sortedKeys(targetMap) { + tgtSeq := targetMap[name] if _, exists := sourceMap[name]; !exists { diff.Extra = append(diff.Extra, tgtSeq) } diff --git a/pkg/diff/diff_test.go b/pkg/diff/diff_test.go index 1d54ab7..ab4db3b 100644 --- a/pkg/diff/diff_test.go +++ b/pkg/diff/diff_test.go @@ -1,6 +1,7 @@ package diff import ( + "reflect" "testing" "git.warky.dev/wdevs/relspecgo/pkg/models" @@ -140,6 +141,46 @@ func TestCompareColumns(t *testing.T) { } } +// TestCompareColumns_Deterministic verifies that Missing/Extra entries are +// always reported in the same (alphabetical) order across repeated calls, +// instead of following Go's randomized map iteration order over the +// source/target column maps. +func TestCompareColumns_Deterministic(t *testing.T) { + source := map[string]*models.Column{ + "zeta": {Name: "zeta", Type: "text"}, + "alpha": {Name: "alpha", Type: "text"}, + "mu": {Name: "mu", Type: "text"}, + } + target := map[string]*models.Column{ + "omega": {Name: "omega", Type: "text"}, + "delta": {Name: "delta", Type: "text"}, + "charlie": {Name: "charlie", Type: "text"}, + } + + wantMissing := []string{"alpha", "mu", "zeta"} + wantExtra := []string{"charlie", "delta", "omega"} + + for i := 0; i < 25; i++ { + got := compareColumns(source, target) + + gotMissing := make([]string, len(got.Missing)) + for j, c := range got.Missing { + gotMissing[j] = c.Name + } + gotExtra := make([]string, len(got.Extra)) + for j, c := range got.Extra { + gotExtra[j] = c.Name + } + + if !reflect.DeepEqual(gotMissing, wantMissing) { + t.Fatalf("compareColumns() Missing = %v, want %v (run %d)", gotMissing, wantMissing, i) + } + if !reflect.DeepEqual(gotExtra, wantExtra) { + t.Fatalf("compareColumns() Extra = %v, want %v (run %d)", gotExtra, wantExtra, i) + } + } +} + func TestCompareColumnDetails(t *testing.T) { tests := []struct { name string diff --git a/pkg/inspector/inspector.go b/pkg/inspector/inspector.go index ba6a3ca..05d469c 100644 --- a/pkg/inspector/inspector.go +++ b/pkg/inspector/inspector.go @@ -2,6 +2,7 @@ package inspector import ( "fmt" + "sort" "time" "git.warky.dev/wdevs/relspecgo/pkg/models" @@ -54,8 +55,15 @@ func NewInspector(db *models.Database, config *Config) *Inspector { func (i *Inspector) Inspect() (*InspectorReport, error) { results := []ValidationResult{} - // Run all enabled validators - for ruleName, rule := range i.config.Rules { + // Run all enabled validators in deterministic (alphabetical) rule-name order + ruleNames := make([]string, 0, len(i.config.Rules)) + for ruleName := range i.config.Rules { + ruleNames = append(ruleNames, ruleName) + } + sort.Strings(ruleNames) + + for _, ruleName := range ruleNames { + rule := i.config.Rules[ruleName] if !rule.IsEnabled() { continue } diff --git a/pkg/inspector/inspector_test.go b/pkg/inspector/inspector_test.go index 016f97c..563227d 100644 --- a/pkg/inspector/inspector_test.go +++ b/pkg/inspector/inspector_test.go @@ -51,6 +51,45 @@ func TestInspect(t *testing.T) { } } +// TestInspect_Deterministic verifies that repeated Inspect() calls against +// the same database and config produce violations in the same order, instead +// of following Go's randomized map iteration order over config.Rules and the +// per-table Columns/Constraints/Indexes maps. +func TestInspect_Deterministic(t *testing.T) { + db := createTestDatabase() + config := GetDefaultConfig() + + inspector := NewInspector(db, config) + + first, err := inspector.Inspect() + if err != nil { + t.Fatalf("Inspect() returned error: %v", err) + } + + wantOrder := make([]string, len(first.Violations)) + for i, v := range first.Violations { + wantOrder[i] = v.RuleName + "|" + v.Location + } + + for i := 0; i < 25; i++ { + report, err := inspector.Inspect() + if err != nil { + t.Fatalf("Inspect() returned error on run %d: %v", i, err) + } + + if len(report.Violations) != len(wantOrder) { + t.Fatalf("run %d: got %d violations, want %d", i, len(report.Violations), len(wantOrder)) + } + + for j, v := range report.Violations { + got := v.RuleName + "|" + v.Location + if got != wantOrder[j] { + t.Fatalf("run %d: violation[%d] = %q, want %q", i, j, got, wantOrder[j]) + } + } + } +} + func TestInspectWithDisabledRules(t *testing.T) { db := createTestDatabase() config := GetDefaultConfig() diff --git a/pkg/inspector/report.go b/pkg/inspector/report.go index 33d4599..1877926 100644 --- a/pkg/inspector/report.go +++ b/pkg/inspector/report.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "sort" "strings" "time" ) @@ -199,12 +200,18 @@ func (f *MarkdownFormatter) formatContext(context map[string]interface{}) string "column": true, } - for key, value := range context { + keys := make([]string, 0, len(context)) + for key := range context { + keys = append(keys, key) + } + sort.Strings(keys) + + for _, key := range keys { if skipKeys[key] { continue } - parts = append(parts, fmt.Sprintf("%s=%v", key, value)) + parts = append(parts, fmt.Sprintf("%s=%v", key, context[key])) } return strings.Join(parts, ", ") diff --git a/pkg/inspector/validators.go b/pkg/inspector/validators.go index 1494712..6d36583 100644 --- a/pkg/inspector/validators.go +++ b/pkg/inspector/validators.go @@ -2,12 +2,54 @@ package inspector import ( "regexp" + "sort" "strings" "git.warky.dev/wdevs/relspecgo/pkg/models" "git.warky.dev/wdevs/relspecgo/pkg/pgsql" ) +// sortedKeys returns a map's keys sorted alphabetically, so validators report +// violations in a deterministic order instead of Go's randomized map order. +func sortedKeys[T any](m map[string]T) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// sortColumns returns columns sorted by Sequence then Name for deterministic output. +func sortColumns(columns map[string]*models.Column) []*models.Column { + result := make([]*models.Column, 0, len(columns)) + for _, col := range columns { + result = append(result, col) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Sequence > 0 && result[j].Sequence > 0 { + return result[i].Sequence < result[j].Sequence + } + return result[i].Name < result[j].Name + }) + return result +} + +// sortConstraints returns constraints sorted by Sequence then Name for deterministic output. +func sortConstraints(constraints map[string]*models.Constraint) []*models.Constraint { + result := make([]*models.Constraint, 0, len(constraints)) + for _, c := range constraints { + result = append(result, c) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Sequence > 0 && result[j].Sequence > 0 { + return result[i].Sequence < result[j].Sequence + } + return result[i].Name < result[j].Name + }) + return result +} + // validatePrimaryKeyNaming checks that primary key column names match a pattern func validatePrimaryKeyNaming(db *models.Database, rule Rule, ruleName string) []ValidationResult { results := []ValidationResult{} @@ -18,7 +60,7 @@ func validatePrimaryKeyNaming(db *models.Database, rule Rule, ruleName string) [ for _, schema := range db.Schemas { for _, table := range schema.Tables { - for _, col := range table.Columns { + for _, col := range sortColumns(table.Columns) { if col.IsPrimaryKey { location := formatLocation(schema.Name, table.Name, col.Name) passed := pattern.MatchString(col.Name) @@ -49,7 +91,7 @@ func validatePrimaryKeyDatatype(db *models.Database, rule Rule, ruleName string) for _, schema := range db.Schemas { for _, table := range schema.Tables { - for _, col := range table.Columns { + for _, col := range sortColumns(table.Columns) { if col.IsPrimaryKey { location := formatLocation(schema.Name, table.Name, col.Name) @@ -84,7 +126,7 @@ func validatePrimaryKeyAutoIncrement(db *models.Database, rule Rule, ruleName st for _, schema := range db.Schemas { for _, table := range schema.Tables { - for _, col := range table.Columns { + for _, col := range sortColumns(table.Columns) { if col.IsPrimaryKey { location := formatLocation(schema.Name, table.Name, col.Name) @@ -125,7 +167,7 @@ func validateForeignKeyColumnNaming(db *models.Database, rule Rule, ruleName str for _, schema := range db.Schemas { for _, table := range schema.Tables { // Check foreign key constraints - for _, constraint := range table.Constraints { + for _, constraint := range sortConstraints(table.Constraints) { if constraint.Type == models.ForeignKeyConstraint { for _, colName := range constraint.Columns { location := formatLocation(schema.Name, table.Name, colName) @@ -163,7 +205,7 @@ func validateForeignKeyConstraintNaming(db *models.Database, rule Rule, ruleName for _, schema := range db.Schemas { for _, table := range schema.Tables { - for _, constraint := range table.Constraints { + for _, constraint := range sortConstraints(table.Constraints) { if constraint.Type == models.ForeignKeyConstraint { location := formatLocation(schema.Name, table.Name, "") passed := pattern.MatchString(constraint.Name) @@ -209,7 +251,7 @@ func validateForeignKeyIndex(db *models.Database, rule Rule, ruleName string) [] } // Check if each FK column has an index - for fkCol := range fkColumns { + for _, fkCol := range sortedKeys(fkColumns) { hasIndex := false // Check table indexes @@ -282,7 +324,7 @@ func validateColumnNamingCase(db *models.Database, rule Rule, ruleName string) [ for _, schema := range db.Schemas { for _, table := range schema.Tables { - for _, col := range table.Columns { + for _, col := range sortColumns(table.Columns) { location := formatLocation(schema.Name, table.Name, col.Name) passed := pattern.MatchString(col.Name) @@ -339,7 +381,7 @@ func validateColumnNameLength(db *models.Database, rule Rule, ruleName string) [ for _, schema := range db.Schemas { for _, table := range schema.Tables { - for _, col := range table.Columns { + for _, col := range sortColumns(table.Columns) { location := formatLocation(schema.Name, table.Name, col.Name) passed := len(col.Name) <= rule.MaxLength @@ -396,7 +438,7 @@ func validateReservedKeywords(db *models.Database, rule Rule, ruleName string) [ // Check column names if rule.CheckColumns { - for _, col := range table.Columns { + for _, col := range sortColumns(table.Columns) { location := formatLocation(schema.Name, table.Name, col.Name) passed := !keywords[strings.ToUpper(col.Name)] @@ -479,7 +521,7 @@ func validateOrphanedForeignKey(db *models.Database, rule Rule, ruleName string) // Check all foreign key constraints for _, schema := range db.Schemas { for _, table := range schema.Tables { - for _, constraint := range table.Constraints { + for _, constraint := range sortConstraints(table.Constraints) { if constraint.Type == models.ForeignKeyConstraint { // Build referenced table key refSchema := constraint.ReferencedSchema @@ -522,7 +564,7 @@ func validateCircularDependency(db *models.Database, rule Rule, ruleName string) for _, table := range schema.Tables { tableKey := schema.Name + "." + table.Name - for _, constraint := range table.Constraints { + for _, constraint := range sortConstraints(table.Constraints) { if constraint.Type == models.ForeignKeyConstraint { refSchema := constraint.ReferencedSchema if refSchema == "" { @@ -537,7 +579,7 @@ func validateCircularDependency(db *models.Database, rule Rule, ruleName string) } // Check for cycles using DFS - for tableKey := range dependencies { + for _, tableKey := range sortedKeys(dependencies) { visited := make(map[string]bool) recStack := make(map[string]bool) diff --git a/pkg/merge/merge.go b/pkg/merge/merge.go index 1ca6eca..6a96180 100644 --- a/pkg/merge/merge.go +++ b/pkg/merge/merge.go @@ -5,6 +5,7 @@ package merge import ( "fmt" + "sort" "strconv" "strings" @@ -156,8 +157,17 @@ func (r *MergeResult) mergeColumns(table *models.Table, srcTable *models.Table) existingColumns[colName] = table.Columns[colName] } - // Merge columns - for colName, srcCol := range srcTable.Columns { + // Merge columns in deterministic (alphabetical) order so that, when a + // TypeConflicts entry is recorded, its position in the report doesn't + // depend on Go's randomized map iteration order. + srcColNames := make([]string, 0, len(srcTable.Columns)) + for colName := range srcTable.Columns { + srcColNames = append(srcColNames, colName) + } + sort.Strings(srcColNames) + + for _, colName := range srcColNames { + srcCol := srcTable.Columns[colName] if tgtCol, exists := existingColumns[colName]; !exists { // Column doesn't exist, add it newCol := cloneColumn(srcCol) diff --git a/pkg/models/flatview.go b/pkg/models/flatview.go index 687ee3c..f990f8f 100644 --- a/pkg/models/flatview.go +++ b/pkg/models/flatview.go @@ -1,6 +1,9 @@ package models -import "fmt" +import ( + "fmt" + "sort" +) // Flat/Denormalized Views // @@ -56,6 +59,10 @@ func (d *Database) ToFlatColumns() []*FlatColumn { } } + sort.Slice(flatColumns, func(i, j int) bool { + return flatColumns[i].FullyQualifiedName < flatColumns[j].FullyQualifiedName + }) + return flatColumns } @@ -148,6 +155,10 @@ func (d *Database) ToFlatConstraints() []*FlatConstraint { } } + sort.Slice(flatConstraints, func(i, j int) bool { + return flatConstraints[i].FullyQualifiedName < flatConstraints[j].FullyQualifiedName + }) + return flatConstraints } @@ -198,5 +209,16 @@ func (d *Database) ToFlatRelationships() []*FlatRelationship { } } + sort.Slice(flatRelationships, func(i, j int) bool { + a, b := flatRelationships[i], flatRelationships[j] + if a.FromFQN != b.FromFQN { + return a.FromFQN < b.FromFQN + } + if a.RelationshipName != b.RelationshipName { + return a.RelationshipName < b.RelationshipName + } + return a.ToFQN < b.ToFQN + }) + return flatRelationships } diff --git a/pkg/models/models.go b/pkg/models/models.go index 99f29d2..81311d5 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -5,6 +5,7 @@ package models import ( + "sort" "strings" "time" @@ -141,15 +142,28 @@ func (d *Table) SQLName() string { // GetPrimaryKey returns the primary key column for the table, or nil if none exists. func (m Table) GetPrimaryKey() *Column { + var pk *Column for _, column := range m.Columns { - if column.IsPrimaryKey { - return column + if !column.IsPrimaryKey { + continue + } + if pk == nil || columnLess(column, pk) { + pk = column } } - return nil + return pk } -// GetForeignKeys returns all foreign key constraints for the table. +// columnLess reports whether a should sort before b, by Sequence then Name. +func columnLess(a, b *Column) bool { + if a.Sequence > 0 && b.Sequence > 0 { + return a.Sequence < b.Sequence + } + return a.Name < b.Name +} + +// GetForeignKeys returns all foreign key constraints for the table, sorted +// deterministically by Sequence then Name. func (m Table) GetForeignKeys() []*Constraint { keys := make([]*Constraint, 0) @@ -158,6 +172,12 @@ func (m Table) GetForeignKeys() []*Constraint { keys = append(keys, c) } } + sort.Slice(keys, func(i, j int) bool { + if keys[i].Sequence > 0 && keys[j].Sequence > 0 { + return keys[i].Sequence < keys[j].Sequence + } + return keys[i].Name < keys[j].Name + }) return keys } diff --git a/pkg/readers/dctx/reader.go b/pkg/readers/dctx/reader.go index 2509fc1..89064d7 100644 --- a/pkg/readers/dctx/reader.go +++ b/pkg/readers/dctx/reader.go @@ -4,6 +4,7 @@ import ( "encoding/xml" "fmt" "os" + "sort" "strings" "git.warky.dev/wdevs/relspecgo/pkg/models" @@ -373,7 +374,13 @@ func (r *Reader) convertKey(dctxKey *models.DCTXKey, table *models.Table, fieldG if len(columns) == 0 { if dctxKey.Primary { // Look for common primary key column patterns + colNames := make([]string, 0, len(table.Columns)) for colName := range table.Columns { + colNames = append(colNames, colName) + } + sort.Strings(colNames) + + for _, colName := range colNames { colNameLower := strings.ToLower(colName) if strings.HasPrefix(colNameLower, "rid_") || strings.HasSuffix(colNameLower, "id") { columns = append(columns, colName) diff --git a/pkg/readers/prisma/reader.go b/pkg/readers/prisma/reader.go index 815329e..7de71d7 100644 --- a/pkg/readers/prisma/reader.go +++ b/pkg/readers/prisma/reader.go @@ -820,17 +820,31 @@ func (r *Reader) createImplicitJoinTable(model1, model2 string, tableMap map[str tableMap[joinTableName] = joinTable } -// getPrimaryKeyColumn returns the primary key column of a table +// getPrimaryKeyColumn returns the primary key column of a table. For tables +// with a composite primary key, the column with the lowest Sequence (or, +// failing that, the alphabetically first Name) is returned deterministically. func (r *Reader) getPrimaryKeyColumn(table *models.Table) *models.Column { if table == nil { return nil } + var pk *models.Column for _, col := range table.Columns { - if col.IsPrimaryKey { - return col + if !col.IsPrimaryKey { + continue + } + if pk == nil { + pk = col + continue + } + if col.Sequence > 0 && pk.Sequence > 0 { + if col.Sequence < pk.Sequence { + pk = col + } + } else if col.Name < pk.Name { + pk = col } } - return nil + return pk } diff --git a/pkg/readers/typeorm/reader.go b/pkg/readers/typeorm/reader.go index 660a8db..52fc033 100644 --- a/pkg/readers/typeorm/reader.go +++ b/pkg/readers/typeorm/reader.go @@ -806,17 +806,31 @@ func (r *Reader) createManyToManyJoinTable(entity1, entity2 string, tableMap map tableMap[joinTableName] = joinTable } -// getPrimaryKeyColumn returns the primary key column of a table +// getPrimaryKeyColumn returns the primary key column of a table. For tables +// with a composite primary key, the column with the lowest Sequence (or, +// failing that, the alphabetically first Name) is returned deterministically. func (r *Reader) getPrimaryKeyColumn(table *models.Table) *models.Column { if table == nil { return nil } + var pk *models.Column for _, col := range table.Columns { - if col.IsPrimaryKey { - return col + if !col.IsPrimaryKey { + continue + } + if pk == nil { + pk = col + continue + } + if col.Sequence > 0 && pk.Sequence > 0 { + if col.Sequence < pk.Sequence { + pk = col + } + } else if col.Name < pk.Name { + pk = col } } - return nil + return pk } diff --git a/pkg/ui/editor.go b/pkg/ui/editor.go index 7b1ae5e..cb3eda6 100644 --- a/pkg/ui/editor.go +++ b/pkg/ui/editor.go @@ -2,6 +2,7 @@ package ui import ( "fmt" + "sort" "github.com/rivo/tview" @@ -69,5 +70,6 @@ func getColumnNames(table *models.Table) []string { for name := range table.Columns { names = append(names, name) } + sort.Strings(names) return names } diff --git a/pkg/ui/relation_dataops.go b/pkg/ui/relation_dataops.go index 30ebd38..7a4e7d6 100644 --- a/pkg/ui/relation_dataops.go +++ b/pkg/ui/relation_dataops.go @@ -1,6 +1,10 @@ package ui -import "git.warky.dev/wdevs/relspecgo/pkg/models" +import ( + "sort" + + "git.warky.dev/wdevs/relspecgo/pkg/models" +) // Relationship data operations - business logic for relationship management @@ -111,5 +115,6 @@ func (se *SchemaEditor) GetRelationshipNames(schemaIndex, tableIndex int) []stri for name := range table.Relationships { names = append(names, name) } + sort.Strings(names) return names } diff --git a/pkg/writers/bun/template_data.go b/pkg/writers/bun/template_data.go index 767adc0..4abacd1 100644 --- a/pkg/writers/bun/template_data.go +++ b/pkg/writers/bun/template_data.go @@ -220,8 +220,11 @@ func NewModelData(table *models.Table, schema string, typeMapper *TypeMapper, fl Prefix: GeneratePrefix(table.Name), } + // Convert columns to fields (sorted by sequence or name) + columns := sortColumns(table.Columns) + // Find primary key - for _, col := range table.Columns { + for _, col := range columns { if col.IsPrimaryKey { // Sanitize column name to remove backticks safeName := writers.SanitizeStructTagValue(col.Name) @@ -240,8 +243,6 @@ func NewModelData(table *models.Table, schema string, typeMapper *TypeMapper, fl } } - // Convert columns to fields (sorted by sequence or name) - columns := sortColumns(table.Columns) for _, col := range columns { field := columnToField(col, table, typeMapper) // Check for name collision with generated methods and rename if needed @@ -335,6 +336,21 @@ func sortConstraints(constraints map[string]*models.Constraint) []*models.Constr return result } +// sortIndexes sorts indexes by sequence, then by name +func sortIndexes(indexes map[string]*models.Index) []*models.Index { + result := make([]*models.Index, 0, len(indexes)) + for _, idx := range indexes { + result = append(result, idx) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Sequence > 0 && result[j].Sequence > 0 { + return result[i].Sequence < result[j].Sequence + } + return result[i].Name < result[j].Name + }) + return result +} + // sortColumns sorts columns by sequence, then by name func sortColumns(columns map[string]*models.Column) []*models.Column { result := make([]*models.Column, 0, len(columns)) diff --git a/pkg/writers/bun/type_mapper.go b/pkg/writers/bun/type_mapper.go index 7b55321..acfd5eb 100644 --- a/pkg/writers/bun/type_mapper.go +++ b/pkg/writers/bun/type_mapper.go @@ -383,7 +383,7 @@ func (tm *TypeMapper) BuildBunTag(column *models.Column, table *models.Table) st // Check for indexes (unique indexes should be added to tag) if table != nil { - for _, index := range table.Indexes { + for _, index := range sortIndexes(table.Indexes) { if !index.Unique { continue } diff --git a/pkg/writers/bun/writer_test.go b/pkg/writers/bun/writer_test.go index 1bd5679..dc89167 100644 --- a/pkg/writers/bun/writer_test.go +++ b/pkg/writers/bun/writer_test.go @@ -836,6 +836,43 @@ func TestTypeMapper_BuildBunTag(t *testing.T) { } } +// TestTypeMapper_BuildBunTag_MultipleUniqueIndexesDeterministic verifies that +// when a column belongs to more than one unique index, the "unique:" tag +// fragments always appear in the same order across repeated calls, instead +// of following Go's randomized map iteration order over Table.Indexes. +func TestTypeMapper_BuildBunTag_MultipleUniqueIndexesDeterministic(t *testing.T) { + mapper := NewTypeMapper("", "") + table := &models.Table{ + Name: "accounts", + Indexes: map[string]*models.Index{ + "idx_z_accounts_email_tenant": { + Name: "idx_z_accounts_email_tenant", + Columns: []string{"email", "tenant_id"}, + Unique: true, + }, + "idx_a_accounts_email_region": { + Name: "idx_a_accounts_email_region", + Columns: []string{"email", "region_id"}, + Unique: true, + }, + }, + } + column := &models.Column{Name: "email", Type: "varchar", Length: 255, NotNull: true} + + first := mapper.BuildBunTag(column, table) + for i := 0; i < 50; i++ { + got := mapper.BuildBunTag(column, table) + if got != first { + t.Fatalf("BuildBunTag() is non-deterministic across calls: %q vs %q", first, got) + } + } + + wantOrder := "unique:idx_a_accounts_email_region,unique:idx_z_accounts_email_tenant," + if !strings.Contains(first, wantOrder) { + t.Errorf("BuildBunTag() = %q, want unique tags sorted by index name: %q", first, wantOrder) + } +} + // TestTypeMapper_BuildBunTag_ArraysAreNativeInEveryMode verifies that array // columns always use a plain "text[]"-style type and the native Go slice // type plus an explicit "array" tag, regardless of NullableTypes style diff --git a/pkg/writers/dbml/writer.go b/pkg/writers/dbml/writer.go index 198b740..6d7763d 100644 --- a/pkg/writers/dbml/writer.go +++ b/pkg/writers/dbml/writer.go @@ -3,6 +3,7 @@ package dbml import ( "fmt" "os" + "sort" "strings" "git.warky.dev/wdevs/relspecgo/pkg/models" @@ -78,7 +79,7 @@ func (w *Writer) databaseToDBML(d *models.Database) string { sb.WriteString("\n// Relationships\n") for _, schema := range d.Schemas { for _, table := range schema.Tables { - for _, constraint := range table.Constraints { + for _, constraint := range sortConstraints(table.Constraints) { if constraint.Type == models.ForeignKeyConstraint { sb.WriteString(w.constraintToDBML(constraint, table)) } @@ -112,7 +113,7 @@ func (w *Writer) tableToDBML(t *models.Table) string { tableName := fmt.Sprintf("%s.%s", t.Schema, t.Name) fmt.Fprintf(&sb, "Table %s {\n", tableName) - for _, column := range t.Columns { + for _, column := range sortColumns(t.Columns) { fmt.Fprintf(&sb, " %s %s", column.Name, column.Type) var attrs []string @@ -149,7 +150,7 @@ func (w *Writer) tableToDBML(t *models.Table) string { if len(t.Indexes) > 0 { sb.WriteString("\n indexes {\n") - for _, index := range t.Indexes { + for _, index := range sortIndexes(t.Indexes) { var indexAttrs []string if index.Unique { indexAttrs = append(indexAttrs, "unique") @@ -230,3 +231,48 @@ func (w *Writer) constraintToDBML(c *models.Constraint, t *models.Table) string return refLine + "\n" } + +// sortColumns returns columns sorted by Sequence then Name for deterministic output. +func sortColumns(columns map[string]*models.Column) []*models.Column { + result := make([]*models.Column, 0, len(columns)) + for _, col := range columns { + result = append(result, col) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Sequence > 0 && result[j].Sequence > 0 { + return result[i].Sequence < result[j].Sequence + } + return result[i].Name < result[j].Name + }) + return result +} + +// sortConstraints returns constraints sorted by Sequence then Name for deterministic output. +func sortConstraints(constraints map[string]*models.Constraint) []*models.Constraint { + result := make([]*models.Constraint, 0, len(constraints)) + for _, c := range constraints { + result = append(result, c) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Sequence > 0 && result[j].Sequence > 0 { + return result[i].Sequence < result[j].Sequence + } + return result[i].Name < result[j].Name + }) + return result +} + +// sortIndexes returns indexes sorted by Sequence then Name for deterministic output. +func sortIndexes(indexes map[string]*models.Index) []*models.Index { + result := make([]*models.Index, 0, len(indexes)) + for _, idx := range indexes { + result = append(result, idx) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Sequence > 0 && result[j].Sequence > 0 { + return result[i].Sequence < result[j].Sequence + } + return result[i].Name < result[j].Name + }) + return result +} diff --git a/pkg/writers/dctx/writer.go b/pkg/writers/dctx/writer.go index 0c29077..e63c504 100644 --- a/pkg/writers/dctx/writer.go +++ b/pkg/writers/dctx/writer.go @@ -66,7 +66,14 @@ func (w *Writer) WriteSchema(schema *models.Schema) error { // Add table-level relationships for _, table := range tableSlice { - for _, rel := range table.Relationships { + relNames := make([]string, 0, len(table.Relationships)) + for name := range table.Relationships { + relNames = append(relNames, name) + } + sort.Strings(relNames) + + for _, relName := range relNames { + rel := table.Relationships[relName] // Check if this relationship is already in the list (avoid duplicates) isDuplicate := false for _, existing := range allRelations { diff --git a/pkg/writers/drawdb/writer.go b/pkg/writers/drawdb/writer.go index 2e5bb87..5525a4f 100644 --- a/pkg/writers/drawdb/writer.go +++ b/pkg/writers/drawdb/writer.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "sort" "git.warky.dev/wdevs/relspecgo/pkg/models" "git.warky.dev/wdevs/relspecgo/pkg/writers" @@ -175,7 +176,7 @@ func (w *Writer) databaseToDrawDB(d *models.Database) *DrawDBSchema { // Add relationships for _, schemaModel := range d.Schemas { for _, table := range schemaModel.Tables { - for _, constraint := range table.Constraints { + for _, constraint := range sortConstraints(table.Constraints) { if constraint.Type == models.ForeignKeyConstraint && constraint.ReferencedTable != "" { startTableKey := fmt.Sprintf("%s.%s", schemaModel.Name, table.Name) endTableKey := fmt.Sprintf("%s.%s", constraint.ReferencedSchema, constraint.ReferencedTable) @@ -306,7 +307,7 @@ func (w *Writer) convertTableToDrawDB(table *models.Table, schemaName string, ta } // Add fields - for _, column := range table.Columns { + for _, column := range sortColumns(table.Columns) { field := &DrawDBField{ ID: fieldID, Name: column.Name, @@ -339,7 +340,7 @@ func (w *Writer) convertTableToDrawDB(table *models.Table, schemaName string, ta // Add indexes indexID := 0 - for _, index := range table.Indexes { + for _, index := range sortIndexes(table.Indexes) { drawIndex := &DrawDBIndex{ ID: indexID, Name: index.Name, @@ -393,3 +394,48 @@ func getColorForIndex(index int) string { } return colors[index%len(colors)] } + +// sortColumns returns columns sorted by Sequence then Name for deterministic output. +func sortColumns(columns map[string]*models.Column) []*models.Column { + result := make([]*models.Column, 0, len(columns)) + for _, col := range columns { + result = append(result, col) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Sequence > 0 && result[j].Sequence > 0 { + return result[i].Sequence < result[j].Sequence + } + return result[i].Name < result[j].Name + }) + return result +} + +// sortConstraints returns constraints sorted by Sequence then Name for deterministic output. +func sortConstraints(constraints map[string]*models.Constraint) []*models.Constraint { + result := make([]*models.Constraint, 0, len(constraints)) + for _, c := range constraints { + result = append(result, c) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Sequence > 0 && result[j].Sequence > 0 { + return result[i].Sequence < result[j].Sequence + } + return result[i].Name < result[j].Name + }) + return result +} + +// sortIndexes returns indexes sorted by Sequence then Name for deterministic output. +func sortIndexes(indexes map[string]*models.Index) []*models.Index { + result := make([]*models.Index, 0, len(indexes)) + for _, idx := range indexes { + result = append(result, idx) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Sequence > 0 && result[j].Sequence > 0 { + return result[i].Sequence < result[j].Sequence + } + return result[i].Name < result[j].Name + }) + return result +} diff --git a/pkg/writers/drizzle/writer.go b/pkg/writers/drizzle/writer.go index e82bb67..48c7954 100644 --- a/pkg/writers/drizzle/writer.go +++ b/pkg/writers/drizzle/writer.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "git.warky.dev/wdevs/relspecgo/pkg/models" @@ -250,7 +251,7 @@ func (w *Writer) buildTableData(table *models.Table, schema *models.Schema, db * indexColumnFields := make(map[string]bool) // Add indexes (excluding single-column unique indexes, which are handled inline) - for _, index := range table.Indexes { + for _, index := range sortIndexes(table.Indexes) { // Skip single-column unique indexes (handled by .unique() modifier) if index.Unique && len(index.Columns) == 1 { continue @@ -270,7 +271,7 @@ func (w *Writer) buildTableData(table *models.Table, schema *models.Schema, db * } // Add multi-column unique constraints as unique indexes - for _, constraint := range table.Constraints { + for _, constraint := range sortConstraints(table.Constraints) { if constraint.Type == models.UniqueConstraint && len(constraint.Columns) > 1 { // Create a unique index for this constraint indexData := &IndexData{ @@ -316,6 +317,36 @@ func (w *Writer) buildTableData(table *models.Table, schema *models.Schema, db * return tableData } +// sortIndexes returns indexes sorted by Sequence then Name for deterministic output. +func sortIndexes(indexes map[string]*models.Index) []*models.Index { + result := make([]*models.Index, 0, len(indexes)) + for _, idx := range indexes { + result = append(result, idx) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Sequence > 0 && result[j].Sequence > 0 { + return result[i].Sequence < result[j].Sequence + } + return result[i].Name < result[j].Name + }) + return result +} + +// sortConstraints returns constraints sorted by Sequence then Name for deterministic output. +func sortConstraints(constraints map[string]*models.Constraint) []*models.Constraint { + result := make([]*models.Constraint, 0, len(constraints)) + for _, c := range constraints { + result = append(result, c) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Sequence > 0 && result[j].Sequence > 0 { + return result[i].Sequence < result[j].Sequence + } + return result[i].Name < result[j].Name + }) + return result +} + // sortStrings sorts a slice of strings in place func sortStrings(strs []string) { for i := 0; i < len(strs); i++ { @@ -422,7 +453,8 @@ func (w *Writer) getTableEnumNames(table *models.Table, schema *models.Schema, e enumNames := make([]string, 0) seen := make(map[string]bool) - for _, col := range table.Columns { + for _, colName := range w.getSortedColumnNames(table) { + col := table.Columns[colName] if enumMap[col.Type] || enumMap[strings.ToLower(col.Type)] { // Find the enum in schema for _, enum := range schema.Enums { diff --git a/pkg/writers/gorm/template_data.go b/pkg/writers/gorm/template_data.go index 1d1d7bc..fa543ec 100644 --- a/pkg/writers/gorm/template_data.go +++ b/pkg/writers/gorm/template_data.go @@ -134,8 +134,11 @@ func NewModelData(table *models.Table, schema string, typeMapper *TypeMapper, fl Prefix: GeneratePrefix(table.Name), } + // Convert columns to fields (sorted by sequence or name) + columns := sortColumns(table.Columns) + // Find primary key - for _, col := range table.Columns { + for _, col := range columns { if col.IsPrimaryKey { // Sanitize column name to remove backticks safeName := writers.SanitizeStructTagValue(col.Name) @@ -153,8 +156,6 @@ func NewModelData(table *models.Table, schema string, typeMapper *TypeMapper, fl } } - // Convert columns to fields (sorted by sequence or name) - columns := sortColumns(table.Columns) for _, col := range columns { field := columnToField(col, table, typeMapper) // Check for name collision with generated methods and rename if needed @@ -248,6 +249,21 @@ func sortConstraints(constraints map[string]*models.Constraint) []*models.Constr return result } +// sortIndexes sorts indexes by sequence, then by name +func sortIndexes(indexes map[string]*models.Index) []*models.Index { + result := make([]*models.Index, 0, len(indexes)) + for _, idx := range indexes { + result = append(result, idx) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Sequence > 0 && result[j].Sequence > 0 { + return result[i].Sequence < result[j].Sequence + } + return result[i].Name < result[j].Name + }) + return result +} + // sortColumns sorts columns by sequence, then by name func sortColumns(columns map[string]*models.Column) []*models.Column { result := make([]*models.Column, 0, len(columns)) diff --git a/pkg/writers/gorm/type_mapper.go b/pkg/writers/gorm/type_mapper.go index 24e67c7..7f7e375 100644 --- a/pkg/writers/gorm/type_mapper.go +++ b/pkg/writers/gorm/type_mapper.go @@ -415,7 +415,7 @@ func (tm *TypeMapper) BuildGormTag(column *models.Column, table *models.Table) s // Check for unique constraint if table != nil { - for _, constraint := range table.Constraints { + for _, constraint := range sortConstraints(table.Constraints) { if constraint.Type == models.UniqueConstraint { for _, col := range constraint.Columns { if col == column.Name { @@ -431,7 +431,7 @@ func (tm *TypeMapper) BuildGormTag(column *models.Column, table *models.Table) s } // Check for index - for _, index := range table.Indexes { + for _, index := range sortIndexes(table.Indexes) { for _, col := range index.Columns { if col == column.Name { if index.Unique { diff --git a/pkg/writers/gorm/writer_test.go b/pkg/writers/gorm/writer_test.go index 52a2ec0..552d208 100644 --- a/pkg/writers/gorm/writer_test.go +++ b/pkg/writers/gorm/writer_test.go @@ -757,3 +757,48 @@ func TestTypeMapper_BuildGormTag_PreservesExplicitTypeModifiers(t *testing.T) { t.Fatalf("type modifier appears duplicated in %q", tag) } } + +// TestTypeMapper_BuildGormTag_MultipleUniqueIndexesDeterministic verifies +// that when a column belongs to a unique constraint and more than one +// unique index, the "uniqueIndex:" tag fragments always appear in the same +// order across repeated calls, instead of following Go's randomized map +// iteration order over Table.Constraints and Table.Indexes. +func TestTypeMapper_BuildGormTag_MultipleUniqueIndexesDeterministic(t *testing.T) { + mapper := NewTypeMapper("") + table := &models.Table{ + Name: "accounts", + Constraints: map[string]*models.Constraint{ + "uq_z_accounts_email": { + Name: "uq_z_accounts_email", + Type: models.UniqueConstraint, + Columns: []string{"email"}, + }, + }, + Indexes: map[string]*models.Index{ + "idx_z_accounts_email_tenant": { + Name: "idx_z_accounts_email_tenant", + Columns: []string{"email", "tenant_id"}, + Unique: true, + }, + "idx_a_accounts_email_region": { + Name: "idx_a_accounts_email_region", + Columns: []string{"email", "region_id"}, + Unique: true, + }, + }, + } + column := &models.Column{Name: "email", Type: "varchar", Length: 255, NotNull: true} + + first := mapper.BuildGormTag(column, table) + for i := 0; i < 50; i++ { + got := mapper.BuildGormTag(column, table) + if got != first { + t.Fatalf("BuildGormTag() is non-deterministic across calls: %q vs %q", first, got) + } + } + + wantOrder := "uniqueIndex:uq_z_accounts_email;uniqueIndex:idx_a_accounts_email_region;uniqueIndex:idx_z_accounts_email_tenant" + if !strings.Contains(first, wantOrder) { + t.Errorf("BuildGormTag() = %q, want uniqueIndex tags sorted (constraint before indexes, indexes by name): %q", first, wantOrder) + } +} diff --git a/pkg/writers/graphql/writer.go b/pkg/writers/graphql/writer.go index 715f34e..3f801f3 100644 --- a/pkg/writers/graphql/writer.go +++ b/pkg/writers/graphql/writer.go @@ -233,7 +233,8 @@ func (w *Writer) tableToGraphQL(table *models.Table, db *models.Database, schema // Add relation fields relationFields = w.generateRelationFields(table, db, schema) - // Write fields in order: ID, scalars (sorted), relations (sorted) + // Write fields in order: ID (sorted), scalars (sorted), relations (sorted) + sort.Strings(idFields) for _, field := range idFields { sb.WriteString(field + "\n") } diff --git a/pkg/writers/pgsql/migration_writer.go b/pkg/writers/pgsql/migration_writer.go index 4f0dce3..fef8d95 100644 --- a/pkg/writers/pgsql/migration_writer.go +++ b/pkg/writers/pgsql/migration_writer.go @@ -239,7 +239,8 @@ func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *mod } // Check each constraint in current database - for constraintName, currentConstraint := range currentTable.Constraints { + for _, currentConstraint := range sortConstraints(currentTable.Constraints) { + constraintName := currentConstraint.Name modelConstraint, existsInModel := modelTable.Constraints[constraintName] shouldDrop := false @@ -252,7 +253,8 @@ func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *mod if shouldDrop && currentConstraint.Type == models.PrimaryKeyConstraint { // Drop FK constraints that depend on this PK before dropping the PK itself. for _, otherTable := range current.Tables { - for fkName, fkConstraint := range otherTable.Constraints { + for _, fkConstraint := range sortConstraints(otherTable.Constraints) { + fkName := fkConstraint.Name if fkConstraint.Type != models.ForeignKeyConstraint { continue } @@ -310,7 +312,8 @@ func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *mod } // Check indexes - for indexName, currentIndex := range currentTable.Indexes { + for _, currentIndex := range sortIndexes(currentTable.Indexes) { + indexName := currentIndex.Name modelIndex, existsInModel := modelTable.Indexes[indexName] shouldDrop := false @@ -401,7 +404,7 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model } // Check each model column - for _, modelCol := range modelTable.Columns { + for _, modelCol := range sortColumns(modelTable.Columns) { currentCol, exists := currentColumns[strings.ToLower(modelCol.Name)] if !exists { @@ -518,7 +521,8 @@ func (w *MigrationWriter) generateIndexScripts(model *models.Schema, current *mo // Process primary keys first - check explicit constraints foundExplicitPK := false - for constraintName, constraint := range modelTable.Constraints { + for _, constraint := range sortConstraints(modelTable.Constraints) { + constraintName := constraint.Name if constraint.Type == models.PrimaryKeyConstraint { foundExplicitPK = true shouldCreate := true @@ -603,7 +607,8 @@ func (w *MigrationWriter) generateIndexScripts(model *models.Schema, current *mo } // Process indexes - for indexName, modelIndex := range modelTable.Indexes { + for _, modelIndex := range sortIndexes(modelTable.Indexes) { + indexName := modelIndex.Name // Skip primary key indexes if strings.HasPrefix(strings.ToLower(indexName), "pk_") { continue @@ -697,7 +702,8 @@ func (w *MigrationWriter) generateForeignKeyScripts(model *models.Schema, curren currentTable := currentTables[strings.ToLower(modelTable.Name)] // Process each constraint - for constraintName, constraint := range modelTable.Constraints { + for _, constraint := range sortConstraints(modelTable.Constraints) { + constraintName := constraint.Name if constraint.Type != models.ForeignKeyConstraint { continue } @@ -787,7 +793,7 @@ func (w *MigrationWriter) generateCommentScripts(model *models.Schema, current * } // Column comments - for _, col := range modelTable.Columns { + for _, col := range sortColumns(modelTable.Columns) { if col.Description != "" { sql, err := w.executor.ExecuteCommentColumn(CommentColumnData{ SchemaName: model.Name, diff --git a/pkg/writers/pgsql/templates.go b/pkg/writers/pgsql/templates.go index b299194..ca6739e 100644 --- a/pkg/writers/pgsql/templates.go +++ b/pkg/writers/pgsql/templates.go @@ -545,7 +545,7 @@ func BuildAuditFunctionData( // Build list of audited columns auditedColumns := make([]*models.Column, 0) - for _, col := range table.Columns { + for _, col := range sortColumns(table.Columns) { if col.Name == pk.Name { continue } diff --git a/pkg/writers/pgsql/writer.go b/pkg/writers/pgsql/writer.go index 51f1a93..110d10c 100644 --- a/pkg/writers/pgsql/writer.go +++ b/pkg/writers/pgsql/writer.go @@ -199,7 +199,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro for _, table := range schema.Tables { // First check for explicit PrimaryKeyConstraint var pkConstraint *models.Constraint - for _, constraint := range table.Constraints { + for _, constraint := range sortConstraints(table.Constraints) { if constraint.Type == models.PrimaryKeyConstraint { pkConstraint = constraint break @@ -255,7 +255,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro // Phase 5: Indexes for _, table := range schema.Tables { - for _, index := range table.Indexes { + for _, index := range sortIndexes(table.Indexes) { // Skip primary key indexes if strings.HasSuffix(index.Name, "_pkey") { continue @@ -298,7 +298,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro // Phase 5.5: Unique constraints for _, table := range schema.Tables { - for _, constraint := range table.Constraints { + for _, constraint := range sortConstraints(table.Constraints) { if constraint.Type != models.UniqueConstraint { continue } @@ -321,7 +321,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro // Phase 5.7: Check constraints for _, table := range schema.Tables { - for _, constraint := range table.Constraints { + for _, constraint := range sortConstraints(table.Constraints) { if constraint.Type != models.CheckConstraint { continue } @@ -344,7 +344,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro // Phase 6: Foreign keys for _, table := range schema.Tables { - for _, constraint := range table.Constraints { + for _, constraint := range sortConstraints(table.Constraints) { if constraint.Type != models.ForeignKeyConstraint { continue } @@ -394,7 +394,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro statements = append(statements, stmt) } - for _, column := range table.Columns { + for _, column := range sortColumns(table.Columns) { if column.Comment != "" { stmt := fmt.Sprintf("COMMENT ON COLUMN %s.%s IS '%s'", w.qualTable(schema.SQLName(), table.SQLName()), column.SQLName(), escapeQuote(column.Comment)) @@ -866,10 +866,9 @@ func (w *Writer) writePrimaryKeys(schema *models.Schema) error { for _, table := range schema.Tables { // Find primary key constraint var pkConstraint *models.Constraint - for name, constraint := range table.Constraints { + for _, constraint := range sortConstraints(table.Constraints) { if constraint.Type == models.PrimaryKeyConstraint { pkConstraint = constraint - _ = name // Use the name variable break } } @@ -1475,6 +1474,51 @@ func resolveIndexColumn(table *models.Table, colName string) (*models.Column, bo return nil, false } +// sortColumns returns columns sorted by Sequence then Name for deterministic output. +func sortColumns(columns map[string]*models.Column) []*models.Column { + result := make([]*models.Column, 0, len(columns)) + for _, col := range columns { + result = append(result, col) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Sequence > 0 && result[j].Sequence > 0 { + return result[i].Sequence < result[j].Sequence + } + return result[i].Name < result[j].Name + }) + return result +} + +// sortConstraints returns constraints sorted by Sequence then Name for deterministic output. +func sortConstraints(constraints map[string]*models.Constraint) []*models.Constraint { + result := make([]*models.Constraint, 0, len(constraints)) + for _, c := range constraints { + result = append(result, c) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Sequence > 0 && result[j].Sequence > 0 { + return result[i].Sequence < result[j].Sequence + } + return result[i].Name < result[j].Name + }) + return result +} + +// sortIndexes returns indexes sorted by Sequence then Name for deterministic output. +func sortIndexes(indexes map[string]*models.Index) []*models.Index { + result := make([]*models.Index, 0, len(indexes)) + for _, idx := range indexes { + result = append(result, idx) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Sequence > 0 && result[j].Sequence > 0 { + return result[i].Sequence < result[j].Sequence + } + return result[i].Name < result[j].Name + }) + return result +} + // formatStringList formats a list of strings as a SQL-safe comma-separated quoted list func formatStringList(items []string) string { quoted := make([]string, len(items)) diff --git a/pkg/writers/prisma/writer.go b/pkg/writers/prisma/writer.go index 1123e7d..1420c5e 100644 --- a/pkg/writers/prisma/writer.go +++ b/pkg/writers/prisma/writer.go @@ -549,14 +549,14 @@ func (w *Writer) generateBlockAttributes(table *models.Table) string { } // @@unique for multi-column unique constraints - for _, constraint := range table.Constraints { + for _, constraint := range sortConstraints(table.Constraints) { if constraint.Type == models.UniqueConstraint && len(constraint.Columns) > 1 { fmt.Fprintf(&sb, " @@unique([%s])\n", strings.Join(constraint.Columns, ", ")) } } // @@index for indexes - for _, index := range table.Indexes { + for _, index := range sortIndexes(table.Indexes) { if !index.Unique { // Unique indexes are handled by @@unique fmt.Fprintf(&sb, " @@index([%s])\n", strings.Join(index.Columns, ", ")) } @@ -564,3 +564,33 @@ func (w *Writer) generateBlockAttributes(table *models.Table) string { return sb.String() } + +// sortConstraints returns constraints sorted by Sequence then Name for deterministic output. +func sortConstraints(constraints map[string]*models.Constraint) []*models.Constraint { + result := make([]*models.Constraint, 0, len(constraints)) + for _, c := range constraints { + result = append(result, c) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Sequence > 0 && result[j].Sequence > 0 { + return result[i].Sequence < result[j].Sequence + } + return result[i].Name < result[j].Name + }) + return result +} + +// sortIndexes returns indexes sorted by Sequence then Name for deterministic output. +func sortIndexes(indexes map[string]*models.Index) []*models.Index { + result := make([]*models.Index, 0, len(indexes)) + for _, idx := range indexes { + result = append(result, idx) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Sequence > 0 && result[j].Sequence > 0 { + return result[i].Sequence < result[j].Sequence + } + return result[i].Name < result[j].Name + }) + return result +} diff --git a/pkg/writers/sqlite/templates.go b/pkg/writers/sqlite/templates.go index f1f850d..d22b2a9 100644 --- a/pkg/writers/sqlite/templates.go +++ b/pkg/writers/sqlite/templates.go @@ -4,6 +4,7 @@ import ( "bytes" "embed" "fmt" + "sort" "text/template" "git.warky.dev/wdevs/relspecgo/pkg/models" @@ -133,15 +134,11 @@ func (te *TemplateExecutor) ExecuteCreateForeignKey(data ConstraintTemplateData) // BuildTableTemplateData builds TableTemplateData from a models.Table func BuildTableTemplateData(schema string, table *models.Table) TableTemplateData { - // Get sorted columns - columns := make([]*models.Column, 0, len(table.Columns)) - for _, col := range table.Columns { - columns = append(columns, col) - } + columns := sortColumns(table.Columns) // Find primary key constraint var pk *models.Constraint - for _, constraint := range table.Constraints { + for _, constraint := range sortConstraints(table.Constraints) { if constraint.Type == models.PrimaryKeyConstraint { pk = constraint break @@ -151,7 +148,7 @@ func BuildTableTemplateData(schema string, table *models.Table) TableTemplateDat // If no explicit primary key constraint, build one from columns with IsPrimaryKey=true if pk == nil { pkCols := []string{} - for _, col := range table.Columns { + for _, col := range columns { if col.IsPrimaryKey { pkCols = append(pkCols, col.Name) } @@ -172,3 +169,48 @@ func BuildTableTemplateData(schema string, table *models.Table) TableTemplateDat PrimaryKey: pk, } } + +// sortColumns returns columns sorted by Sequence then Name for deterministic output. +func sortColumns(columns map[string]*models.Column) []*models.Column { + result := make([]*models.Column, 0, len(columns)) + for _, col := range columns { + result = append(result, col) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Sequence > 0 && result[j].Sequence > 0 { + return result[i].Sequence < result[j].Sequence + } + return result[i].Name < result[j].Name + }) + return result +} + +// sortConstraints returns constraints sorted by Sequence then Name for deterministic output. +func sortConstraints(constraints map[string]*models.Constraint) []*models.Constraint { + result := make([]*models.Constraint, 0, len(constraints)) + for _, c := range constraints { + result = append(result, c) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Sequence > 0 && result[j].Sequence > 0 { + return result[i].Sequence < result[j].Sequence + } + return result[i].Name < result[j].Name + }) + return result +} + +// sortIndexes returns indexes sorted by Sequence then Name for deterministic output. +func sortIndexes(indexes map[string]*models.Index) []*models.Index { + result := make([]*models.Index, 0, len(indexes)) + for _, idx := range indexes { + result = append(result, idx) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Sequence > 0 && result[j].Sequence > 0 { + return result[i].Sequence < result[j].Sequence + } + return result[i].Name < result[j].Name + }) + return result +} diff --git a/pkg/writers/sqlite/writer.go b/pkg/writers/sqlite/writer.go index 23cc76f..0e96e17 100644 --- a/pkg/writers/sqlite/writer.go +++ b/pkg/writers/sqlite/writer.go @@ -143,7 +143,7 @@ func (w *Writer) writeTable(schema string, table *models.Table) error { // writeIndexes writes indexes for a table func (w *Writer) writeIndexes(schema string, table *models.Table) error { - for _, index := range table.Indexes { + for _, index := range sortIndexes(table.Indexes) { // Skip primary key indexes if strings.HasSuffix(index.Name, "_pkey") { continue @@ -174,7 +174,7 @@ func (w *Writer) writeIndexes(schema string, table *models.Table) error { // writeUniqueConstraints writes unique constraints as unique indexes func (w *Writer) writeUniqueConstraints(schema string, table *models.Table) error { - for _, constraint := range table.Constraints { + for _, constraint := range sortConstraints(table.Constraints) { if constraint.Type != models.UniqueConstraint { continue } @@ -195,7 +195,7 @@ func (w *Writer) writeUniqueConstraints(schema string, table *models.Table) erro } // Also handle unique indexes from the Indexes map - for _, index := range table.Indexes { + for _, index := range sortIndexes(table.Indexes) { if !index.Unique { continue } @@ -232,7 +232,7 @@ func (w *Writer) writeUniqueConstraints(schema string, table *models.Table) erro // writeCheckConstraints writes check constraints as comments func (w *Writer) writeCheckConstraints(schema string, table *models.Table) error { - for _, constraint := range table.Constraints { + for _, constraint := range sortConstraints(table.Constraints) { if constraint.Type != models.CheckConstraint { continue } @@ -257,7 +257,7 @@ func (w *Writer) writeCheckConstraints(schema string, table *models.Table) error // writeForeignKeys writes foreign keys as comments func (w *Writer) writeForeignKeys(schema string, table *models.Table) error { - for _, constraint := range table.Constraints { + for _, constraint := range sortConstraints(table.Constraints) { if constraint.Type != models.ForeignKeyConstraint { continue } diff --git a/pkg/writers/typeorm/writer.go b/pkg/writers/typeorm/writer.go index 8af58d7..06df307 100644 --- a/pkg/writers/typeorm/writer.go +++ b/pkg/writers/typeorm/writer.go @@ -531,7 +531,13 @@ func (w *Writer) generateInverseRelations(table *models.Table, schema *models.Sc // generateManyToManyRelations generates @ManyToMany fields func (w *Writer) generateManyToManyRelations(table *models.Table, schema *models.Schema, joinTables map[string]bool, sb *strings.Builder) { - for joinTableName := range joinTables { + joinTableNames := make([]string, 0, len(joinTables)) + for name := range joinTables { + joinTableNames = append(joinTableNames, name) + } + sort.Strings(joinTableNames) + + for _, joinTableName := range joinTableNames { joinTable := w.findTable(joinTableName, schema) if joinTable == nil { continue