Compare commits

..
2 Commits
Author SHA1 Message Date
warkanum d44945b475 chore(release): update package version to 1.0.66
Release / test (push) Successful in 57s
Release / release (push) Successful in 1m42s
Release / pkg-aur (push) Failing after 1m14s
Release / pkg-deb (push) Failing after 1m58s
Release / pkg-rpm (push) Successful in 9m49s
2026-08-10 20:54:56 +02:00
warkanum 3b88c386a1 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.
2026-08-10 20:54:40 +02:00
34 changed files with 735 additions and 102 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Hein (Warky Devs) <hein@warky.dev> # Maintainer: Hein (Warky Devs) <hein@warky.dev>
pkgname=relspec pkgname=relspec
pkgver=1.0.65 pkgver=1.0.66
pkgrel=1 pkgrel=1
pkgdesc="RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs." pkgdesc="RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs."
arch=('x86_64' 'aarch64') arch=('x86_64' 'aarch64')
+1 -1
View File
@@ -1,5 +1,5 @@
Name: relspec Name: relspec
Version: 1.0.65 Version: 1.0.66
Release: 1%{?dist} Release: 1%{?dist}
Summary: RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs. Summary: RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs.
+44 -16
View File
@@ -2,10 +2,22 @@ package diff
import ( import (
"reflect" "reflect"
"sort"
"git.warky.dev/wdevs/relspecgo/pkg/models" "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 // CompareDatabases compares two database models and returns the differences
func CompareDatabases(source, target *models.Database) *DiffResult { func CompareDatabases(source, target *models.Database) *DiffResult {
result := &DiffResult{ result := &DiffResult{
@@ -34,7 +46,8 @@ func compareSchemas(source, target []*models.Schema) *SchemaDiff {
} }
// Find missing and modified schemas // Find missing and modified schemas
for name, srcSchema := range sourceMap { for _, name := range sortedKeys(sourceMap) {
srcSchema := sourceMap[name]
if tgtSchema, exists := targetMap[name]; !exists { if tgtSchema, exists := targetMap[name]; !exists {
diff.Missing = append(diff.Missing, srcSchema) diff.Missing = append(diff.Missing, srcSchema)
} else { } else {
@@ -45,7 +58,8 @@ func compareSchemas(source, target []*models.Schema) *SchemaDiff {
} }
// Find extra schemas // Find extra schemas
for name, tgtSchema := range targetMap { for _, name := range sortedKeys(targetMap) {
tgtSchema := targetMap[name]
if _, exists := sourceMap[name]; !exists { if _, exists := sourceMap[name]; !exists {
diff.Extra = append(diff.Extra, tgtSchema) diff.Extra = append(diff.Extra, tgtSchema)
} }
@@ -106,7 +120,8 @@ func compareTables(source, target []*models.Table) *TableDiff {
} }
// Find missing and modified tables // Find missing and modified tables
for name, srcTable := range sourceMap { for _, name := range sortedKeys(sourceMap) {
srcTable := sourceMap[name]
if tgtTable, exists := targetMap[name]; !exists { if tgtTable, exists := targetMap[name]; !exists {
diff.Missing = append(diff.Missing, srcTable) diff.Missing = append(diff.Missing, srcTable)
} else { } else {
@@ -117,7 +132,8 @@ func compareTables(source, target []*models.Table) *TableDiff {
} }
// Find extra tables // Find extra tables
for name, tgtTable := range targetMap { for _, name := range sortedKeys(targetMap) {
tgtTable := targetMap[name]
if _, exists := sourceMap[name]; !exists { if _, exists := sourceMap[name]; !exists {
diff.Extra = append(diff.Extra, tgtTable) diff.Extra = append(diff.Extra, tgtTable)
} }
@@ -176,7 +192,8 @@ func compareColumns(source, target map[string]*models.Column) *ColumnDiff {
} }
// Find missing and modified columns // Find missing and modified columns
for name, srcCol := range source { for _, name := range sortedKeys(source) {
srcCol := source[name]
if tgtCol, exists := target[name]; !exists { if tgtCol, exists := target[name]; !exists {
diff.Missing = append(diff.Missing, srcCol) diff.Missing = append(diff.Missing, srcCol)
} else { } else {
@@ -192,7 +209,8 @@ func compareColumns(source, target map[string]*models.Column) *ColumnDiff {
} }
// Find extra columns // Find extra columns
for name, tgtCol := range target { for _, name := range sortedKeys(target) {
tgtCol := target[name]
if _, exists := source[name]; !exists { if _, exists := source[name]; !exists {
diff.Extra = append(diff.Extra, tgtCol) diff.Extra = append(diff.Extra, tgtCol)
} }
@@ -240,7 +258,8 @@ func compareIndexes(source, target map[string]*models.Index) *IndexDiff {
} }
// Find missing and modified indexes // Find missing and modified indexes
for name, srcIdx := range source { for _, name := range sortedKeys(source) {
srcIdx := source[name]
if tgtIdx, exists := target[name]; !exists { if tgtIdx, exists := target[name]; !exists {
diff.Missing = append(diff.Missing, srcIdx) diff.Missing = append(diff.Missing, srcIdx)
} else { } else {
@@ -256,7 +275,8 @@ func compareIndexes(source, target map[string]*models.Index) *IndexDiff {
} }
// Find extra indexes // Find extra indexes
for name, tgtIdx := range target { for _, name := range sortedKeys(target) {
tgtIdx := target[name]
if _, exists := source[name]; !exists { if _, exists := source[name]; !exists {
diff.Extra = append(diff.Extra, tgtIdx) diff.Extra = append(diff.Extra, tgtIdx)
} }
@@ -292,7 +312,8 @@ func compareConstraints(source, target map[string]*models.Constraint) *Constrain
} }
// Find missing and modified constraints // Find missing and modified constraints
for name, srcCon := range source { for _, name := range sortedKeys(source) {
srcCon := source[name]
if tgtCon, exists := target[name]; !exists { if tgtCon, exists := target[name]; !exists {
diff.Missing = append(diff.Missing, srcCon) diff.Missing = append(diff.Missing, srcCon)
} else { } else {
@@ -308,7 +329,8 @@ func compareConstraints(source, target map[string]*models.Constraint) *Constrain
} }
// Find extra constraints // Find extra constraints
for name, tgtCon := range target { for _, name := range sortedKeys(target) {
tgtCon := target[name]
if _, exists := source[name]; !exists { if _, exists := source[name]; !exists {
diff.Extra = append(diff.Extra, tgtCon) diff.Extra = append(diff.Extra, tgtCon)
} }
@@ -350,7 +372,8 @@ func compareRelationships(source, target map[string]*models.Relationship) *Relat
} }
// Find missing and modified relationships // Find missing and modified relationships
for name, srcRel := range source { for _, name := range sortedKeys(source) {
srcRel := source[name]
if tgtRel, exists := target[name]; !exists { if tgtRel, exists := target[name]; !exists {
diff.Missing = append(diff.Missing, srcRel) diff.Missing = append(diff.Missing, srcRel)
} else { } else {
@@ -366,7 +389,8 @@ func compareRelationships(source, target map[string]*models.Relationship) *Relat
} }
// Find extra relationships // Find extra relationships
for name, tgtRel := range target { for _, name := range sortedKeys(target) {
tgtRel := target[name]
if _, exists := source[name]; !exists { if _, exists := source[name]; !exists {
diff.Extra = append(diff.Extra, tgtRel) diff.Extra = append(diff.Extra, tgtRel)
} }
@@ -415,7 +439,8 @@ func compareViews(source, target []*models.View) *ViewDiff {
} }
// Find missing and modified views // Find missing and modified views
for name, srcView := range sourceMap { for _, name := range sortedKeys(sourceMap) {
srcView := sourceMap[name]
if tgtView, exists := targetMap[name]; !exists { if tgtView, exists := targetMap[name]; !exists {
diff.Missing = append(diff.Missing, srcView) diff.Missing = append(diff.Missing, srcView)
} else { } else {
@@ -431,7 +456,8 @@ func compareViews(source, target []*models.View) *ViewDiff {
} }
// Find extra views // Find extra views
for name, tgtView := range targetMap { for _, name := range sortedKeys(targetMap) {
tgtView := targetMap[name]
if _, exists := sourceMap[name]; !exists { if _, exists := sourceMap[name]; !exists {
diff.Extra = append(diff.Extra, tgtView) diff.Extra = append(diff.Extra, tgtView)
} }
@@ -468,7 +494,8 @@ func compareSequences(source, target []*models.Sequence) *SequenceDiff {
} }
// Find missing and modified sequences // Find missing and modified sequences
for name, srcSeq := range sourceMap { for _, name := range sortedKeys(sourceMap) {
srcSeq := sourceMap[name]
if tgtSeq, exists := targetMap[name]; !exists { if tgtSeq, exists := targetMap[name]; !exists {
diff.Missing = append(diff.Missing, srcSeq) diff.Missing = append(diff.Missing, srcSeq)
} else { } else {
@@ -484,7 +511,8 @@ func compareSequences(source, target []*models.Sequence) *SequenceDiff {
} }
// Find extra sequences // Find extra sequences
for name, tgtSeq := range targetMap { for _, name := range sortedKeys(targetMap) {
tgtSeq := targetMap[name]
if _, exists := sourceMap[name]; !exists { if _, exists := sourceMap[name]; !exists {
diff.Extra = append(diff.Extra, tgtSeq) diff.Extra = append(diff.Extra, tgtSeq)
} }
+41
View File
@@ -1,6 +1,7 @@
package diff package diff
import ( import (
"reflect"
"testing" "testing"
"git.warky.dev/wdevs/relspecgo/pkg/models" "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) { func TestCompareColumnDetails(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+10 -2
View File
@@ -2,6 +2,7 @@ package inspector
import ( import (
"fmt" "fmt"
"sort"
"time" "time"
"git.warky.dev/wdevs/relspecgo/pkg/models" "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) { func (i *Inspector) Inspect() (*InspectorReport, error) {
results := []ValidationResult{} results := []ValidationResult{}
// Run all enabled validators // Run all enabled validators in deterministic (alphabetical) rule-name order
for ruleName, rule := range i.config.Rules { 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() { if !rule.IsEnabled() {
continue continue
} }
+39
View File
@@ -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) { func TestInspectWithDisabledRules(t *testing.T) {
db := createTestDatabase() db := createTestDatabase()
config := GetDefaultConfig() config := GetDefaultConfig()
+9 -2
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"sort"
"strings" "strings"
"time" "time"
) )
@@ -199,12 +200,18 @@ func (f *MarkdownFormatter) formatContext(context map[string]interface{}) string
"column": true, "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] { if skipKeys[key] {
continue continue
} }
parts = append(parts, fmt.Sprintf("%s=%v", key, value)) parts = append(parts, fmt.Sprintf("%s=%v", key, context[key]))
} }
return strings.Join(parts, ", ") return strings.Join(parts, ", ")
+54 -12
View File
@@ -2,12 +2,54 @@ package inspector
import ( import (
"regexp" "regexp"
"sort"
"strings" "strings"
"git.warky.dev/wdevs/relspecgo/pkg/models" "git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/pgsql" "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 // validatePrimaryKeyNaming checks that primary key column names match a pattern
func validatePrimaryKeyNaming(db *models.Database, rule Rule, ruleName string) []ValidationResult { func validatePrimaryKeyNaming(db *models.Database, rule Rule, ruleName string) []ValidationResult {
results := []ValidationResult{} results := []ValidationResult{}
@@ -18,7 +60,7 @@ func validatePrimaryKeyNaming(db *models.Database, rule Rule, ruleName string) [
for _, schema := range db.Schemas { for _, schema := range db.Schemas {
for _, table := range schema.Tables { for _, table := range schema.Tables {
for _, col := range table.Columns { for _, col := range sortColumns(table.Columns) {
if col.IsPrimaryKey { if col.IsPrimaryKey {
location := formatLocation(schema.Name, table.Name, col.Name) location := formatLocation(schema.Name, table.Name, col.Name)
passed := pattern.MatchString(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 _, schema := range db.Schemas {
for _, table := range schema.Tables { for _, table := range schema.Tables {
for _, col := range table.Columns { for _, col := range sortColumns(table.Columns) {
if col.IsPrimaryKey { if col.IsPrimaryKey {
location := formatLocation(schema.Name, table.Name, col.Name) 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 _, schema := range db.Schemas {
for _, table := range schema.Tables { for _, table := range schema.Tables {
for _, col := range table.Columns { for _, col := range sortColumns(table.Columns) {
if col.IsPrimaryKey { if col.IsPrimaryKey {
location := formatLocation(schema.Name, table.Name, col.Name) 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 _, schema := range db.Schemas {
for _, table := range schema.Tables { for _, table := range schema.Tables {
// Check foreign key constraints // Check foreign key constraints
for _, constraint := range table.Constraints { for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.ForeignKeyConstraint { if constraint.Type == models.ForeignKeyConstraint {
for _, colName := range constraint.Columns { for _, colName := range constraint.Columns {
location := formatLocation(schema.Name, table.Name, colName) 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 _, schema := range db.Schemas {
for _, table := range schema.Tables { for _, table := range schema.Tables {
for _, constraint := range table.Constraints { for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.ForeignKeyConstraint { if constraint.Type == models.ForeignKeyConstraint {
location := formatLocation(schema.Name, table.Name, "") location := formatLocation(schema.Name, table.Name, "")
passed := pattern.MatchString(constraint.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 // Check if each FK column has an index
for fkCol := range fkColumns { for _, fkCol := range sortedKeys(fkColumns) {
hasIndex := false hasIndex := false
// Check table indexes // Check table indexes
@@ -282,7 +324,7 @@ func validateColumnNamingCase(db *models.Database, rule Rule, ruleName string) [
for _, schema := range db.Schemas { for _, schema := range db.Schemas {
for _, table := range schema.Tables { for _, table := range schema.Tables {
for _, col := range table.Columns { for _, col := range sortColumns(table.Columns) {
location := formatLocation(schema.Name, table.Name, col.Name) location := formatLocation(schema.Name, table.Name, col.Name)
passed := pattern.MatchString(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 _, schema := range db.Schemas {
for _, table := range schema.Tables { for _, table := range schema.Tables {
for _, col := range table.Columns { for _, col := range sortColumns(table.Columns) {
location := formatLocation(schema.Name, table.Name, col.Name) location := formatLocation(schema.Name, table.Name, col.Name)
passed := len(col.Name) <= rule.MaxLength passed := len(col.Name) <= rule.MaxLength
@@ -396,7 +438,7 @@ func validateReservedKeywords(db *models.Database, rule Rule, ruleName string) [
// Check column names // Check column names
if rule.CheckColumns { if rule.CheckColumns {
for _, col := range table.Columns { for _, col := range sortColumns(table.Columns) {
location := formatLocation(schema.Name, table.Name, col.Name) location := formatLocation(schema.Name, table.Name, col.Name)
passed := !keywords[strings.ToUpper(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 // Check all foreign key constraints
for _, schema := range db.Schemas { for _, schema := range db.Schemas {
for _, table := range schema.Tables { for _, table := range schema.Tables {
for _, constraint := range table.Constraints { for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.ForeignKeyConstraint { if constraint.Type == models.ForeignKeyConstraint {
// Build referenced table key // Build referenced table key
refSchema := constraint.ReferencedSchema refSchema := constraint.ReferencedSchema
@@ -522,7 +564,7 @@ func validateCircularDependency(db *models.Database, rule Rule, ruleName string)
for _, table := range schema.Tables { for _, table := range schema.Tables {
tableKey := schema.Name + "." + table.Name tableKey := schema.Name + "." + table.Name
for _, constraint := range table.Constraints { for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.ForeignKeyConstraint { if constraint.Type == models.ForeignKeyConstraint {
refSchema := constraint.ReferencedSchema refSchema := constraint.ReferencedSchema
if refSchema == "" { if refSchema == "" {
@@ -537,7 +579,7 @@ func validateCircularDependency(db *models.Database, rule Rule, ruleName string)
} }
// Check for cycles using DFS // Check for cycles using DFS
for tableKey := range dependencies { for _, tableKey := range sortedKeys(dependencies) {
visited := make(map[string]bool) visited := make(map[string]bool)
recStack := make(map[string]bool) recStack := make(map[string]bool)
+12 -2
View File
@@ -5,6 +5,7 @@ package merge
import ( import (
"fmt" "fmt"
"sort"
"strconv" "strconv"
"strings" "strings"
@@ -156,8 +157,17 @@ func (r *MergeResult) mergeColumns(table *models.Table, srcTable *models.Table)
existingColumns[colName] = table.Columns[colName] existingColumns[colName] = table.Columns[colName]
} }
// Merge columns // Merge columns in deterministic (alphabetical) order so that, when a
for colName, srcCol := range srcTable.Columns { // 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 { if tgtCol, exists := existingColumns[colName]; !exists {
// Column doesn't exist, add it // Column doesn't exist, add it
newCol := cloneColumn(srcCol) newCol := cloneColumn(srcCol)
+23 -1
View File
@@ -1,6 +1,9 @@
package models package models
import "fmt" import (
"fmt"
"sort"
)
// Flat/Denormalized Views // 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 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 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 return flatRelationships
} }
+24 -4
View File
@@ -5,6 +5,7 @@
package models package models
import ( import (
"sort"
"strings" "strings"
"time" "time"
@@ -141,15 +142,28 @@ func (d *Table) SQLName() string {
// GetPrimaryKey returns the primary key column for the table, or nil if none exists. // GetPrimaryKey returns the primary key column for the table, or nil if none exists.
func (m Table) GetPrimaryKey() *Column { func (m Table) GetPrimaryKey() *Column {
var pk *Column
for _, column := range m.Columns { for _, column := range m.Columns {
if column.IsPrimaryKey { if !column.IsPrimaryKey {
return column 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 { func (m Table) GetForeignKeys() []*Constraint {
keys := make([]*Constraint, 0) keys := make([]*Constraint, 0)
@@ -158,6 +172,12 @@ func (m Table) GetForeignKeys() []*Constraint {
keys = append(keys, c) 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 return keys
} }
+7
View File
@@ -4,6 +4,7 @@ import (
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"os" "os"
"sort"
"strings" "strings"
"git.warky.dev/wdevs/relspecgo/pkg/models" "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 len(columns) == 0 {
if dctxKey.Primary { if dctxKey.Primary {
// Look for common primary key column patterns // Look for common primary key column patterns
colNames := make([]string, 0, len(table.Columns))
for colName := range table.Columns { for colName := range table.Columns {
colNames = append(colNames, colName)
}
sort.Strings(colNames)
for _, colName := range colNames {
colNameLower := strings.ToLower(colName) colNameLower := strings.ToLower(colName)
if strings.HasPrefix(colNameLower, "rid_") || strings.HasSuffix(colNameLower, "id") { if strings.HasPrefix(colNameLower, "rid_") || strings.HasSuffix(colNameLower, "id") {
columns = append(columns, colName) columns = append(columns, colName)
+18 -4
View File
@@ -820,17 +820,31 @@ func (r *Reader) createImplicitJoinTable(model1, model2 string, tableMap map[str
tableMap[joinTableName] = joinTable 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 { func (r *Reader) getPrimaryKeyColumn(table *models.Table) *models.Column {
if table == nil { if table == nil {
return nil return nil
} }
var pk *models.Column
for _, col := range table.Columns { for _, col := range table.Columns {
if col.IsPrimaryKey { if !col.IsPrimaryKey {
return col 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
} }
+18 -4
View File
@@ -806,17 +806,31 @@ func (r *Reader) createManyToManyJoinTable(entity1, entity2 string, tableMap map
tableMap[joinTableName] = joinTable 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 { func (r *Reader) getPrimaryKeyColumn(table *models.Table) *models.Column {
if table == nil { if table == nil {
return nil return nil
} }
var pk *models.Column
for _, col := range table.Columns { for _, col := range table.Columns {
if col.IsPrimaryKey { if !col.IsPrimaryKey {
return col 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
} }
+2
View File
@@ -2,6 +2,7 @@ package ui
import ( import (
"fmt" "fmt"
"sort"
"github.com/rivo/tview" "github.com/rivo/tview"
@@ -69,5 +70,6 @@ func getColumnNames(table *models.Table) []string {
for name := range table.Columns { for name := range table.Columns {
names = append(names, name) names = append(names, name)
} }
sort.Strings(names)
return names return names
} }
+6 -1
View File
@@ -1,6 +1,10 @@
package ui 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 // 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 { for name := range table.Relationships {
names = append(names, name) names = append(names, name)
} }
sort.Strings(names)
return names return names
} }
+19 -3
View File
@@ -220,8 +220,11 @@ func NewModelData(table *models.Table, schema string, typeMapper *TypeMapper, fl
Prefix: GeneratePrefix(table.Name), Prefix: GeneratePrefix(table.Name),
} }
// Convert columns to fields (sorted by sequence or name)
columns := sortColumns(table.Columns)
// Find primary key // Find primary key
for _, col := range table.Columns { for _, col := range columns {
if col.IsPrimaryKey { if col.IsPrimaryKey {
// Sanitize column name to remove backticks // Sanitize column name to remove backticks
safeName := writers.SanitizeStructTagValue(col.Name) 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 { for _, col := range columns {
field := columnToField(col, table, typeMapper) field := columnToField(col, table, typeMapper)
// Check for name collision with generated methods and rename if needed // 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 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 // sortColumns sorts columns by sequence, then by name
func sortColumns(columns map[string]*models.Column) []*models.Column { func sortColumns(columns map[string]*models.Column) []*models.Column {
result := make([]*models.Column, 0, len(columns)) 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) // Check for indexes (unique indexes should be added to tag)
if table != nil { if table != nil {
for _, index := range table.Indexes { for _, index := range sortIndexes(table.Indexes) {
if !index.Unique { if !index.Unique {
continue 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 // TestTypeMapper_BuildBunTag_ArraysAreNativeInEveryMode verifies that array
// columns always use a plain "text[]"-style type and the native Go slice // columns always use a plain "text[]"-style type and the native Go slice
// type plus an explicit "array" tag, regardless of NullableTypes style // type plus an explicit "array" tag, regardless of NullableTypes style
+49 -3
View File
@@ -3,6 +3,7 @@ package dbml
import ( import (
"fmt" "fmt"
"os" "os"
"sort"
"strings" "strings"
"git.warky.dev/wdevs/relspecgo/pkg/models" "git.warky.dev/wdevs/relspecgo/pkg/models"
@@ -78,7 +79,7 @@ func (w *Writer) databaseToDBML(d *models.Database) string {
sb.WriteString("\n// Relationships\n") sb.WriteString("\n// Relationships\n")
for _, schema := range d.Schemas { for _, schema := range d.Schemas {
for _, table := range schema.Tables { for _, table := range schema.Tables {
for _, constraint := range table.Constraints { for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.ForeignKeyConstraint { if constraint.Type == models.ForeignKeyConstraint {
sb.WriteString(w.constraintToDBML(constraint, table)) 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) tableName := fmt.Sprintf("%s.%s", t.Schema, t.Name)
fmt.Fprintf(&sb, "Table %s {\n", tableName) 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) fmt.Fprintf(&sb, " %s %s", column.Name, column.Type)
var attrs []string var attrs []string
@@ -149,7 +150,7 @@ func (w *Writer) tableToDBML(t *models.Table) string {
if len(t.Indexes) > 0 { if len(t.Indexes) > 0 {
sb.WriteString("\n indexes {\n") sb.WriteString("\n indexes {\n")
for _, index := range t.Indexes { for _, index := range sortIndexes(t.Indexes) {
var indexAttrs []string var indexAttrs []string
if index.Unique { if index.Unique {
indexAttrs = append(indexAttrs, "unique") indexAttrs = append(indexAttrs, "unique")
@@ -230,3 +231,48 @@ func (w *Writer) constraintToDBML(c *models.Constraint, t *models.Table) string
return refLine + "\n" 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 // Add table-level relationships
for _, table := range tableSlice { 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) // Check if this relationship is already in the list (avoid duplicates)
isDuplicate := false isDuplicate := false
for _, existing := range allRelations { for _, existing := range allRelations {
+49 -3
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"sort"
"git.warky.dev/wdevs/relspecgo/pkg/models" "git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/writers" "git.warky.dev/wdevs/relspecgo/pkg/writers"
@@ -175,7 +176,7 @@ func (w *Writer) databaseToDrawDB(d *models.Database) *DrawDBSchema {
// Add relationships // Add relationships
for _, schemaModel := range d.Schemas { for _, schemaModel := range d.Schemas {
for _, table := range schemaModel.Tables { for _, table := range schemaModel.Tables {
for _, constraint := range table.Constraints { for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.ForeignKeyConstraint && constraint.ReferencedTable != "" { if constraint.Type == models.ForeignKeyConstraint && constraint.ReferencedTable != "" {
startTableKey := fmt.Sprintf("%s.%s", schemaModel.Name, table.Name) startTableKey := fmt.Sprintf("%s.%s", schemaModel.Name, table.Name)
endTableKey := fmt.Sprintf("%s.%s", constraint.ReferencedSchema, constraint.ReferencedTable) 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 // Add fields
for _, column := range table.Columns { for _, column := range sortColumns(table.Columns) {
field := &DrawDBField{ field := &DrawDBField{
ID: fieldID, ID: fieldID,
Name: column.Name, Name: column.Name,
@@ -339,7 +340,7 @@ func (w *Writer) convertTableToDrawDB(table *models.Table, schemaName string, ta
// Add indexes // Add indexes
indexID := 0 indexID := 0
for _, index := range table.Indexes { for _, index := range sortIndexes(table.Indexes) {
drawIndex := &DrawDBIndex{ drawIndex := &DrawDBIndex{
ID: indexID, ID: indexID,
Name: index.Name, Name: index.Name,
@@ -393,3 +394,48 @@ func getColorForIndex(index int) string {
} }
return colors[index%len(colors)] 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" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"git.warky.dev/wdevs/relspecgo/pkg/models" "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) indexColumnFields := make(map[string]bool)
// Add indexes (excluding single-column unique indexes, which are handled inline) // 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) // Skip single-column unique indexes (handled by .unique() modifier)
if index.Unique && len(index.Columns) == 1 { if index.Unique && len(index.Columns) == 1 {
continue continue
@@ -270,7 +271,7 @@ func (w *Writer) buildTableData(table *models.Table, schema *models.Schema, db *
} }
// Add multi-column unique constraints as unique indexes // 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 { if constraint.Type == models.UniqueConstraint && len(constraint.Columns) > 1 {
// Create a unique index for this constraint // Create a unique index for this constraint
indexData := &IndexData{ indexData := &IndexData{
@@ -316,6 +317,36 @@ func (w *Writer) buildTableData(table *models.Table, schema *models.Schema, db *
return tableData 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 // sortStrings sorts a slice of strings in place
func sortStrings(strs []string) { func sortStrings(strs []string) {
for i := 0; i < len(strs); i++ { 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) enumNames := make([]string, 0)
seen := make(map[string]bool) 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)] { if enumMap[col.Type] || enumMap[strings.ToLower(col.Type)] {
// Find the enum in schema // Find the enum in schema
for _, enum := range schema.Enums { 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), Prefix: GeneratePrefix(table.Name),
} }
// Convert columns to fields (sorted by sequence or name)
columns := sortColumns(table.Columns)
// Find primary key // Find primary key
for _, col := range table.Columns { for _, col := range columns {
if col.IsPrimaryKey { if col.IsPrimaryKey {
// Sanitize column name to remove backticks // Sanitize column name to remove backticks
safeName := writers.SanitizeStructTagValue(col.Name) 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 { for _, col := range columns {
field := columnToField(col, table, typeMapper) field := columnToField(col, table, typeMapper)
// Check for name collision with generated methods and rename if needed // 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 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 // sortColumns sorts columns by sequence, then by name
func sortColumns(columns map[string]*models.Column) []*models.Column { func sortColumns(columns map[string]*models.Column) []*models.Column {
result := make([]*models.Column, 0, len(columns)) 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 // Check for unique constraint
if table != nil { if table != nil {
for _, constraint := range table.Constraints { for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.UniqueConstraint { if constraint.Type == models.UniqueConstraint {
for _, col := range constraint.Columns { for _, col := range constraint.Columns {
if col == column.Name { if col == column.Name {
@@ -431,7 +431,7 @@ func (tm *TypeMapper) BuildGormTag(column *models.Column, table *models.Table) s
} }
// Check for index // Check for index
for _, index := range table.Indexes { for _, index := range sortIndexes(table.Indexes) {
for _, col := range index.Columns { for _, col := range index.Columns {
if col == column.Name { if col == column.Name {
if index.Unique { 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) 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 // Add relation fields
relationFields = w.generateRelationFields(table, db, schema) 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 { for _, field := range idFields {
sb.WriteString(field + "\n") 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 // 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] modelConstraint, existsInModel := modelTable.Constraints[constraintName]
shouldDrop := false shouldDrop := false
@@ -252,7 +253,8 @@ func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *mod
if shouldDrop && currentConstraint.Type == models.PrimaryKeyConstraint { if shouldDrop && currentConstraint.Type == models.PrimaryKeyConstraint {
// Drop FK constraints that depend on this PK before dropping the PK itself. // Drop FK constraints that depend on this PK before dropping the PK itself.
for _, otherTable := range current.Tables { 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 { if fkConstraint.Type != models.ForeignKeyConstraint {
continue continue
} }
@@ -310,7 +312,8 @@ func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *mod
} }
// Check indexes // Check indexes
for indexName, currentIndex := range currentTable.Indexes { for _, currentIndex := range sortIndexes(currentTable.Indexes) {
indexName := currentIndex.Name
modelIndex, existsInModel := modelTable.Indexes[indexName] modelIndex, existsInModel := modelTable.Indexes[indexName]
shouldDrop := false shouldDrop := false
@@ -401,7 +404,7 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
} }
// Check each model column // Check each model column
for _, modelCol := range modelTable.Columns { for _, modelCol := range sortColumns(modelTable.Columns) {
currentCol, exists := currentColumns[strings.ToLower(modelCol.Name)] currentCol, exists := currentColumns[strings.ToLower(modelCol.Name)]
if !exists { if !exists {
@@ -518,7 +521,8 @@ func (w *MigrationWriter) generateIndexScripts(model *models.Schema, current *mo
// Process primary keys first - check explicit constraints // Process primary keys first - check explicit constraints
foundExplicitPK := false foundExplicitPK := false
for constraintName, constraint := range modelTable.Constraints { for _, constraint := range sortConstraints(modelTable.Constraints) {
constraintName := constraint.Name
if constraint.Type == models.PrimaryKeyConstraint { if constraint.Type == models.PrimaryKeyConstraint {
foundExplicitPK = true foundExplicitPK = true
shouldCreate := true shouldCreate := true
@@ -603,7 +607,8 @@ func (w *MigrationWriter) generateIndexScripts(model *models.Schema, current *mo
} }
// Process indexes // Process indexes
for indexName, modelIndex := range modelTable.Indexes { for _, modelIndex := range sortIndexes(modelTable.Indexes) {
indexName := modelIndex.Name
// Skip primary key indexes // Skip primary key indexes
if strings.HasPrefix(strings.ToLower(indexName), "pk_") { if strings.HasPrefix(strings.ToLower(indexName), "pk_") {
continue continue
@@ -697,7 +702,8 @@ func (w *MigrationWriter) generateForeignKeyScripts(model *models.Schema, curren
currentTable := currentTables[strings.ToLower(modelTable.Name)] currentTable := currentTables[strings.ToLower(modelTable.Name)]
// Process each constraint // Process each constraint
for constraintName, constraint := range modelTable.Constraints { for _, constraint := range sortConstraints(modelTable.Constraints) {
constraintName := constraint.Name
if constraint.Type != models.ForeignKeyConstraint { if constraint.Type != models.ForeignKeyConstraint {
continue continue
} }
@@ -787,7 +793,7 @@ func (w *MigrationWriter) generateCommentScripts(model *models.Schema, current *
} }
// Column comments // Column comments
for _, col := range modelTable.Columns { for _, col := range sortColumns(modelTable.Columns) {
if col.Description != "" { if col.Description != "" {
sql, err := w.executor.ExecuteCommentColumn(CommentColumnData{ sql, err := w.executor.ExecuteCommentColumn(CommentColumnData{
SchemaName: model.Name, SchemaName: model.Name,
+1 -1
View File
@@ -545,7 +545,7 @@ func BuildAuditFunctionData(
// Build list of audited columns // Build list of audited columns
auditedColumns := make([]*models.Column, 0) auditedColumns := make([]*models.Column, 0)
for _, col := range table.Columns { for _, col := range sortColumns(table.Columns) {
if col.Name == pk.Name { if col.Name == pk.Name {
continue continue
} }
+52 -8
View File
@@ -199,7 +199,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
for _, table := range schema.Tables { for _, table := range schema.Tables {
// First check for explicit PrimaryKeyConstraint // First check for explicit PrimaryKeyConstraint
var pkConstraint *models.Constraint var pkConstraint *models.Constraint
for _, constraint := range table.Constraints { for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.PrimaryKeyConstraint { if constraint.Type == models.PrimaryKeyConstraint {
pkConstraint = constraint pkConstraint = constraint
break break
@@ -255,7 +255,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
// Phase 5: Indexes // Phase 5: Indexes
for _, table := range schema.Tables { for _, table := range schema.Tables {
for _, index := range table.Indexes { for _, index := range sortIndexes(table.Indexes) {
// Skip primary key indexes // Skip primary key indexes
if strings.HasSuffix(index.Name, "_pkey") { if strings.HasSuffix(index.Name, "_pkey") {
continue continue
@@ -298,7 +298,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
// Phase 5.5: Unique constraints // Phase 5.5: Unique constraints
for _, table := range schema.Tables { for _, table := range schema.Tables {
for _, constraint := range table.Constraints { for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type != models.UniqueConstraint { if constraint.Type != models.UniqueConstraint {
continue continue
} }
@@ -321,7 +321,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
// Phase 5.7: Check constraints // Phase 5.7: Check constraints
for _, table := range schema.Tables { for _, table := range schema.Tables {
for _, constraint := range table.Constraints { for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type != models.CheckConstraint { if constraint.Type != models.CheckConstraint {
continue continue
} }
@@ -344,7 +344,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
// Phase 6: Foreign keys // Phase 6: Foreign keys
for _, table := range schema.Tables { for _, table := range schema.Tables {
for _, constraint := range table.Constraints { for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type != models.ForeignKeyConstraint { if constraint.Type != models.ForeignKeyConstraint {
continue continue
} }
@@ -394,7 +394,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
statements = append(statements, stmt) statements = append(statements, stmt)
} }
for _, column := range table.Columns { for _, column := range sortColumns(table.Columns) {
if column.Comment != "" { if column.Comment != "" {
stmt := fmt.Sprintf("COMMENT ON COLUMN %s.%s IS '%s'", stmt := fmt.Sprintf("COMMENT ON COLUMN %s.%s IS '%s'",
w.qualTable(schema.SQLName(), table.SQLName()), column.SQLName(), escapeQuote(column.Comment)) 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 { for _, table := range schema.Tables {
// Find primary key constraint // Find primary key constraint
var pkConstraint *models.Constraint var pkConstraint *models.Constraint
for name, constraint := range table.Constraints { for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.PrimaryKeyConstraint { if constraint.Type == models.PrimaryKeyConstraint {
pkConstraint = constraint pkConstraint = constraint
_ = name // Use the name variable
break break
} }
} }
@@ -1475,6 +1474,51 @@ func resolveIndexColumn(table *models.Table, colName string) (*models.Column, bo
return nil, false 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 // formatStringList formats a list of strings as a SQL-safe comma-separated quoted list
func formatStringList(items []string) string { func formatStringList(items []string) string {
quoted := make([]string, len(items)) 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 // @@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 { if constraint.Type == models.UniqueConstraint && len(constraint.Columns) > 1 {
fmt.Fprintf(&sb, " @@unique([%s])\n", strings.Join(constraint.Columns, ", ")) fmt.Fprintf(&sb, " @@unique([%s])\n", strings.Join(constraint.Columns, ", "))
} }
} }
// @@index for indexes // @@index for indexes
for _, index := range table.Indexes { for _, index := range sortIndexes(table.Indexes) {
if !index.Unique { // Unique indexes are handled by @@unique if !index.Unique { // Unique indexes are handled by @@unique
fmt.Fprintf(&sb, " @@index([%s])\n", strings.Join(index.Columns, ", ")) 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() 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" "bytes"
"embed" "embed"
"fmt" "fmt"
"sort"
"text/template" "text/template"
"git.warky.dev/wdevs/relspecgo/pkg/models" "git.warky.dev/wdevs/relspecgo/pkg/models"
@@ -133,15 +134,11 @@ func (te *TemplateExecutor) ExecuteCreateForeignKey(data ConstraintTemplateData)
// BuildTableTemplateData builds TableTemplateData from a models.Table // BuildTableTemplateData builds TableTemplateData from a models.Table
func BuildTableTemplateData(schema string, table *models.Table) TableTemplateData { func BuildTableTemplateData(schema string, table *models.Table) TableTemplateData {
// Get sorted columns columns := sortColumns(table.Columns)
columns := make([]*models.Column, 0, len(table.Columns))
for _, col := range table.Columns {
columns = append(columns, col)
}
// Find primary key constraint // Find primary key constraint
var pk *models.Constraint var pk *models.Constraint
for _, constraint := range table.Constraints { for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.PrimaryKeyConstraint { if constraint.Type == models.PrimaryKeyConstraint {
pk = constraint pk = constraint
break 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 no explicit primary key constraint, build one from columns with IsPrimaryKey=true
if pk == nil { if pk == nil {
pkCols := []string{} pkCols := []string{}
for _, col := range table.Columns { for _, col := range columns {
if col.IsPrimaryKey { if col.IsPrimaryKey {
pkCols = append(pkCols, col.Name) pkCols = append(pkCols, col.Name)
} }
@@ -172,3 +169,48 @@ func BuildTableTemplateData(schema string, table *models.Table) TableTemplateDat
PrimaryKey: pk, 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 // writeIndexes writes indexes for a table
func (w *Writer) writeIndexes(schema string, table *models.Table) error { 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 // Skip primary key indexes
if strings.HasSuffix(index.Name, "_pkey") { if strings.HasSuffix(index.Name, "_pkey") {
continue continue
@@ -174,7 +174,7 @@ func (w *Writer) writeIndexes(schema string, table *models.Table) error {
// writeUniqueConstraints writes unique constraints as unique indexes // writeUniqueConstraints writes unique constraints as unique indexes
func (w *Writer) writeUniqueConstraints(schema string, table *models.Table) error { 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 { if constraint.Type != models.UniqueConstraint {
continue continue
} }
@@ -195,7 +195,7 @@ func (w *Writer) writeUniqueConstraints(schema string, table *models.Table) erro
} }
// Also handle unique indexes from the Indexes map // Also handle unique indexes from the Indexes map
for _, index := range table.Indexes { for _, index := range sortIndexes(table.Indexes) {
if !index.Unique { if !index.Unique {
continue continue
} }
@@ -232,7 +232,7 @@ func (w *Writer) writeUniqueConstraints(schema string, table *models.Table) erro
// writeCheckConstraints writes check constraints as comments // writeCheckConstraints writes check constraints as comments
func (w *Writer) writeCheckConstraints(schema string, table *models.Table) error { 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 { if constraint.Type != models.CheckConstraint {
continue continue
} }
@@ -257,7 +257,7 @@ func (w *Writer) writeCheckConstraints(schema string, table *models.Table) error
// writeForeignKeys writes foreign keys as comments // writeForeignKeys writes foreign keys as comments
func (w *Writer) writeForeignKeys(schema string, table *models.Table) error { 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 { if constraint.Type != models.ForeignKeyConstraint {
continue continue
} }
+7 -1
View File
@@ -531,7 +531,13 @@ func (w *Writer) generateInverseRelations(table *models.Table, schema *models.Sc
// generateManyToManyRelations generates @ManyToMany fields // generateManyToManyRelations generates @ManyToMany fields
func (w *Writer) generateManyToManyRelations(table *models.Table, schema *models.Schema, joinTables map[string]bool, sb *strings.Builder) { 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) joinTable := w.findTable(joinTableName, schema)
if joinTable == nil { if joinTable == nil {
continue continue