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:
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user