From 4244e838b1dbfa6505a326bb45d66d4ae6cba48d Mon Sep 17 00:00:00 2001 From: Hein Date: Mon, 18 May 2026 13:03:07 +0200 Subject: [PATCH] fix(reflection): enhance GetForeignKeyColumn logic for self-referential models * Add support for self-referential models in GetForeignKeyColumn * Update comments for clarity on foreign key resolution strategies * Introduce selfRefItem struct for testing self-referential behavior --- pkg/common/recursive_crud.go | 5 ++ pkg/reflection/model_utils.go | 58 +++++++++++++++---- .../model_utils_foreign_key_test.go | 19 ++++++ 3 files changed, 71 insertions(+), 11 deletions(-) diff --git a/pkg/common/recursive_crud.go b/pkg/common/recursive_crud.go index a6a80c9..226c7db 100644 --- a/pkg/common/recursive_crud.go +++ b/pkg/common/recursive_crud.go @@ -234,6 +234,8 @@ func (p *NestedCUDProcessor) injectForeignKeys(data map[string]interface{}, mode return } + pkCol := reflection.GetPrimaryKeyName(reflect.New(modelType).Interface()) + for parentKey, parentID := range parentIDs { dbColNames := reflection.GetForeignKeyColumn(modelType, parentKey) @@ -255,6 +257,9 @@ func (p *NestedCUDProcessor) injectForeignKeys(data map[string]interface{}, mode } for _, dbColName := range dbColNames { + if pkCol != "" && strings.EqualFold(dbColName, pkCol) { + continue + } if _, exists := data[dbColName]; !exists { logger.Debug("Injecting foreign key: %s = %v", dbColName, parentID) data[dbColName] = parentID diff --git a/pkg/reflection/model_utils.go b/pkg/reflection/model_utils.go index 03de859..8c753f8 100644 --- a/pkg/reflection/model_utils.go +++ b/pkg/reflection/model_utils.go @@ -974,16 +974,21 @@ func GetRelationType(model interface{}, fieldName string) RelationType { } // GetForeignKeyColumn returns the DB column names of the foreign key(s) that -// the relation field identified by parentKey owns on modelType. Composite keys -// (e.g. bun "join:a=b,join:c=d" or GORM "foreignKey:ColA,ColB") yield multiple -// entries. Returns nil when no tag is found (caller should fall back to -// convention). +// relate parentKey to modelType. Composite keys (e.g. bun "join:a=b,join:c=d" +// or GORM "foreignKey:ColA,ColB") yield multiple entries. Returns nil when no +// tag is found (caller should fall back to convention). // -// It checks tags in priority order: -// 1. Bun join: tag — e.g. `bun:"rel:belongs-to,join:department_id=id"` → ["department_id"] -// 2. GORM foreignKey: tag — e.g. `gorm:"foreignKey:DepartmentID"` → [column of DepartmentID field] +// Two lookup strategies are tried in order: // -// parentKey is matched case-insensitively against the field name and JSON tag. +// 1. Relation-field match: find a field whose name/json equals parentKey, then +// read its bun join: or GORM foreignKey: tag and return the local columns. +// e.g. parentKey="department", field `Department bun:"join:dept_id=id"` → ["dept_id"] +// +// 2. Join left-side scan: scan every bun join tag in the struct for pairs whose +// left side equals parentKey and return the right-side (child FK) columns. +// e.g. parentKey="rid_mastertaskitem", field `Children bun:"join:rid_mastertaskitem=rid_parentmastertaskitem"` → ["rid_parentmastertaskitem"] +// Strategy 1 is skipped if the matched field is a declared relation (rel:) or +// has a GORM tag but carries no explicit FK — callers should use convention. func GetForeignKeyColumn(modelType reflect.Type, parentKey string) []string { for modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice { modelType = modelType.Elem() @@ -992,6 +997,7 @@ func GetForeignKeyColumn(modelType reflect.Type, parentKey string) []string { return nil } + // Strategy 1: match parentKey against a field's name/json tag. for i := 0; i < modelType.NumField(); i++ { field := modelType.Field(i) @@ -1001,9 +1007,11 @@ func GetForeignKeyColumn(modelType reflect.Type, parentKey string) []string { continue } + bunTag := field.Tag.Get("bun") + // Bun: join:local_col=foreign_col (one join: part per pair) var bunCols []string - for _, part := range strings.Split(field.Tag.Get("bun"), ",") { + for _, part := range strings.Split(bunTag, ",") { part = strings.TrimSpace(part) if strings.HasPrefix(part, "join:") { pair := strings.TrimPrefix(part, "join:") @@ -1033,10 +1041,38 @@ func GetForeignKeyColumn(modelType reflect.Type, parentKey string) []string { } } - return nil + // The field matched by name/json but has no explicit FK tag. If it is a + // declared relation field (rel:) or carries a GORM tag, the caller should + // use naming convention — don't fall through to strategy 2. Otherwise the + // matched field is a plain scalar column; proceed to the join left-side scan. + if strings.Contains(bunTag, "rel:") || field.Tag.Get("gorm") != "" { + return nil + } + break } - return nil + // Strategy 2: scan every field's bun join tag for pairs whose left side (the + // parent's column) matches parentKey; the right side is the child FK column. + // This handles cases where parentKey is a raw column name rather than a + // relation field name (e.g. self-referential or has-many relationships). + seen := map[string]bool{} + var cols []string + for i := 0; i < modelType.NumField(); i++ { + for _, part := range strings.Split(modelType.Field(i).Tag.Get("bun"), ",") { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, "join:") { + pair := strings.TrimPrefix(part, "join:") + if idx := strings.Index(pair, "="); idx > 0 { + left, right := pair[:idx], pair[idx+1:] + if strings.EqualFold(left, parentKey) && !seen[right] { + seen[right] = true + cols = append(cols, right) + } + } + } + } + } + return cols // nil if empty } // GetRelationModel gets the model type for a relation field diff --git a/pkg/reflection/model_utils_foreign_key_test.go b/pkg/reflection/model_utils_foreign_key_test.go index 8be6cb9..0eee7b1 100644 --- a/pkg/reflection/model_utils_foreign_key_test.go +++ b/pkg/reflection/model_utils_foreign_key_test.go @@ -37,6 +37,17 @@ type gormCompositeEmployee struct { Department *fkDept `gorm:"foreignKey:DeptID,TenantID" json:"department"` } +// selfRefItem mimics a self-referential model (like mastertaskitem) where the +// parent PK column appears as the left side of a has-many join tag. +type selfRefItem struct { + RidItem int32 `json:"rid_item" bun:"rid_item,type:integer,pk"` + RidParentItem int32 `json:"rid_parentitem" bun:"rid_parentitem,type:integer"` + // has-one (single parent pointer) + Parent *selfRefItem `json:"Parent,omitempty" bun:"rel:has-one,join:rid_item=rid_parentitem"` + // has-many (child collection) — same join, duplicate right-side must be deduped + Children []*selfRefItem `json:"Children,omitempty" bun:"rel:has-many,join:rid_item=rid_parentitem"` +} + // conventionEmployee has no explicit FK tag — relies on naming convention. type conventionEmployee struct { DepartmentID string `json:"department_id"` @@ -101,6 +112,14 @@ func TestGetForeignKeyColumn(t *testing.T) { want: []string{"dept_id", "tenant_id"}, }, + // Join left-side scan (parentKey is a raw column name, not a relation field name) + { + name: "self-referential: parent PK column returns child FK column", + modelType: reflect.TypeOf(selfRefItem{}), + parentKey: "rid_item", + want: []string{"rid_parentitem"}, + }, + // Pointer and slice unwrapping { name: "pointer to struct is unwrapped",