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.
279 lines
7.3 KiB
Go
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
|
|
}
|