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.
422 lines
11 KiB
Go
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() + "}"
|
|
}
|