diff --git a/pkg/common/recursive_crud.go b/pkg/common/recursive_crud.go index 49952ec..a6a80c9 100644 --- a/pkg/common/recursive_crud.go +++ b/pkg/common/recursive_crud.go @@ -235,9 +235,9 @@ func (p *NestedCUDProcessor) injectForeignKeys(data map[string]interface{}, mode } for parentKey, parentID := range parentIDs { - dbColName := reflection.GetForeignKeyColumn(modelType, parentKey) + dbColNames := reflection.GetForeignKeyColumn(modelType, parentKey) - if dbColName == "" { + if len(dbColNames) == 0 { // No explicit tag found — fall back to naming convention by scanning scalar fields. for i := 0; i < modelType.NumField(); i++ { field := modelType.Field(i) @@ -248,13 +248,13 @@ func (p *NestedCUDProcessor) injectForeignKeys(data map[string]interface{}, mode strings.EqualFold(jsonName, parentKey+"_id") || strings.EqualFold(jsonName, parentKey+"id") || strings.EqualFold(field.Name, parentKey+"ID") { - dbColName = reflection.GetColumnName(field) + dbColNames = []string{reflection.GetColumnName(field)} break } } } - if dbColName != "" { + for _, dbColName := range dbColNames { 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 0d303c8..03de859 100644 --- a/pkg/reflection/model_utils.go +++ b/pkg/reflection/model_utils.go @@ -973,21 +973,23 @@ func GetRelationType(model interface{}, fieldName string) RelationType { return RelationUnknown } -// GetForeignKeyColumn returns the DB column name of the foreign key that the -// relation field identified by parentKey owns on modelType. +// 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). // // 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 -// 3. Returns "" when no tag is found (caller should fall back to convention) +// 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] // // parentKey is matched case-insensitively against the field name and JSON tag. -func GetForeignKeyColumn(modelType reflect.Type, parentKey string) string { +func GetForeignKeyColumn(modelType reflect.Type, parentKey string) []string { for modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice { modelType = modelType.Elem() } if modelType.Kind() != reflect.Struct { - return "" + return nil } for i := 0; i < modelType.NumField(); i++ { @@ -999,34 +1001,42 @@ func GetForeignKeyColumn(modelType reflect.Type, parentKey string) string { continue } - // Bun: join:local_col=foreign_col + // Bun: join:local_col=foreign_col (one join: part per pair) + var bunCols []string for _, part := range strings.Split(field.Tag.Get("bun"), ",") { part = strings.TrimSpace(part) if strings.HasPrefix(part, "join:") { - // join: may contain multiple pairs separated by spaces: "join:a=b join:c=d" - // but typically it's a single pair; take the first local column pair := strings.TrimPrefix(part, "join:") if idx := strings.Index(pair, "="); idx > 0 { - return pair[:idx] + bunCols = append(bunCols, pair[:idx]) } } } + if len(bunCols) > 0 { + return bunCols + } - // GORM: foreignKey:FieldName + // GORM: foreignKey:FieldA,FieldB for _, part := range strings.Split(field.Tag.Get("gorm"), ";") { part = strings.TrimSpace(part) if strings.HasPrefix(part, "foreignKey:") { - fkFieldName := strings.TrimPrefix(part, "foreignKey:") - if fkField, ok := modelType.FieldByName(fkFieldName); ok { - return getColumnNameFromField(fkField) + var cols []string + for _, fkFieldName := range strings.Split(strings.TrimPrefix(part, "foreignKey:"), ",") { + fkFieldName = strings.TrimSpace(fkFieldName) + if fkField, ok := modelType.FieldByName(fkFieldName); ok { + cols = append(cols, getColumnNameFromField(fkField)) + } + } + if len(cols) > 0 { + return cols } } } - return "" + return nil } - return "" + return nil } // 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 48aee07..8be6cb9 100644 --- a/pkg/reflection/model_utils_foreign_key_test.go +++ b/pkg/reflection/model_utils_foreign_key_test.go @@ -15,6 +15,13 @@ type bunEmployee struct { Department *fkDept `bun:"rel:belongs-to,join:dept_id=id" json:"department"` } +// bunCompositeEmployee has a composite bun join: (two join: parts). +type bunCompositeEmployee struct { + DeptID string `bun:"dept_id" json:"dept_id"` + TenantID string `bun:"tenant_id" json:"tenant_id"` + Department *fkDept `bun:"rel:belongs-to,join:dept_id=id,join:tenant_id=id" json:"department"` +} + // gormEmployee uses gorm foreignKey: tag (mirrors testmodels.Employee). type gormEmployee struct { DepartmentID string `json:"department_id"` @@ -23,6 +30,13 @@ type gormEmployee struct { Manager *fkDept `gorm:"foreignKey:ManagerID;references:ID" json:"manager"` } +// gormCompositeEmployee has a composite GORM foreignKey. +type gormCompositeEmployee struct { + DeptID string `json:"dept_id"` + TenantID string `json:"tenant_id"` + Department *fkDept `gorm:"foreignKey:DeptID,TenantID" json:"department"` +} + // conventionEmployee has no explicit FK tag — relies on naming convention. type conventionEmployee struct { DepartmentID string `json:"department_id"` @@ -39,20 +53,26 @@ func TestGetForeignKeyColumn(t *testing.T) { name string modelType reflect.Type parentKey string - want string + want []string }{ // Bun join: tag { name: "bun join tag returns local column", modelType: reflect.TypeOf(bunEmployee{}), parentKey: "department", - want: "dept_id", + want: []string{"dept_id"}, }, { name: "bun join tag matched via json tag (case-insensitive)", modelType: reflect.TypeOf(bunEmployee{}), parentKey: "Department", - want: "dept_id", + want: []string{"dept_id"}, + }, + { + name: "bun composite join returns all local columns", + modelType: reflect.TypeOf(bunCompositeEmployee{}), + parentKey: "department", + want: []string{"dept_id", "tenant_id"}, }, // GORM foreignKey: tag @@ -60,19 +80,25 @@ func TestGetForeignKeyColumn(t *testing.T) { name: "gorm foreignKey resolves to column name", modelType: reflect.TypeOf(gormEmployee{}), parentKey: "department", - want: "department_id", + want: []string{"department_id"}, }, { name: "gorm foreignKey resolves second relation", modelType: reflect.TypeOf(gormEmployee{}), parentKey: "manager", - want: "manager_id", + want: []string{"manager_id"}, }, { name: "gorm foreignKey matched case-insensitively", modelType: reflect.TypeOf(gormEmployee{}), parentKey: "Department", - want: "department_id", + want: []string{"department_id"}, + }, + { + name: "gorm composite foreignKey returns all columns", + modelType: reflect.TypeOf(gormCompositeEmployee{}), + parentKey: "department", + want: []string{"dept_id", "tenant_id"}, }, // Pointer and slice unwrapping @@ -80,43 +106,43 @@ func TestGetForeignKeyColumn(t *testing.T) { name: "pointer to struct is unwrapped", modelType: reflect.TypeOf(&gormEmployee{}), parentKey: "department", - want: "department_id", + want: []string{"department_id"}, }, { name: "slice of struct is unwrapped", modelType: reflect.TypeOf([]gormEmployee{}), parentKey: "department", - want: "department_id", + want: []string{"department_id"}, }, - // No tag — returns "" so caller can fall back to convention + // No tag — returns nil so caller can fall back to convention { - name: "relation with no FK tag returns empty string", + name: "relation with no FK tag returns nil", modelType: reflect.TypeOf(conventionEmployee{}), parentKey: "department", - want: "", + want: nil, }, // Unknown parent key { - name: "unknown parent key returns empty string", + name: "unknown parent key returns nil", modelType: reflect.TypeOf(gormEmployee{}), parentKey: "nonexistent", - want: "", + want: nil, }, { - name: "non-struct type returns empty string", + name: "non-struct type returns nil", modelType: reflect.TypeOf(""), parentKey: "department", - want: "", + want: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := GetForeignKeyColumn(tt.modelType, tt.parentKey) - if got != tt.want { - t.Errorf("GetForeignKeyColumn(%v, %q) = %q, want %q", tt.modelType, tt.parentKey, got, tt.want) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetForeignKeyColumn(%v, %q) = %v, want %v", tt.modelType, tt.parentKey, got, tt.want) } }) }