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