Files
relspecgo/pkg/writers/bun/template_data.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

418 lines
12 KiB
Go

package bun
import (
"encoding/json"
"fmt"
"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 SQL type (needs .Int64() call)
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
// ExtraFields are user-defined fields added via generator config (issue #4)
ExtraFields []*FieldData `json:"extra_fields,omitempty"`
}
// ExtraFieldConfig represents a custom field to be added to generated models
type ExtraFieldConfig struct {
Name string `json:"name"`
Type string `json:"type"`
BunTag string `json:"bun_tag,omitempty"`
JSONTag string `json:"json_tag,omitempty"`
Comment string `json:"comment,omitempty"`
TargetTable string `json:"target_table,omitempty"` // optional: if set, field only applies to this table
}
// LoadExtraFieldsFromMetadata loads custom field configurations from metadata map.
// Accepts either a JSON-encoded array of fields (for CLI use) or a structured list
// (for programmatic/sidecar file use). Each entry must have "name" and "type";
// an optional "target_table" scopes the field to one table only.
func LoadExtraFieldsFromMetadata(metadata map[string]interface{}) []ExtraFieldConfig {
if metadata == nil {
return nil
}
extraFieldsRaw, ok := metadata["extra_fields"]
if !ok || extraFieldsRaw == nil {
return nil
}
// Try to parse as JSON string first (from CLI flag)
if strVal, ok := extraFieldsRaw.(string); ok {
var fields []ExtraFieldConfig
if err := json.Unmarshal([]byte(strVal), &fields); err == nil && len(fields) > 0 {
return filterValidFields(fields)
}
}
// Try to parse as structured data (from sidecar file or programmatic API)
extraFieldsList, ok := extraFieldsRaw.([]interface{})
if !ok || len(extraFieldsList) == 0 {
return nil
}
fields := make([]ExtraFieldConfig, 0, len(extraFieldsList))
for _, item := range extraFieldsList {
if fieldMap, ok := item.(map[string]interface{}); ok {
field := ExtraFieldConfig{
Name: toString(fieldMap["name"]),
Type: toString(fieldMap["type"]),
BunTag: toString(fieldMap["bun_tag"]),
JSONTag: toString(fieldMap["json_tag"]),
Comment: toString(fieldMap["comment"]),
TargetTable: toString(fieldMap["target_table"]),
}
if field.Name != "" && field.Type != "" {
fields = append(fields, field)
}
}
}
return filterValidFields(fields)
}
// filterValidFields removes entries that are missing required fields.
func filterValidFields(fields []ExtraFieldConfig) []ExtraFieldConfig {
var valid []ExtraFieldConfig
for _, f := range fields {
if f.Name != "" && f.Type != "" {
valid = append(valid, f)
}
}
return valid
}
func toString(v interface{}) string {
if v == nil {
return ""
}
switch val := v.(type) {
case string:
return val
default:
return fmt.Sprintf("%v", val)
}
}
// FieldData represents a single field in a struct
type FieldData struct {
Name string // Go field name (PascalCase)
Type string // Go type
BunTag string // Complete bun 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)
model.IDColumnName = safeName
// Check if PK type is a SQL type (contains sql_types)
goType := typeMapper.SQLTypeToGoType(col.Type, col.NotNull)
model.PrimaryKeyType = goType
model.PrimaryKeyIsSQL = strings.Contains(goType, "sql_types")
model.PrimaryKeyIsStr = isStringLikePrimaryKeyType(goType)
model.PrimaryKeyIDType = "int64"
if model.PrimaryKeyIsStr {
model.PrimaryKeyIDType = "string"
}
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)
bunTag := typeMapper.BuildBunTag(col, table)
// Use same sanitized name for JSON tag
jsonTag := safeName
return &FieldData{
Name: fieldName,
Type: goType,
BunTag: bunTag,
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", "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
}