Files
relspecgo/pkg/writers/dbml/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

279 lines
7.3 KiB
Go

package dbml
import (
"fmt"
"os"
"sort"
"strings"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
)
// Writer implements the writers.Writer interface for DBML format
type Writer struct {
options *writers.WriterOptions
}
// NewWriter creates a new DBML writer with the given options
func NewWriter(options *writers.WriterOptions) *Writer {
return &Writer{
options: options,
}
}
// WriteDatabase writes a Database model to DBML format
func (w *Writer) WriteDatabase(db *models.Database) error {
content := w.databaseToDBML(db)
if w.options.OutputPath != "" {
return os.WriteFile(w.options.OutputPath, []byte(content), 0644)
}
fmt.Print(content)
return nil
}
// WriteSchema writes a Schema model to DBML format
func (w *Writer) WriteSchema(schema *models.Schema) error {
content := w.schemaToDBML(schema)
if w.options.OutputPath != "" {
return os.WriteFile(w.options.OutputPath, []byte(content), 0644)
}
fmt.Print(content)
return nil
}
// WriteTable writes a Table model to DBML format
func (w *Writer) WriteTable(table *models.Table) error {
content := w.tableToDBML(table)
if w.options.OutputPath != "" {
return os.WriteFile(w.options.OutputPath, []byte(content), 0644)
}
fmt.Print(content)
return nil
}
// databaseToDBML converts a Database to DBML format string
func (w *Writer) databaseToDBML(d *models.Database) string {
var sb strings.Builder
if d.Description != "" {
fmt.Fprintf(&sb, "// %s\n", d.Description)
}
if d.Comment != "" {
fmt.Fprintf(&sb, "// %s\n", d.Comment)
}
if d.Description != "" || d.Comment != "" {
sb.WriteString("\n")
}
for _, schema := range d.Schemas {
sb.WriteString(w.schemaToDBML(schema))
}
sb.WriteString("\n// Relationships\n")
for _, schema := range d.Schemas {
for _, table := range schema.Tables {
for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.ForeignKeyConstraint {
sb.WriteString(w.constraintToDBML(constraint, table))
}
}
}
}
return sb.String()
}
// schemaToDBML converts a Schema to DBML format string
func (w *Writer) schemaToDBML(schema *models.Schema) string {
var sb strings.Builder
if schema.Description != "" {
fmt.Fprintf(&sb, "// Schema: %s - %s\n", schema.Name, schema.Description)
}
for _, table := range schema.Tables {
sb.WriteString(w.tableToDBML(table))
sb.WriteString("\n")
}
return sb.String()
}
// tableToDBML converts a Table to DBML format string
func (w *Writer) tableToDBML(t *models.Table) string {
var sb strings.Builder
tableName := fmt.Sprintf("%s.%s", t.Schema, t.Name)
fmt.Fprintf(&sb, "Table %s {\n", tableName)
for _, column := range sortColumns(t.Columns) {
fmt.Fprintf(&sb, " %s %s", column.Name, column.Type)
var attrs []string
if column.IsPrimaryKey {
attrs = append(attrs, "pk")
}
if column.NotNull && !column.IsPrimaryKey {
attrs = append(attrs, "not null")
}
if column.AutoIncrement {
attrs = append(attrs, "increment")
}
if column.Default != nil {
// Check if default value contains backticks (DBML expressions like `now()`)
defaultStr := fmt.Sprintf("%v", column.Default)
if strings.HasPrefix(defaultStr, "`") && strings.HasSuffix(defaultStr, "`") {
// Already an expression with backticks, use as-is
attrs = append(attrs, fmt.Sprintf("default: %s", defaultStr))
} else {
// Regular value, wrap in single quotes
attrs = append(attrs, fmt.Sprintf("default: '%v'", column.Default))
}
}
if len(attrs) > 0 {
fmt.Fprintf(&sb, " [%s]", strings.Join(attrs, ", "))
}
if column.Comment != "" {
fmt.Fprintf(&sb, " // %s", column.Comment)
}
sb.WriteString("\n")
}
if len(t.Indexes) > 0 {
sb.WriteString("\n indexes {\n")
for _, index := range sortIndexes(t.Indexes) {
var indexAttrs []string
if index.Unique {
indexAttrs = append(indexAttrs, "unique")
}
if index.Name != "" {
indexAttrs = append(indexAttrs, fmt.Sprintf("name: '%s'", index.Name))
}
if index.Type != "" {
indexAttrs = append(indexAttrs, fmt.Sprintf("type: %s", index.Type))
}
fmt.Fprintf(&sb, " (%s)", strings.Join(index.Columns, ", "))
if len(indexAttrs) > 0 {
fmt.Fprintf(&sb, " [%s]", strings.Join(indexAttrs, ", "))
}
sb.WriteString("\n")
}
sb.WriteString(" }\n")
}
note := strings.TrimSpace(t.Description + " " + t.Comment)
if note != "" {
fmt.Fprintf(&sb, "\n Note: '%s'\n", note)
}
sb.WriteString("}\n")
return sb.String()
}
// constraintToDBML converts a Constraint to DBML format string
func (w *Writer) constraintToDBML(c *models.Constraint, t *models.Table) string {
if c.Type != models.ForeignKeyConstraint || c.ReferencedTable == "" {
return ""
}
fromTable := fmt.Sprintf("%s.%s", c.Schema, c.Table)
toTable := fmt.Sprintf("%s.%s", c.ReferencedSchema, c.ReferencedTable)
relationship := ">" // Default to many-to-one
for _, index := range t.Indexes {
if index.Unique && strings.Join(index.Columns, ",") == strings.Join(c.Columns, ",") {
relationship = "-" // one-to-one
break
}
}
for _, column := range c.Columns {
if t.Columns[column].IsPrimaryKey {
relationship = "-" // one-to-one
break
}
}
var fromRef, toRef string
if len(c.Columns) == 1 {
fromRef = fmt.Sprintf("%s.%s", fromTable, c.Columns[0])
} else {
fromRef = fmt.Sprintf("%s.(%s)", fromTable, strings.Join(c.Columns, ", "))
}
if len(c.ReferencedColumns) == 1 {
toRef = fmt.Sprintf("%s.%s", toTable, c.ReferencedColumns[0])
} else {
toRef = fmt.Sprintf("%s.(%s)", toTable, strings.Join(c.ReferencedColumns, ", "))
}
var actions []string
if c.OnDelete != "" {
actions = append(actions, fmt.Sprintf("delete: %s", c.OnDelete))
}
if c.OnUpdate != "" {
actions = append(actions, fmt.Sprintf("update: %s", c.OnUpdate))
}
refLine := fmt.Sprintf("Ref: %s %s %s", fromRef, relationship, toRef)
if len(actions) > 0 {
refLine += fmt.Sprintf(" [%s]", strings.Join(actions, ", "))
}
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
}