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
+19 -3
View File
@@ -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))
+1 -1
View File
@@ -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
}
+37
View File
@@ -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
+49 -3
View File
@@ -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
}
+8 -1
View File
@@ -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 {
+49 -3
View File
@@ -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
}
+35 -3
View File
@@ -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 {
+19 -3
View File
@@ -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))
+2 -2
View File
@@ -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 {
+45
View File
@@ -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)
}
}
+2 -1
View File
@@ -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")
}
+14 -8
View File
@@ -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,
+1 -1
View File
@@ -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
}
+52 -8
View File
@@ -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))
+32 -2
View File
@@ -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
}
+49 -7
View File
@@ -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
}
+5 -5
View File
@@ -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
}
+7 -1
View File
@@ -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