Files
relspecgo/pkg/readers/dctx/reader.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

577 lines
15 KiB
Go

package dctx
import (
"encoding/xml"
"fmt"
"os"
"sort"
"strings"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
"git.warky.dev/wdevs/relspecgo/pkg/readers"
)
// Reader implements the readers.Reader interface for DCTX format
type Reader struct {
options *readers.ReaderOptions
}
// NewReader creates a new DCTX reader with the given options
func NewReader(options *readers.ReaderOptions) *Reader {
return &Reader{
options: options,
}
}
// ReadDatabase reads and parses DCTX input, returning a Database model
func (r *Reader) ReadDatabase() (*models.Database, error) {
if r.options.FilePath == "" {
return nil, fmt.Errorf("file path is required for DCTX reader")
}
data, err := os.ReadFile(r.options.FilePath)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
var dctx models.DCTXDictionary
if err := xml.Unmarshal(data, &dctx); err != nil {
return nil, fmt.Errorf("failed to parse DCTX XML: %w", err)
}
return r.convertToDatabase(&dctx)
}
// ReadSchema reads and parses DCTX input, returning a Schema model
func (r *Reader) ReadSchema() (*models.Schema, error) {
db, err := r.ReadDatabase()
if err != nil {
return nil, err
}
if len(db.Schemas) == 0 {
return nil, fmt.Errorf("no schemas found in DCTX")
}
return db.Schemas[0], nil
}
// ReadTable reads and parses DCTX input, returning a Table model
func (r *Reader) ReadTable() (*models.Table, error) {
schema, err := r.ReadSchema()
if err != nil {
return nil, err
}
if len(schema.Tables) == 0 {
return nil, fmt.Errorf("no tables found in DCTX")
}
return schema.Tables[0], nil
}
// convertToDatabase converts a DCTX dictionary to a Database model
func (r *Reader) convertToDatabase(dctx *models.DCTXDictionary) (*models.Database, error) {
dbName := dctx.Name
if dbName == "" {
dbName = "database"
}
db := models.InitDatabase(dbName)
schema := models.InitSchema("public")
// Note: DCTX doesn't have database GUID, but schema can use dictionary name if available
// Create GUID mappings for tables and keys
tableGuidMap := make(map[string]string) // GUID -> table name
keyGuidMap := make(map[string]*models.DCTXKey) // GUID -> key definition
keyTableMap := make(map[string]string) // key GUID -> table name
fieldGuidMaps := make(map[string]map[string]string) // table name -> field GUID -> field name
// First pass: build GUID mappings
for i := range dctx.Tables {
dctxTable := &dctx.Tables[i]
if !r.hasSQLOption(dctxTable) {
continue
}
tableName := r.sanitizeName(dctxTable.Name)
tableGuidMap[dctxTable.Guid] = tableName
// Map keys to their table
for _, dctxKey := range dctxTable.Keys {
keyGuidMap[dctxKey.Guid] = &dctxKey
keyTableMap[dctxKey.Guid] = tableName
}
}
// Process tables - only include tables with SQL option enabled
for i := range dctx.Tables {
dctxTable := &dctx.Tables[i]
if !r.hasSQLOption(dctxTable) {
continue
}
table, fieldGuidMap, err := r.convertTable(dctxTable)
if err != nil {
return nil, fmt.Errorf("failed to convert table %s: %w", dctxTable.Name, err)
}
fieldGuidMaps[table.Name] = fieldGuidMap
schema.Tables = append(schema.Tables, table)
// Process keys (indexes, primary keys)
err = r.processKeys(dctxTable, table, fieldGuidMap)
if err != nil {
return nil, fmt.Errorf("failed to process keys for table %s: %w", dctxTable.Name, err)
}
}
// Process relations
err := r.processRelations(dctx, schema, tableGuidMap, keyGuidMap, fieldGuidMaps)
if err != nil {
return nil, fmt.Errorf("failed to process relations: %w", err)
}
db.Schemas = append(db.Schemas, schema)
return db, nil
}
// hasSQLOption checks if a DCTX table has the SQL option set to "1"
func (r *Reader) hasSQLOption(dctxTable *models.DCTXTable) bool {
for _, option := range dctxTable.Options {
if option.Property == "SQL" && option.PropertyValue == "1" {
return true
}
}
return false
}
// collectFieldGuids recursively collects all field GUIDs from a field and its nested fields
func (r *Reader) collectFieldGuids(dctxField *models.DCTXField, guidMap map[string]string) {
// Store the current field's GUID if available
if dctxField.Guid != "" && dctxField.Name != "" {
guidMap[dctxField.Guid] = r.sanitizeName(dctxField.Name)
}
// Recursively process nested fields (for GROUP types)
for i := range dctxField.Fields {
r.collectFieldGuids(&dctxField.Fields[i], guidMap)
}
}
// convertTable converts a DCTX table to a Table model
func (r *Reader) convertTable(dctxTable *models.DCTXTable) (*models.Table, map[string]string, error) {
tableName := r.sanitizeName(dctxTable.Name)
table := models.InitTable(tableName, "public")
table.Description = dctxTable.Description
// Assign GUID from DCTX table
if dctxTable.Guid != "" {
table.GUID = dctxTable.Guid
}
fieldGuidMap := make(map[string]string)
// Process fields
for _, dctxField := range dctxTable.Fields {
// Recursively collect all field GUIDs (including nested fields in GROUP types)
r.collectFieldGuids(&dctxField, fieldGuidMap)
columns, err := r.convertField(&dctxField, table.Name)
if err != nil {
return nil, nil, fmt.Errorf("failed to convert field %s: %w", dctxField.Name, err)
}
// Add all columns
for _, column := range columns {
table.Columns[column.Name] = column
}
}
return table, fieldGuidMap, nil
}
// convertField converts a DCTX field to Column(s)
func (r *Reader) convertField(dctxField *models.DCTXField, tableName string) ([]*models.Column, error) {
var columns []*models.Column
// Handle GROUP fields (nested structures)
if dctxField.DataType == "GROUP" {
for _, subField := range dctxField.Fields {
subColumns, err := r.convertField(&subField, tableName)
if err != nil {
return nil, err
}
columns = append(columns, subColumns...)
}
return columns, nil
}
// Convert single field
column := models.InitColumn(r.sanitizeName(dctxField.Name), tableName, "public")
// Assign GUID from DCTX field
if dctxField.Guid != "" {
column.GUID = dctxField.Guid
}
// Map Clarion data types
dataType, length := r.mapDataType(dctxField.DataType, dctxField.Size)
column.Type = dataType
column.Length = length
// Check for auto-increment (identity)
for _, option := range dctxField.Options {
if option.Property == "IsIdentity" && option.PropertyValue == "1" {
column.AutoIncrement = true
column.NotNull = true
}
}
columns = append(columns, column)
return columns, nil
}
// mapDataType maps Clarion data types to SQL types
func (r *Reader) mapDataType(clarionType string, size int) (sqlType string, precision int) {
trimmedType := strings.TrimSpace(clarionType)
// Preserve known PostgreSQL types (including arrays and extension types)
// from DCTX input instead of coercing them to generic text.
if pgsql.IsKnownPostgresType(trimmedType) {
pgType := canonicalizePostgresType(trimmedType)
if !pgsql.HasExplicitTypeModifier(pgType) && size > 0 && pgsql.SupportsLength(pgType) {
return pgType, size
}
return pgType, 0
}
switch strings.ToUpper(trimmedType) {
case "LONG":
if size == 8 {
return "bigint", 0
}
return "integer", 0
case "ULONG":
if size == 8 {
return "bigint", 0
}
return "integer", 0
case "SHORT":
return "smallint", 0
case "USHORT":
return "smallint", 0
case "BYTE":
return "smallint", 0
case "STRING":
if size > 0 {
return "varchar", size
}
return "text", 0
case "CSTRING":
if size > 0 {
// CSTRING includes null terminator, so subtract 1
length := size - 1
if length <= 0 {
length = 1
}
return "varchar", length
}
return "text", 0
case "PSTRING":
if size > 0 {
return "varchar", size
}
return "text", 0
case "DECIMAL":
return "decimal", 0
case "REAL":
return "real", 0
case "SREAL":
return "double precision", 0
case "DATE":
return "date", 0
case "TIME":
return "time", 0
case "BLOB":
return "bytea", 0
case "MEMO":
return "text", 0
case "BOOL", "BOOLEAN":
return "boolean", 0
default:
return "text", 0
}
}
func canonicalizePostgresType(typeStr string) string {
t := strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(typeStr)), " "))
if t == "" {
return ""
}
// Handle array suffixes
arrayCount := 0
for strings.HasSuffix(t, "[]") {
arrayCount++
t = strings.TrimSpace(strings.TrimSuffix(t, "[]"))
}
// Handle optional type modifier
modifier := ""
if idx := strings.Index(t, "("); idx > 0 {
if end := strings.LastIndex(t, ")"); end > idx {
modifier = t[idx : end+1]
t = strings.TrimSpace(t[:idx])
}
}
base := pgsql.CanonicalizeBaseType(t)
return base + modifier + strings.Repeat("[]", arrayCount)
}
// processKeys processes DCTX keys and converts them to indexes and primary keys
func (r *Reader) processKeys(dctxTable *models.DCTXTable, table *models.Table, fieldGuidMap map[string]string) error {
for _, dctxKey := range dctxTable.Keys {
err := r.convertKey(&dctxKey, table, fieldGuidMap)
if err != nil {
return fmt.Errorf("failed to convert key %s: %w", dctxKey.Name, err)
}
}
return nil
}
// convertKey converts a DCTX key to appropriate constraint/index
func (r *Reader) convertKey(dctxKey *models.DCTXKey, table *models.Table, fieldGuidMap map[string]string) error {
var columns []string
// Extract column names from key components
if len(dctxKey.Components) > 0 {
for _, component := range dctxKey.Components {
if fieldName, exists := fieldGuidMap[component.FieldId]; exists {
columns = append(columns, fieldName)
}
}
}
// If no columns found, try to infer
if len(columns) == 0 {
if dctxKey.Primary {
// Look for common primary key column patterns
colNames := make([]string, 0, len(table.Columns))
for colName := range table.Columns {
colNames = append(colNames, colName)
}
sort.Strings(colNames)
for _, colName := range colNames {
colNameLower := strings.ToLower(colName)
if strings.HasPrefix(colNameLower, "rid_") || strings.HasSuffix(colNameLower, "id") {
columns = append(columns, colName)
break
}
}
}
// If still no columns, skip
if len(columns) == 0 {
return nil
}
}
// Handle primary key
if dctxKey.Primary {
// Create primary key constraint
constraint := models.InitConstraint(r.sanitizeName(dctxKey.Name), models.PrimaryKeyConstraint)
constraint.Table = table.Name
constraint.Schema = table.Schema
constraint.Columns = columns
// Assign GUID from DCTX key
if dctxKey.Guid != "" {
constraint.GUID = dctxKey.Guid
}
table.Constraints[constraint.Name] = constraint
// Mark columns as NOT NULL
for _, colName := range columns {
if col, exists := table.Columns[colName]; exists {
col.NotNull = true
col.IsPrimaryKey = true
}
}
return nil
}
// Handle regular index
index := models.InitIndex(r.sanitizeName(dctxKey.Name), table.Name, table.Schema)
index.Table = table.Name
index.Schema = table.Schema
index.Columns = columns
index.Unique = dctxKey.Unique
index.Type = "btree"
// Assign GUID from DCTX key
if dctxKey.Guid != "" {
index.GUID = dctxKey.Guid
}
table.Indexes[index.Name] = index
return nil
}
// processRelations processes DCTX relations and creates foreign keys
func (r *Reader) processRelations(dctx *models.DCTXDictionary, schema *models.Schema, tableGuidMap map[string]string, keyGuidMap map[string]*models.DCTXKey, fieldGuidMaps map[string]map[string]string) error {
for i := range dctx.Relations {
relation := &dctx.Relations[i]
// Get table names from GUIDs
primaryTableName := tableGuidMap[relation.PrimaryTable]
foreignTableName := tableGuidMap[relation.ForeignTable]
if primaryTableName == "" || foreignTableName == "" {
continue
}
// Find tables
var primaryTable, foreignTable *models.Table
for _, table := range schema.Tables {
if table.Name == primaryTableName {
primaryTable = table
}
if table.Name == foreignTableName {
foreignTable = table
}
}
if primaryTable == nil || foreignTable == nil {
continue
}
var fkColumns, pkColumns []string
// Try to use explicit field mappings
// NOTE: DCTX format has backwards naming - ForeignMapping contains primary table fields,
// and PrimaryMapping contains foreign table fields
if len(relation.ForeignMappings) > 0 && len(relation.PrimaryMappings) > 0 {
foreignFieldMap := fieldGuidMaps[foreignTableName]
primaryFieldMap := fieldGuidMaps[primaryTableName]
// ForeignMapping actually contains fields from the PRIMARY table
for _, mapping := range relation.ForeignMappings {
if fieldName, exists := primaryFieldMap[mapping.Field]; exists {
pkColumns = append(pkColumns, fieldName)
}
}
// PrimaryMapping actually contains fields from the FOREIGN table
for _, mapping := range relation.PrimaryMappings {
if fieldName, exists := foreignFieldMap[mapping.Field]; exists {
fkColumns = append(fkColumns, fieldName)
}
}
}
// Validate columns exist
if len(fkColumns) == 0 || len(pkColumns) == 0 {
continue
}
allFkColumnsExist := true
for _, colName := range fkColumns {
if _, exists := foreignTable.Columns[colName]; !exists {
allFkColumnsExist = false
break
}
}
if !allFkColumnsExist {
continue
}
allPkColumnsExist := true
for _, colName := range pkColumns {
if _, exists := primaryTable.Columns[colName]; !exists {
allPkColumnsExist = false
break
}
}
if !allPkColumnsExist {
continue
}
// Create foreign key
fkName := r.sanitizeName(fmt.Sprintf("fk_%s_%s", foreignTableName, primaryTableName))
constraint := models.InitConstraint(fkName, models.ForeignKeyConstraint)
constraint.Table = foreignTableName
constraint.Schema = "public"
constraint.Columns = fkColumns
constraint.ReferencedTable = primaryTableName
constraint.ReferencedSchema = "public"
constraint.ReferencedColumns = pkColumns
constraint.OnDelete = r.mapReferentialAction(relation.Delete)
constraint.OnUpdate = r.mapReferentialAction(relation.Update)
// Assign GUID from DCTX relation
if relation.Guid != "" {
constraint.GUID = relation.Guid
}
foreignTable.Constraints[fkName] = constraint
// Create relationship
relationshipName := fmt.Sprintf("%s_to_%s", foreignTableName, primaryTableName)
relationship := models.InitRelationship(relationshipName, models.OneToMany)
relationship.FromTable = primaryTableName
relationship.FromSchema = "public"
relationship.ToTable = foreignTableName
relationship.ToSchema = "public"
relationship.ForeignKey = fkName
relationship.Properties["on_delete"] = constraint.OnDelete
relationship.Properties["on_update"] = constraint.OnUpdate
// Assign GUID from DCTX relation
if relation.Guid != "" {
relationship.GUID = relation.Guid
}
foreignTable.Relationships[relationshipName] = relationship
}
return nil
}
// mapReferentialAction maps DCTX referential actions to SQL syntax
func (r *Reader) mapReferentialAction(action string) string {
switch strings.ToUpper(action) {
case "RESTRICT", "RESTRICT_SERVER":
return "RESTRICT"
case "CASCADE", "CASCADE_SERVER":
return "CASCADE"
case "SET_NULL", "SET_NULL_SERVER":
return "SET NULL"
case "SET_DEFAULT", "SET_DEFAULT_SERVER":
return "SET DEFAULT"
case "NO_ACTION", "NO_ACTION_SERVER":
return "NO ACTION"
default:
return "RESTRICT"
}
}
// sanitizeName sanitizes a name to lowercase
func (r *Reader) sanitizeName(name string) string {
return strings.ToLower(name)
}