mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-01-21 18:14:27 +00:00
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.
This commit is contained in:
@@ -3,6 +3,9 @@ package common
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
||||
)
|
||||
|
||||
// ValidateAndUnwrapModelResult contains the result of model validation
|
||||
@@ -45,3 +48,216 @@ func ValidateAndUnwrapModel(model interface{}) (*ValidateAndUnwrapModelResult, e
|
||||
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())
|
||||
}
|
||||
|
||||
108
pkg/common/handler_utils_test.go
Normal file
108
pkg/common/handler_utils_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractTagValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tag string
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Extract existing key",
|
||||
tag: "json:name;validate:required",
|
||||
key: "json",
|
||||
expected: "name",
|
||||
},
|
||||
{
|
||||
name: "Extract key with spaces",
|
||||
tag: "json:name ; validate:required",
|
||||
key: "validate",
|
||||
expected: "required",
|
||||
},
|
||||
{
|
||||
name: "Extract key at end",
|
||||
tag: "json:name;validate:required;db:column_name",
|
||||
key: "db",
|
||||
expected: "column_name",
|
||||
},
|
||||
{
|
||||
name: "Extract key at beginning",
|
||||
tag: "primary:true;json:id;db:user_id",
|
||||
key: "primary",
|
||||
expected: "true",
|
||||
},
|
||||
{
|
||||
name: "Key not found",
|
||||
tag: "json:name;validate:required",
|
||||
key: "db",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Empty tag",
|
||||
tag: "",
|
||||
key: "json",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Single key-value pair",
|
||||
tag: "json:name",
|
||||
key: "json",
|
||||
expected: "name",
|
||||
},
|
||||
{
|
||||
name: "Key with empty value",
|
||||
tag: "json:;validate:required",
|
||||
key: "json",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Key with complex value",
|
||||
tag: "json:user_name,omitempty;validate:required,min=3",
|
||||
key: "json",
|
||||
expected: "user_name,omitempty",
|
||||
},
|
||||
{
|
||||
name: "Multiple semicolons",
|
||||
tag: "json:name;;validate:required",
|
||||
key: "validate",
|
||||
expected: "required",
|
||||
},
|
||||
{
|
||||
name: "BUN Tag with comma separator",
|
||||
tag: "rel:has-many,join:rid_hub=rid_hub_child",
|
||||
key: "join",
|
||||
expected: "rid_hub=rid_hub_child",
|
||||
},
|
||||
{
|
||||
name: "Extract foreignKey",
|
||||
tag: "foreignKey:UserID;references:ID",
|
||||
key: "foreignKey",
|
||||
expected: "UserID",
|
||||
},
|
||||
{
|
||||
name: "Extract references",
|
||||
tag: "foreignKey:UserID;references:ID",
|
||||
key: "references",
|
||||
expected: "ID",
|
||||
},
|
||||
{
|
||||
name: "Extract many2many",
|
||||
tag: "many2many:user_roles",
|
||||
key: "many2many",
|
||||
expected: "user_roles",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ExtractTagValue(tt.tag, tt.key)
|
||||
if result != tt.expected {
|
||||
t.Errorf("ExtractTagValue(%q, %q) = %q; want %q", tt.tag, tt.key, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -20,17 +20,6 @@ type RelationshipInfoProvider interface {
|
||||
GetRelationshipInfo(modelType reflect.Type, relationName string) *RelationshipInfo
|
||||
}
|
||||
|
||||
// RelationshipInfo contains information about a model relationship
|
||||
type RelationshipInfo struct {
|
||||
FieldName string
|
||||
JSONName string
|
||||
RelationType string // "belongsTo", "hasMany", "hasOne", "many2many"
|
||||
ForeignKey string
|
||||
References string
|
||||
JoinTable string
|
||||
RelatedModel interface{}
|
||||
}
|
||||
|
||||
// NestedCUDProcessor handles recursive processing of nested object graphs
|
||||
type NestedCUDProcessor struct {
|
||||
db Database
|
||||
|
||||
@@ -111,3 +111,14 @@ type TableMetadata struct {
|
||||
Columns []Column `json:"columns"`
|
||||
Relations []string `json:"relations"`
|
||||
}
|
||||
|
||||
// RelationshipInfo contains information about a model relationship
|
||||
type RelationshipInfo struct {
|
||||
FieldName string `json:"field_name"`
|
||||
JSONName string `json:"json_name"`
|
||||
RelationType string `json:"relation_type"` // "belongsTo", "hasMany", "hasOne", "many2many"
|
||||
ForeignKey string `json:"foreign_key"`
|
||||
References string `json:"references"`
|
||||
JoinTable string `json:"join_table"`
|
||||
RelatedModel interface{} `json:"related_model"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user