fix(reflection): update GetForeignKeyColumn to return multiple columns

* Change return type to []string for composite keys
* Adjust related logic in injectForeignKeys method
* Update tests to validate new behavior for composite foreign keys
This commit is contained in:
Hein
2026-05-18 12:39:06 +02:00
parent 85bb0f7874
commit c13df30d09
3 changed files with 74 additions and 38 deletions
+4 -4
View File
@@ -235,9 +235,9 @@ func (p *NestedCUDProcessor) injectForeignKeys(data map[string]interface{}, mode
} }
for parentKey, parentID := range parentIDs { 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. // No explicit tag found — fall back to naming convention by scanning scalar fields.
for i := 0; i < modelType.NumField(); i++ { for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(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(jsonName, parentKey+"id") || strings.EqualFold(jsonName, parentKey+"id") ||
strings.EqualFold(field.Name, parentKey+"ID") { strings.EqualFold(field.Name, parentKey+"ID") {
dbColName = reflection.GetColumnName(field) dbColNames = []string{reflection.GetColumnName(field)}
break break
} }
} }
} }
if dbColName != "" { for _, dbColName := range dbColNames {
if _, exists := data[dbColName]; !exists { if _, exists := data[dbColName]; !exists {
logger.Debug("Injecting foreign key: %s = %v", dbColName, parentID) logger.Debug("Injecting foreign key: %s = %v", dbColName, parentID)
data[dbColName] = parentID data[dbColName] = parentID
+27 -17
View File
@@ -973,21 +973,23 @@ func GetRelationType(model interface{}, fieldName string) RelationType {
return RelationUnknown return RelationUnknown
} }
// GetForeignKeyColumn returns the DB column name of the foreign key that the // GetForeignKeyColumn returns the DB column names of the foreign key(s) that
// relation field identified by parentKey owns on modelType. // 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: // It checks tags in priority order:
// 1. Bun join: tag — e.g. `bun:"rel:belongs-to,join:department_id=id"` → "department_id" // 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 // 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)
// //
// parentKey is matched case-insensitively against the field name and JSON tag. // 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 { for modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice {
modelType = modelType.Elem() modelType = modelType.Elem()
} }
if modelType.Kind() != reflect.Struct { if modelType.Kind() != reflect.Struct {
return "" return nil
} }
for i := 0; i < modelType.NumField(); i++ { for i := 0; i < modelType.NumField(); i++ {
@@ -999,34 +1001,42 @@ func GetForeignKeyColumn(modelType reflect.Type, parentKey string) string {
continue 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"), ",") { for _, part := range strings.Split(field.Tag.Get("bun"), ",") {
part = strings.TrimSpace(part) part = strings.TrimSpace(part)
if strings.HasPrefix(part, "join:") { 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:") pair := strings.TrimPrefix(part, "join:")
if idx := strings.Index(pair, "="); idx > 0 { 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"), ";") { for _, part := range strings.Split(field.Tag.Get("gorm"), ";") {
part = strings.TrimSpace(part) part = strings.TrimSpace(part)
if strings.HasPrefix(part, "foreignKey:") { if strings.HasPrefix(part, "foreignKey:") {
fkFieldName := strings.TrimPrefix(part, "foreignKey:") var cols []string
if fkField, ok := modelType.FieldByName(fkFieldName); ok { for _, fkFieldName := range strings.Split(strings.TrimPrefix(part, "foreignKey:"), ",") {
return getColumnNameFromField(fkField) 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 // GetRelationModel gets the model type for a relation field
+43 -17
View File
@@ -15,6 +15,13 @@ type bunEmployee struct {
Department *fkDept `bun:"rel:belongs-to,join:dept_id=id" json:"department"` 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). // gormEmployee uses gorm foreignKey: tag (mirrors testmodels.Employee).
type gormEmployee struct { type gormEmployee struct {
DepartmentID string `json:"department_id"` DepartmentID string `json:"department_id"`
@@ -23,6 +30,13 @@ type gormEmployee struct {
Manager *fkDept `gorm:"foreignKey:ManagerID;references:ID" json:"manager"` 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. // conventionEmployee has no explicit FK tag — relies on naming convention.
type conventionEmployee struct { type conventionEmployee struct {
DepartmentID string `json:"department_id"` DepartmentID string `json:"department_id"`
@@ -39,20 +53,26 @@ func TestGetForeignKeyColumn(t *testing.T) {
name string name string
modelType reflect.Type modelType reflect.Type
parentKey string parentKey string
want string want []string
}{ }{
// Bun join: tag // Bun join: tag
{ {
name: "bun join tag returns local column", name: "bun join tag returns local column",
modelType: reflect.TypeOf(bunEmployee{}), modelType: reflect.TypeOf(bunEmployee{}),
parentKey: "department", parentKey: "department",
want: "dept_id", want: []string{"dept_id"},
}, },
{ {
name: "bun join tag matched via json tag (case-insensitive)", name: "bun join tag matched via json tag (case-insensitive)",
modelType: reflect.TypeOf(bunEmployee{}), modelType: reflect.TypeOf(bunEmployee{}),
parentKey: "Department", 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 // GORM foreignKey: tag
@@ -60,19 +80,25 @@ func TestGetForeignKeyColumn(t *testing.T) {
name: "gorm foreignKey resolves to column name", name: "gorm foreignKey resolves to column name",
modelType: reflect.TypeOf(gormEmployee{}), modelType: reflect.TypeOf(gormEmployee{}),
parentKey: "department", parentKey: "department",
want: "department_id", want: []string{"department_id"},
}, },
{ {
name: "gorm foreignKey resolves second relation", name: "gorm foreignKey resolves second relation",
modelType: reflect.TypeOf(gormEmployee{}), modelType: reflect.TypeOf(gormEmployee{}),
parentKey: "manager", parentKey: "manager",
want: "manager_id", want: []string{"manager_id"},
}, },
{ {
name: "gorm foreignKey matched case-insensitively", name: "gorm foreignKey matched case-insensitively",
modelType: reflect.TypeOf(gormEmployee{}), modelType: reflect.TypeOf(gormEmployee{}),
parentKey: "Department", 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 // Pointer and slice unwrapping
@@ -80,43 +106,43 @@ func TestGetForeignKeyColumn(t *testing.T) {
name: "pointer to struct is unwrapped", name: "pointer to struct is unwrapped",
modelType: reflect.TypeOf(&gormEmployee{}), modelType: reflect.TypeOf(&gormEmployee{}),
parentKey: "department", parentKey: "department",
want: "department_id", want: []string{"department_id"},
}, },
{ {
name: "slice of struct is unwrapped", name: "slice of struct is unwrapped",
modelType: reflect.TypeOf([]gormEmployee{}), modelType: reflect.TypeOf([]gormEmployee{}),
parentKey: "department", 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{}), modelType: reflect.TypeOf(conventionEmployee{}),
parentKey: "department", parentKey: "department",
want: "", want: nil,
}, },
// Unknown parent key // Unknown parent key
{ {
name: "unknown parent key returns empty string", name: "unknown parent key returns nil",
modelType: reflect.TypeOf(gormEmployee{}), modelType: reflect.TypeOf(gormEmployee{}),
parentKey: "nonexistent", parentKey: "nonexistent",
want: "", want: nil,
}, },
{ {
name: "non-struct type returns empty string", name: "non-struct type returns nil",
modelType: reflect.TypeOf(""), modelType: reflect.TypeOf(""),
parentKey: "department", parentKey: "department",
want: "", want: nil,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got := GetForeignKeyColumn(tt.modelType, tt.parentKey) got := GetForeignKeyColumn(tt.modelType, tt.parentKey)
if got != tt.want { if !reflect.DeepEqual(got, tt.want) {
t.Errorf("GetForeignKeyColumn(%v, %q) = %q, want %q", tt.modelType, tt.parentKey, got, tt.want) t.Errorf("GetForeignKeyColumn(%v, %q) = %v, want %v", tt.modelType, tt.parentKey, got, tt.want)
} }
}) })
} }