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.
This commit is contained in:
2026-08-10 20:54:40 +02:00
parent b95b74f0a3
commit 3b88c386a1
32 changed files with 733 additions and 100 deletions
+44 -16
View File
@@ -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)
}
+41
View File
@@ -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