Files
ResolveSpec/pkg/common/handler_utils.go
Hein bf7125efc3 feat(reflection): add ExtractTagValue and GetRelationshipInfo functions
* Implement ExtractTagValue to handle struct tag parsing.
* Introduce GetRelationshipInfo for extracting relationship metadata.
* Update tests to validate new functionality.
* Refactor related code for improved clarity and maintainability.
2026-01-07 11:54:12 +02:00

264 lines
8.8 KiB
Go

package common
import (
"fmt"
"reflect"
"strings"
"github.com/bitechdev/ResolveSpec/pkg/logger"
)
// ValidateAndUnwrapModelResult contains the result of model validation
type ValidateAndUnwrapModelResult struct {
ModelType reflect.Type
Model interface{}
ModelPtr interface{}
OriginalType reflect.Type
}
// ValidateAndUnwrapModel validates that a model is a struct type and unwraps
// pointers, slices, and arrays to get to the base struct type.
// Returns an error if the model is not a valid struct type.
func ValidateAndUnwrapModel(model interface{}) (*ValidateAndUnwrapModelResult, error) {
modelType := reflect.TypeOf(model)
originalType := modelType
// Unwrap pointers, slices, and arrays to get to the base struct type
for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
modelType = modelType.Elem()
}
// Validate that we have a struct type
if modelType == nil || modelType.Kind() != reflect.Struct {
return nil, fmt.Errorf("model must be a struct type, got %v. Ensure you register the struct (e.g., ModelCoreAccount{}) not a slice (e.g., []*ModelCoreAccount)", originalType)
}
// If the registered model was a pointer or slice, use the unwrapped struct type
if originalType != modelType {
model = reflect.New(modelType).Elem().Interface()
}
// Create a pointer to the model type for database operations
modelPtr := reflect.New(reflect.TypeOf(model)).Interface()
return &ValidateAndUnwrapModelResult{
ModelType: modelType,
Model: model,
ModelPtr: modelPtr,
OriginalType: originalType,
}, nil
}
// ExtractTagValue extracts the value for a given key from a struct tag string.
// It handles both semicolon and comma-separated tag formats (e.g., GORM and BUN tags).
// For tags like "json:name;validate:required" it will extract "name" for key "json".
// For tags like "rel:has-many,join:table" it will extract "table" for key "join".
func ExtractTagValue(tag, key string) string {
// Split by both semicolons and commas to handle different tag formats
// We need to be smart about this - commas can be part of values
// So we'll try semicolon first, then comma if needed
separators := []string{";", ","}
for _, sep := range separators {
parts := strings.Split(tag, sep)
for _, part := range parts {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, key+":") {
return strings.TrimPrefix(part, key+":")
}
}
}
return ""
}
// GetRelationshipInfo analyzes a model type and extracts relationship metadata
// for a specific relation field identified by its JSON name.
// Returns nil if the field is not found or is not a valid relationship.
func GetRelationshipInfo(modelType reflect.Type, relationName string) *RelationshipInfo {
// Ensure we have a struct type
if modelType == nil || modelType.Kind() != reflect.Struct {
logger.Warn("Cannot get relationship info from non-struct type: %v", modelType)
return nil
}
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
jsonTag := field.Tag.Get("json")
jsonName := strings.Split(jsonTag, ",")[0]
if jsonName == relationName {
gormTag := field.Tag.Get("gorm")
bunTag := field.Tag.Get("bun")
info := &RelationshipInfo{
FieldName: field.Name,
JSONName: jsonName,
}
if strings.Contains(bunTag, "rel:") || strings.Contains(bunTag, "join:") {
//bun:"rel:has-many,join:rid_hub=rid_hub_division"
if strings.Contains(bunTag, "has-many") {
info.RelationType = "hasMany"
} else if strings.Contains(bunTag, "has-one") {
info.RelationType = "hasOne"
} else if strings.Contains(bunTag, "belongs-to") {
info.RelationType = "belongsTo"
} else if strings.Contains(bunTag, "many-to-many") {
info.RelationType = "many2many"
} else {
info.RelationType = "hasOne"
}
// Extract join info
joinPart := ExtractTagValue(bunTag, "join")
if joinPart != "" && info.RelationType == "many2many" {
// For many2many, the join part is the join table name
info.JoinTable = joinPart
} else if joinPart != "" {
// For other relations, parse foreignKey and references
joinParts := strings.Split(joinPart, "=")
if len(joinParts) == 2 {
info.ForeignKey = joinParts[0]
info.References = joinParts[1]
}
}
// Get related model type
if field.Type.Kind() == reflect.Slice {
elemType := field.Type.Elem()
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.RelatedModel = reflect.New(elemType).Elem().Interface()
}
} else if field.Type.Kind() == reflect.Ptr || field.Type.Kind() == reflect.Struct {
elemType := field.Type
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.RelatedModel = reflect.New(elemType).Elem().Interface()
}
}
return info
}
// Parse GORM tag to determine relationship type and keys
if strings.Contains(gormTag, "foreignKey") {
info.ForeignKey = ExtractTagValue(gormTag, "foreignKey")
info.References = ExtractTagValue(gormTag, "references")
// Determine if it's belongsTo or hasMany/hasOne
if field.Type.Kind() == reflect.Slice {
info.RelationType = "hasMany"
// Get the element type for slice
elemType := field.Type.Elem()
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.RelatedModel = reflect.New(elemType).Elem().Interface()
}
} else if field.Type.Kind() == reflect.Ptr || field.Type.Kind() == reflect.Struct {
info.RelationType = "belongsTo"
elemType := field.Type
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.RelatedModel = reflect.New(elemType).Elem().Interface()
}
}
} else if strings.Contains(gormTag, "many2many") {
info.RelationType = "many2many"
info.JoinTable = ExtractTagValue(gormTag, "many2many")
// Get the element type for many2many (always slice)
if field.Type.Kind() == reflect.Slice {
elemType := field.Type.Elem()
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.RelatedModel = reflect.New(elemType).Elem().Interface()
}
}
} else {
// Field has no GORM relationship tags, so it's not a relation
return nil
}
return info
}
}
return nil
}
// RelationPathToBunAlias converts a relation path (e.g., "Order.Customer") to a Bun alias format.
// It converts to lowercase and replaces dots with double underscores.
// For example: "Order.Customer" -> "order__customer"
func RelationPathToBunAlias(relationPath string) string {
if relationPath == "" {
return ""
}
// Convert to lowercase and replace dots with double underscores
alias := strings.ToLower(relationPath)
alias = strings.ReplaceAll(alias, ".", "__")
return alias
}
// ReplaceTableReferencesInSQL replaces references to a base table name in a SQL expression
// with the appropriate alias for the current preload level.
// For example, if baseTableName is "mastertaskitem" and targetAlias is "mal__mal",
// it will replace "mastertaskitem.rid_mastertaskitem" with "mal__mal.rid_mastertaskitem"
func ReplaceTableReferencesInSQL(sqlExpr, baseTableName, targetAlias string) string {
if sqlExpr == "" || baseTableName == "" || targetAlias == "" {
return sqlExpr
}
// Replace both quoted and unquoted table references
// Handle patterns like: tablename.column, "tablename".column, tablename."column", "tablename"."column"
// Pattern 1: tablename.column (unquoted)
result := strings.ReplaceAll(sqlExpr, baseTableName+".", targetAlias+".")
// Pattern 2: "tablename".column or "tablename"."column" (quoted table name)
result = strings.ReplaceAll(result, "\""+baseTableName+"\".", "\""+targetAlias+"\".")
return result
}
// GetTableNameFromModel extracts the table name from a model.
// It checks the bun tag first, then falls back to converting the struct name to snake_case.
func GetTableNameFromModel(model interface{}) string {
if model == nil {
return ""
}
modelType := reflect.TypeOf(model)
// Unwrap pointers
for modelType != nil && modelType.Kind() == reflect.Ptr {
modelType = modelType.Elem()
}
if modelType == nil || modelType.Kind() != reflect.Struct {
return ""
}
// Look for bun tag on embedded BaseModel
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
if field.Anonymous {
bunTag := field.Tag.Get("bun")
if strings.HasPrefix(bunTag, "table:") {
return strings.TrimPrefix(bunTag, "table:")
}
}
}
// Fallback: convert struct name to lowercase (simple heuristic)
// This handles cases like "MasterTaskItem" -> "mastertaskitem"
return strings.ToLower(modelType.Name())
}