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.
331 lines
9.5 KiB
Go
331 lines
9.5 KiB
Go
package gorm
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
|
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
|
)
|
|
|
|
// TemplateData represents the data passed to the template for code generation
|
|
type TemplateData struct {
|
|
PackageName string
|
|
Imports []string
|
|
Models []*ModelData
|
|
Config *MethodConfig
|
|
}
|
|
|
|
// ModelData represents a single model/struct in the template
|
|
type ModelData struct {
|
|
Name string
|
|
TableName string // schema.table format
|
|
SchemaName string
|
|
TableNameOnly string // just table name without schema
|
|
Comment string
|
|
Fields []*FieldData
|
|
Config *MethodConfig
|
|
PrimaryKeyField string // Name of the primary key field
|
|
PrimaryKeyType string // Go type of the primary key field
|
|
PrimaryKeyIsSQL bool // Whether PK uses a SQL wrapper type
|
|
PrimaryKeyIsStr bool // Whether helper methods should use string IDs
|
|
PrimaryKeyIDType string // Helper method GetID/SetID/UpdateID type
|
|
IDColumnName string // Name of the ID column in database
|
|
Prefix string // 3-letter prefix
|
|
}
|
|
|
|
// FieldData represents a single field in a struct
|
|
type FieldData struct {
|
|
Name string // Go field name (PascalCase)
|
|
Type string // Go type
|
|
GormTag string // Complete gorm tag
|
|
JSONTag string // JSON tag
|
|
Comment string // Field comment
|
|
}
|
|
|
|
// MethodConfig controls which helper methods to generate
|
|
type MethodConfig struct {
|
|
GenerateTableName bool
|
|
GenerateSchemaName bool
|
|
GenerateTableNameOnly bool
|
|
GenerateGetID bool
|
|
GenerateGetIDStr bool
|
|
GenerateSetID bool
|
|
GenerateUpdateID bool
|
|
GenerateGetIDName bool
|
|
GenerateGetPrefix bool
|
|
}
|
|
|
|
// DefaultMethodConfig returns a MethodConfig with all methods enabled
|
|
func DefaultMethodConfig() *MethodConfig {
|
|
return &MethodConfig{
|
|
GenerateTableName: true,
|
|
GenerateSchemaName: true,
|
|
GenerateTableNameOnly: true,
|
|
GenerateGetID: true,
|
|
GenerateGetIDStr: true,
|
|
GenerateSetID: true,
|
|
GenerateUpdateID: true,
|
|
GenerateGetIDName: true,
|
|
GenerateGetPrefix: true,
|
|
}
|
|
}
|
|
|
|
// NewTemplateData creates a new TemplateData with the given package name and config
|
|
func NewTemplateData(packageName string, config *MethodConfig) *TemplateData {
|
|
if config == nil {
|
|
config = DefaultMethodConfig()
|
|
}
|
|
|
|
return &TemplateData{
|
|
PackageName: packageName,
|
|
Imports: make([]string, 0),
|
|
Models: make([]*ModelData, 0),
|
|
Config: config,
|
|
}
|
|
}
|
|
|
|
// AddModel adds a model to the template data
|
|
func (td *TemplateData) AddModel(model *ModelData) {
|
|
model.Config = td.Config
|
|
td.Models = append(td.Models, model)
|
|
}
|
|
|
|
// AddImport adds an import to the template data (deduplicates automatically)
|
|
func (td *TemplateData) AddImport(importPath string) {
|
|
// Check if already exists
|
|
for _, imp := range td.Imports {
|
|
if imp == importPath {
|
|
return
|
|
}
|
|
}
|
|
td.Imports = append(td.Imports, importPath)
|
|
}
|
|
|
|
// FinalizeImports sorts and organizes imports
|
|
func (td *TemplateData) FinalizeImports() {
|
|
// Sort imports alphabetically
|
|
sort.Strings(td.Imports)
|
|
}
|
|
|
|
// NewModelData creates a new ModelData from a models.Table
|
|
func NewModelData(table *models.Table, schema string, typeMapper *TypeMapper, flattenSchema bool) *ModelData {
|
|
tableName := writers.QualifiedTableName(schema, table.Name, flattenSchema)
|
|
|
|
// Generate model name: Model + Schema + Table (all PascalCase)
|
|
tablePart := SnakeCaseToPascalCase(table.Name)
|
|
|
|
// Include schema name in model name
|
|
var modelName string
|
|
if schema != "" {
|
|
schemaPart := SnakeCaseToPascalCase(schema)
|
|
modelName = "Model" + schemaPart + tablePart
|
|
} else {
|
|
modelName = "Model" + tablePart
|
|
}
|
|
|
|
model := &ModelData{
|
|
Name: modelName,
|
|
TableName: tableName,
|
|
SchemaName: schema,
|
|
TableNameOnly: table.Name,
|
|
Comment: formatComment(table.Description, table.Comment),
|
|
Fields: make([]*FieldData, 0),
|
|
Prefix: GeneratePrefix(table.Name),
|
|
}
|
|
|
|
// Convert columns to fields (sorted by sequence or name)
|
|
columns := sortColumns(table.Columns)
|
|
|
|
// Find primary key
|
|
for _, col := range columns {
|
|
if col.IsPrimaryKey {
|
|
// Sanitize column name to remove backticks
|
|
safeName := writers.SanitizeStructTagValue(col.Name)
|
|
model.PrimaryKeyField = SnakeCaseToPascalCase(safeName)
|
|
goType := typeMapper.SQLTypeToGoType(col.Type, col.NotNull)
|
|
model.PrimaryKeyType = goType
|
|
model.PrimaryKeyIsSQL = strings.Contains(goType, "sql_types.") || strings.Contains(goType, "sql.")
|
|
model.PrimaryKeyIsStr = isStringLikePrimaryKeyType(goType)
|
|
model.PrimaryKeyIDType = "int64"
|
|
if model.PrimaryKeyIsStr {
|
|
model.PrimaryKeyIDType = "string"
|
|
}
|
|
model.IDColumnName = safeName
|
|
break
|
|
}
|
|
}
|
|
|
|
for _, col := range columns {
|
|
field := columnToField(col, table, typeMapper)
|
|
// Check for name collision with generated methods and rename if needed
|
|
field.Name = resolveFieldNameCollision(field.Name)
|
|
model.Fields = append(model.Fields, field)
|
|
}
|
|
|
|
return model
|
|
}
|
|
|
|
// columnToField converts a models.Column to FieldData
|
|
func columnToField(col *models.Column, table *models.Table, typeMapper *TypeMapper) *FieldData {
|
|
// Sanitize column name first to remove backticks before generating field name
|
|
safeName := writers.SanitizeStructTagValue(col.Name)
|
|
fieldName := SnakeCaseToPascalCase(safeName)
|
|
goType := typeMapper.SQLTypeToGoType(col.Type, col.NotNull)
|
|
gormTag := typeMapper.BuildGormTag(col, table)
|
|
// Use same sanitized name for JSON tag
|
|
jsonTag := safeName
|
|
|
|
return &FieldData{
|
|
Name: fieldName,
|
|
Type: goType,
|
|
GormTag: gormTag,
|
|
JSONTag: jsonTag,
|
|
Comment: formatComment(col.Description, col.Comment),
|
|
}
|
|
}
|
|
|
|
// AddRelationshipField adds a relationship field to the model
|
|
func (md *ModelData) AddRelationshipField(field *FieldData) {
|
|
md.Fields = append(md.Fields, field)
|
|
}
|
|
|
|
// formatComment combines description and comment into a single comment string
|
|
func formatComment(description, comment string) string {
|
|
if description != "" && comment != "" {
|
|
return description + " - " + comment
|
|
}
|
|
if description != "" {
|
|
return description
|
|
}
|
|
return comment
|
|
}
|
|
|
|
func isStringLikePrimaryKeyType(goType string) bool {
|
|
switch goType {
|
|
case "string", "*string", "sql.NullString", "sql_types.SqlString", "sql_types.SqlUUID":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// resolveFieldNameCollision checks if a field name conflicts with generated method names
|
|
// and adds an underscore suffix if there's a collision
|
|
func resolveFieldNameCollision(fieldName string) string {
|
|
// List of method names that are generated by the template
|
|
reservedNames := map[string]bool{
|
|
"TableName": true,
|
|
"TableNameOnly": true,
|
|
"SchemaName": true,
|
|
"GetID": true,
|
|
"GetIDStr": true,
|
|
"SetID": true,
|
|
"UpdateID": true,
|
|
"GetIDName": true,
|
|
"GetPrefix": true,
|
|
}
|
|
|
|
// Check if field name conflicts with a reserved method name
|
|
if reservedNames[fieldName] {
|
|
return fieldName + "_"
|
|
}
|
|
|
|
return fieldName
|
|
}
|
|
|
|
// sortConstraints sorts constraints by sequence, then by name
|
|
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 sorts indexes by sequence, then by name
|
|
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
|
|
}
|
|
|
|
// sortColumns sorts columns by sequence, then by name
|
|
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 {
|
|
// Sort by sequence if both have it
|
|
if result[i].Sequence > 0 && result[j].Sequence > 0 {
|
|
return result[i].Sequence < result[j].Sequence
|
|
}
|
|
|
|
// Put primary keys first
|
|
if result[i].IsPrimaryKey != result[j].IsPrimaryKey {
|
|
return result[i].IsPrimaryKey
|
|
}
|
|
|
|
// Otherwise sort alphabetically
|
|
return result[i].Name < result[j].Name
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
// LoadMethodConfigFromMetadata loads method configuration from metadata map
|
|
func LoadMethodConfigFromMetadata(metadata map[string]interface{}) *MethodConfig {
|
|
config := DefaultMethodConfig()
|
|
|
|
if metadata == nil {
|
|
return config
|
|
}
|
|
|
|
// Load each setting from metadata if present
|
|
if val, ok := metadata["generate_table_name"].(bool); ok {
|
|
config.GenerateTableName = val
|
|
}
|
|
if val, ok := metadata["generate_schema_name"].(bool); ok {
|
|
config.GenerateSchemaName = val
|
|
}
|
|
if val, ok := metadata["generate_table_name_only"].(bool); ok {
|
|
config.GenerateTableNameOnly = val
|
|
}
|
|
if val, ok := metadata["generate_get_id"].(bool); ok {
|
|
config.GenerateGetID = val
|
|
}
|
|
if val, ok := metadata["generate_get_id_str"].(bool); ok {
|
|
config.GenerateGetIDStr = val
|
|
}
|
|
if val, ok := metadata["generate_set_id"].(bool); ok {
|
|
config.GenerateSetID = val
|
|
}
|
|
if val, ok := metadata["generate_update_id"].(bool); ok {
|
|
config.GenerateUpdateID = val
|
|
}
|
|
if val, ok := metadata["generate_get_id_name"].(bool); ok {
|
|
config.GenerateGetIDName = val
|
|
}
|
|
if val, ok := metadata["generate_get_prefix"].(bool); ok {
|
|
config.GenerateGetPrefix = val
|
|
}
|
|
|
|
return config
|
|
}
|