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.
442 lines
12 KiB
Go
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
|
|
}
|