Files
relspecgo/pkg/writers/drawdb/writer.go
T
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

442 lines
12 KiB
Go

package drawdb
import (
"encoding/json"
"fmt"
"os"
"sort"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
)
// Writer implements the writers.Writer interface for DrawDB JSON format
type Writer struct {
options *writers.WriterOptions
}
// NewWriter creates a new DrawDB writer with the given options
func NewWriter(options *writers.WriterOptions) *Writer {
return &Writer{
options: options,
}
}
// WriteDatabase writes a Database model to DrawDB JSON format
func (w *Writer) WriteDatabase(db *models.Database) error {
schema := w.databaseToDrawDB(db)
return w.writeJSON(schema)
}
// WriteSchema writes a Schema model to DrawDB JSON format
func (w *Writer) WriteSchema(schema *models.Schema) error {
drawSchema := w.schemaToDrawDB(schema)
return w.writeJSON(drawSchema)
}
// WriteTable writes a Table model to DrawDB JSON format
func (w *Writer) WriteTable(table *models.Table) error {
drawSchema := w.tableToDrawDB(table)
return w.writeJSON(drawSchema)
}
// writeJSON marshals the data to JSON and writes to output
func (w *Writer) writeJSON(data interface{}) error {
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal to JSON: %w", err)
}
if w.options.OutputPath != "" {
return os.WriteFile(w.options.OutputPath, jsonData, 0644)
}
// If no output path, print to stdout
fmt.Println(string(jsonData))
return nil
}
// databaseToDrawDB converts a Database to DrawDB JSON format
func (w *Writer) databaseToDrawDB(d *models.Database) *DrawDBSchema {
schema := &DrawDBSchema{
Tables: make([]*DrawDBTable, 0),
Relationships: make([]*DrawDBRelationship, 0),
Notes: make([]*DrawDBNote, 0),
SubjectAreas: make([]*DrawDBArea, 0),
}
// Track IDs and mappings
tableID := 0
fieldID := 0
relationshipID := 0
noteID := 0
areaID := 0
// Map to track table name to ID
tableMap := make(map[string]int)
// Map to track field full path to ID
fieldMap := make(map[string]int)
// Position tables in a grid layout
gridX, gridY := 50, 50
colWidth, rowHeight := 300, 200
tablesPerRow := 4
tableIndex := 0
// Create subject areas for schemas
for schemaIdx, schemaModel := range d.Schemas {
if schemaModel.Description != "" || schemaModel.Comment != "" {
note := schemaModel.Description
if note != "" && schemaModel.Comment != "" {
note += "\n"
}
note += schemaModel.Comment
_ = note // TODO: Add note/description field to DrawDBArea when supported
area := &DrawDBArea{
ID: areaID,
Name: schemaModel.Name,
Color: getColorForIndex(schemaIdx),
X: gridX - 20,
Y: gridY - 20,
Width: colWidth*tablesPerRow + 100,
Height: rowHeight*((len(schemaModel.Tables)/tablesPerRow)+1) + 100,
}
schema.SubjectAreas = append(schema.SubjectAreas, area)
areaID++
}
// Process tables in schema
for _, table := range schemaModel.Tables {
drawTable, newFieldID := w.convertTableToDrawDB(table, schemaModel.Name, tableID, fieldID, tableIndex, tablesPerRow, gridX, gridY, colWidth, rowHeight, schemaIdx)
// Store table mapping
tableKey := fmt.Sprintf("%s.%s", schemaModel.Name, table.Name)
tableMap[tableKey] = tableID
// Store field mappings
for _, field := range drawTable.Fields {
fieldKey := fmt.Sprintf("%s.%s.%s", schemaModel.Name, table.Name, field.Name)
fieldMap[fieldKey] = field.ID
}
schema.Tables = append(schema.Tables, drawTable)
fieldID = newFieldID
tableID++
tableIndex++
}
}
// Create subject areas for domains
for domainIdx, domainModel := range d.Domains {
// Calculate bounds for all tables in this domain
minX, minY := 999999, 999999
maxX, maxY := 0, 0
domainTableCount := 0
for _, domainTable := range domainModel.Tables {
// Find the table in the schema to get its position
for _, t := range schema.Tables {
if t.Name == domainTable.TableName {
if t.X < minX {
minX = t.X
}
if t.Y < minY {
minY = t.Y
}
if t.X+colWidth > maxX {
maxX = t.X + colWidth
}
if t.Y+rowHeight > maxY {
maxY = t.Y + rowHeight
}
domainTableCount++
break
}
}
}
// Only create area if domain has tables in this schema
if domainTableCount > 0 {
area := &DrawDBArea{
ID: areaID,
Name: domainModel.Name,
Color: getColorForIndex(len(d.Schemas) + domainIdx), // Use different colors than schemas
X: minX - 20,
Y: minY - 20,
Width: maxX - minX + 40,
Height: maxY - minY + 40,
}
schema.SubjectAreas = append(schema.SubjectAreas, area)
areaID++
}
}
// Add relationships
for _, schemaModel := range d.Schemas {
for _, table := range schemaModel.Tables {
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)
startTableID, startExists := tableMap[startTableKey]
endTableID, endExists := tableMap[endTableKey]
if startExists && endExists && len(constraint.Columns) > 0 && len(constraint.ReferencedColumns) > 0 {
// Find relative field IDs within their tables
startFieldID := 0
endFieldID := 0
for _, t := range schema.Tables {
if t.ID == startTableID {
for idx, f := range t.Fields {
if f.Name == constraint.Columns[0] {
startFieldID = idx
break
}
}
}
if t.ID == endTableID {
for idx, f := range t.Fields {
if f.Name == constraint.ReferencedColumns[0] {
endFieldID = idx
break
}
}
}
}
relationship := &DrawDBRelationship{
ID: relationshipID,
Name: constraint.Name,
StartTableID: startTableID,
EndTableID: endTableID,
StartFieldID: startFieldID,
EndFieldID: endFieldID,
Cardinality: "Many to one",
UpdateConstraint: constraint.OnUpdate,
DeleteConstraint: constraint.OnDelete,
}
schema.Relationships = append(schema.Relationships, relationship)
relationshipID++
}
}
}
}
}
// Add database description as a note
if d.Description != "" || d.Comment != "" {
note := d.Description
if note != "" && d.Comment != "" {
note += "\n"
}
note += d.Comment
schema.Notes = append(schema.Notes, &DrawDBNote{
ID: noteID,
Content: fmt.Sprintf("Database: %s\n\n%s", d.Name, note),
Color: "#ffd93d",
X: 10,
Y: 10,
})
}
return schema
}
// schemaToDrawDB converts a Schema to DrawDB format
func (w *Writer) schemaToDrawDB(schema *models.Schema) *DrawDBSchema {
drawSchema := &DrawDBSchema{
Tables: make([]*DrawDBTable, 0),
Relationships: make([]*DrawDBRelationship, 0),
Notes: make([]*DrawDBNote, 0),
SubjectAreas: make([]*DrawDBArea, 0),
}
tableID := 0
fieldID := 0
gridX, gridY := 50, 50
colWidth, rowHeight := 300, 200
tablesPerRow := 4
for idx, table := range schema.Tables {
drawTable, newFieldID := w.convertTableToDrawDB(table, schema.Name, tableID, fieldID, idx, tablesPerRow, gridX, gridY, colWidth, rowHeight, 0)
drawSchema.Tables = append(drawSchema.Tables, drawTable)
fieldID = newFieldID
tableID++
}
return drawSchema
}
// tableToDrawDB converts a single Table to DrawDB format
func (w *Writer) tableToDrawDB(table *models.Table) *DrawDBSchema {
drawSchema := &DrawDBSchema{
Tables: make([]*DrawDBTable, 0),
Relationships: make([]*DrawDBRelationship, 0),
Notes: make([]*DrawDBNote, 0),
SubjectAreas: make([]*DrawDBArea, 0),
}
drawTable, _ := w.convertTableToDrawDB(table, table.Schema, 0, 0, 0, 4, 50, 50, 300, 200, 0)
drawSchema.Tables = append(drawSchema.Tables, drawTable)
return drawSchema
}
// convertTableToDrawDB converts a table to DrawDB format and returns the table and next field ID
func (w *Writer) convertTableToDrawDB(table *models.Table, schemaName string, tableID, fieldID, tableIndex, tablesPerRow, gridX, gridY, colWidth, rowHeight, colorIndex int) (drawTable *DrawDBTable, nextFieldID int) {
// Calculate position
x := gridX + (tableIndex%tablesPerRow)*colWidth
y := gridY + (tableIndex/tablesPerRow)*rowHeight
drawTable = &DrawDBTable{
ID: tableID,
Name: table.Name,
Schema: schemaName,
Comment: table.Description,
Color: getColorForIndex(colorIndex),
X: x,
Y: y,
Fields: make([]*DrawDBField, 0),
Indexes: make([]*DrawDBIndex, 0),
}
// Add fields
for _, column := range sortColumns(table.Columns) {
field := &DrawDBField{
ID: fieldID,
Name: column.Name,
Type: formatTypeForDrawDB(column),
Primary: column.IsPrimaryKey,
NotNull: column.NotNull,
Increment: column.AutoIncrement,
Comment: column.Comment,
}
if column.Default != nil {
field.Default = fmt.Sprintf("%v", column.Default)
}
// Check for unique constraint
for _, constraint := range table.Constraints {
if constraint.Type == models.UniqueConstraint {
for _, col := range constraint.Columns {
if col == column.Name {
field.Unique = true
break
}
}
}
}
drawTable.Fields = append(drawTable.Fields, field)
fieldID++
}
// Add indexes
indexID := 0
for _, index := range sortIndexes(table.Indexes) {
drawIndex := &DrawDBIndex{
ID: indexID,
Name: index.Name,
Unique: index.Unique,
Fields: make([]int, 0),
}
// Map column names to field IDs
for _, colName := range index.Columns {
for idx, field := range drawTable.Fields {
if field.Name == colName {
drawIndex.Fields = append(drawIndex.Fields, idx)
break
}
}
}
drawTable.Indexes = append(drawTable.Indexes, drawIndex)
indexID++
}
return drawTable, fieldID
}
// Helper functions
func formatTypeForDrawDB(column *models.Column) string {
typeStr := column.Type
if column.Length > 0 {
typeStr = fmt.Sprintf("%s(%d)", typeStr, column.Length)
} else if column.Precision > 0 {
if column.Scale > 0 {
typeStr = fmt.Sprintf("%s(%d,%d)", typeStr, column.Precision, column.Scale)
} else {
typeStr = fmt.Sprintf("%s(%d)", typeStr, column.Precision)
}
}
return typeStr
}
func getColorForIndex(index int) string {
colors := []string{
"#6366f1", // indigo
"#8b5cf6", // violet
"#ec4899", // pink
"#f43f5e", // rose
"#14b8a6", // teal
"#06b6d4", // cyan
"#0ea5e9", // sky
"#3b82f6", // blue
}
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
}