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.
577 lines
15 KiB
Go
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)
|
|
}
|