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

422 lines
11 KiB
Go

package dctx
import (
"encoding/xml"
"fmt"
"os"
"sort"
"strings"
"github.com/google/uuid"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
)
// Writer implements the writers.Writer interface for DCTX format
type Writer struct {
options *writers.WriterOptions
fieldGuidMap map[string]string // key: "table.column", value: guid
keyGuidMap map[string]string // key: "table.index", value: guid
tableGuidMap map[string]string // key: "table", value: guid
}
// NewWriter creates a new DCTX writer with the given options
func NewWriter(options *writers.WriterOptions) *Writer {
return &Writer{
options: options,
fieldGuidMap: make(map[string]string),
keyGuidMap: make(map[string]string),
tableGuidMap: make(map[string]string),
}
}
// WriteDatabase is not implemented for DCTX
func (w *Writer) WriteDatabase(db *models.Database) error {
return fmt.Errorf("writing a full database is not supported for DCTX, please write a single schema")
}
// WriteSchema writes a schema to the writer in DCTX format
func (w *Writer) WriteSchema(schema *models.Schema) error {
dctx := models.DCTXDictionary{
Name: schema.Name,
Version: "1",
Tables: make([]models.DCTXTable, len(schema.Tables)),
}
tableSlice := make([]*models.Table, 0, len(schema.Tables))
tableSlice = append(tableSlice, schema.Tables...)
// Pass 1: Create fields and populate fieldGuidMap
for i, table := range tableSlice {
dctx.Tables[i] = w.mapTableFields(table)
}
// Pass 2: Create keys and populate keyGuidMap
for i, table := range tableSlice {
dctx.Tables[i].Keys = w.mapTableKeys(table)
}
// Pass 3: Collect all relationships (from schema and tables)
var allRelations []*models.Relationship
// Add schema-level relations
allRelations = append(allRelations, schema.Relations...)
// Add table-level relationships
for _, table := range tableSlice {
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)
isDuplicate := false
for _, existing := range allRelations {
if existing.Name == rel.Name &&
existing.FromTable == rel.FromTable &&
existing.ToTable == rel.ToTable {
isDuplicate = true
break
}
}
if !isDuplicate {
allRelations = append(allRelations, rel)
}
}
}
// Map all relations to DCTX format
dctx.Relations = make([]models.DCTXRelation, len(allRelations))
for i, rel := range allRelations {
dctx.Relations[i] = w.mapRelation(rel, schema)
}
output, err := xml.MarshalIndent(dctx, "", " ")
if err != nil {
return err
}
file, err := os.Create(w.options.OutputPath)
if err != nil {
return err
}
defer file.Close()
if _, err := file.Write([]byte(xml.Header)); err != nil {
return err
}
_, err = file.Write(output)
return err
}
// WriteTable writes a single table to the writer in DCTX format
func (w *Writer) WriteTable(table *models.Table) error {
dctxTable := w.mapTableFields(table)
dctxTable.Keys = w.mapTableKeys(table)
output, err := xml.MarshalIndent(dctxTable, "", " ")
if err != nil {
return err
}
file, err := os.Create(w.options.OutputPath)
if err != nil {
return err
}
defer file.Close()
_, err = file.Write(output)
return err
}
func (w *Writer) mapTableFields(table *models.Table) models.DCTXTable {
// Generate prefix (first 3 chars, or full name if shorter)
prefix := table.Name
if len(table.Name) > 3 {
prefix = table.Name[:3]
}
// Use GUID from model if available, otherwise generate a new one
tableGuid := table.GUID
if tableGuid == "" {
tableGuid = w.newGUID()
}
w.tableGuidMap[table.Name] = tableGuid
dctxTable := models.DCTXTable{
Guid: tableGuid,
Name: table.Name,
Prefix: prefix,
Description: table.Comment,
Fields: make([]models.DCTXField, len(table.Columns)),
Options: []models.DCTXOption{
{
Property: "SQL",
PropertyType: "1",
PropertyValue: "1",
},
},
}
columnNames := make([]string, 0, len(table.Columns))
for name := range table.Columns {
columnNames = append(columnNames, name)
}
sort.Strings(columnNames)
i := 0
for _, colName := range columnNames {
column := table.Columns[colName]
dctxTable.Fields[i] = w.mapField(column)
i++
}
return dctxTable
}
func (w *Writer) mapTableKeys(table *models.Table) []models.DCTXKey {
indexes := make([]*models.Index, 0, len(table.Indexes))
for _, index := range table.Indexes {
indexes = append(indexes, index)
}
// Stable ordering for deterministic output and test reproducibility:
// primary keys first, then lexicographic by index name.
sort.Slice(indexes, func(i, j int) bool {
iPrimary := strings.HasSuffix(indexes[i].Name, "_pkey")
jPrimary := strings.HasSuffix(indexes[j].Name, "_pkey")
if iPrimary != jPrimary {
return iPrimary
}
return indexes[i].Name < indexes[j].Name
})
keys := make([]models.DCTXKey, len(indexes))
for i, index := range indexes {
keys[i] = w.mapKey(index, table)
}
return keys
}
func (w *Writer) mapField(column *models.Column) models.DCTXField {
// Use GUID from model if available, otherwise generate a new one
guid := column.GUID
if guid == "" {
guid = w.newGUID()
}
fieldKey := fmt.Sprintf("%s.%s", column.Table, column.Name)
w.fieldGuidMap[fieldKey] = guid
return models.DCTXField{
Guid: guid,
Name: column.Name,
DataType: w.mapDataType(column.Type),
Size: column.Length,
}
}
func (w *Writer) mapDataType(dataType string) string {
switch dataType {
case "integer", "int", "int4", "serial":
return "LONG"
case "bigint", "int8", "bigserial":
return "DECIMAL"
case "smallint", "int2":
return "SHORT"
case "boolean", "bool":
return "BYTE"
case "text", "varchar", "char":
return "CSTRING"
case "date":
return "DATE"
case "time":
return "TIME"
case "timestamp", "timestamptz":
return "STRING"
case "decimal", "numeric":
return "DECIMAL"
default:
return "STRING"
}
}
func (w *Writer) mapKey(index *models.Index, table *models.Table) models.DCTXKey {
// Use GUID from model if available, otherwise generate a new one
guid := index.GUID
if guid == "" {
guid = w.newGUID()
}
keyKey := fmt.Sprintf("%s.%s", table.Name, index.Name)
w.keyGuidMap[keyKey] = guid
key := models.DCTXKey{
Guid: guid,
Name: index.Name,
Primary: strings.HasSuffix(index.Name, "_pkey"),
Unique: index.Unique,
Components: make([]models.DCTXComponent, len(index.Columns)),
Description: index.Comment,
}
for i, colName := range index.Columns {
fieldKey := fmt.Sprintf("%s.%s", table.Name, colName)
fieldID := w.fieldGuidMap[fieldKey]
key.Components[i] = models.DCTXComponent{
Guid: w.newGUID(),
FieldId: fieldID,
Order: i + 1,
Ascend: true,
}
}
return key
}
func (w *Writer) mapRelation(rel *models.Relationship, schema *models.Schema) models.DCTXRelation {
// Find the foreign key constraint from the 'from' table
var fromTable *models.Table
for _, t := range schema.Tables {
if t.Name == rel.FromTable {
fromTable = t
break
}
}
var constraint *models.Constraint
if fromTable != nil {
for _, c := range fromTable.Constraints {
if c.Name == rel.ForeignKey {
constraint = c
break
}
}
}
var foreignKeyGUID string
var fkColumns []string
if constraint != nil {
fkColumns = constraint.Columns
// In DCTX, a relation is often linked by a foreign key which is an index.
// We'll look for an index that matches the constraint columns.
for _, index := range fromTable.Indexes {
if strings.Join(index.Columns, ",") == strings.Join(constraint.Columns, ",") {
keyKey := fmt.Sprintf("%s.%s", fromTable.Name, index.Name)
foreignKeyGUID = w.keyGuidMap[keyKey]
break
}
}
}
// Find the primary key of the 'to' table
var toTable *models.Table
for _, t := range schema.Tables {
if t.Name == rel.ToTable {
toTable = t
break
}
}
var primaryKeyGUID string
var pkColumns []string
// Use referenced columns from the constraint if available
if constraint != nil && len(constraint.ReferencedColumns) > 0 {
pkColumns = constraint.ReferencedColumns
}
if toTable != nil {
// Find the matching primary key index
for _, index := range toTable.Indexes {
// If we have referenced columns, try to match them
if len(pkColumns) > 0 {
if strings.Join(index.Columns, ",") == strings.Join(pkColumns, ",") {
keyKey := fmt.Sprintf("%s.%s", toTable.Name, index.Name)
primaryKeyGUID = w.keyGuidMap[keyKey]
break
}
} else if strings.HasSuffix(index.Name, "_pkey") {
// Fall back to finding primary key by naming convention
keyKey := fmt.Sprintf("%s.%s", toTable.Name, index.Name)
primaryKeyGUID = w.keyGuidMap[keyKey]
pkColumns = index.Columns
break
}
}
}
// Create field mappings
// NOTE: DCTX has backwards naming - ForeignMapping contains PRIMARY table fields,
// and PrimaryMapping contains FOREIGN table fields
var foreignMappings []models.DCTXFieldMapping // Will contain primary table fields
var primaryMappings []models.DCTXFieldMapping // Will contain foreign table fields
// Map foreign key columns (from foreign table) to PrimaryMapping
for _, colName := range fkColumns {
fieldKey := fmt.Sprintf("%s.%s", rel.FromTable, colName)
if fieldGUID, exists := w.fieldGuidMap[fieldKey]; exists {
primaryMappings = append(primaryMappings, models.DCTXFieldMapping{
Guid: w.newGUID(),
Field: fieldGUID,
})
}
}
// Map primary key columns (from primary table) to ForeignMapping
for _, colName := range pkColumns {
fieldKey := fmt.Sprintf("%s.%s", rel.ToTable, colName)
if fieldGUID, exists := w.fieldGuidMap[fieldKey]; exists {
foreignMappings = append(foreignMappings, models.DCTXFieldMapping{
Guid: w.newGUID(),
Field: fieldGUID,
})
}
}
// Get OnDelete and OnUpdate actions from the constraint
onDelete := ""
onUpdate := ""
if constraint != nil {
onDelete = w.mapReferentialAction(constraint.OnDelete)
onUpdate = w.mapReferentialAction(constraint.OnUpdate)
}
return models.DCTXRelation{
Guid: rel.GUID, // Use GUID from relationship model
PrimaryTable: w.tableGuidMap[rel.ToTable], // GUID of the 'to' table (e.g., users)
ForeignTable: w.tableGuidMap[rel.FromTable], // GUID of the 'from' table (e.g., posts)
PrimaryKey: primaryKeyGUID,
ForeignKey: foreignKeyGUID,
Delete: onDelete,
Update: onUpdate,
ForeignMappings: foreignMappings,
PrimaryMappings: primaryMappings,
}
}
// mapReferentialAction maps SQL referential actions to DCTX format
func (w *Writer) mapReferentialAction(action string) string {
switch strings.ToUpper(action) {
case "RESTRICT":
return "RESTRICT_SERVER"
case "CASCADE":
return "CASCADE_SERVER"
case "SET NULL":
return "SET_NULL_SERVER"
case "SET DEFAULT":
return "SET_DEFAULT_SERVER"
case "NO ACTION":
return "NO_ACTION_SERVER"
default:
return ""
}
}
func (w *Writer) newGUID() string {
return "{" + uuid.New().String() + "}"
}