fix(09): WR-16 resolve model columns through embedded structs and explicit column tags
This commit is contained in:
@@ -154,7 +154,7 @@ func (p *BlogPlugin) Settings() []pact.SettingsItem {
|
|||||||
summer make:admin-controller acme.blog Posts
|
summer make:admin-controller acme.blog Posts
|
||||||
```
|
```
|
||||||
|
|
||||||
The generated controller requires the permission `acme.blog.access_posts`, so declare it in the plugin's `pact.HasPermissions` or the admin API refuses to start with an unknown-permission error. Its `NewRecord` returns `nil` until you return the model, and the list and every write answer 500 until then. The controller ID maps to the admin API path: `acme.blog.posts` is served under `<prefix>/api/v1/acme/blog/posts`. The admin prefix is `backend.uri`, `/backend` by default. A model's `Fillable` method decides which form fields the API may write; see [Forms](forms.md).
|
The generated controller requires the permission `acme.blog.access_posts`, so declare it in the plugin's `pact.HasPermissions` or the admin API refuses to start with an unknown-permission error. Its `NewRecord` returns `nil` until you return the model, and the list and every write answer 500 until then. The controller ID maps to the admin API path: `acme.blog.posts` is served under `<prefix>/api/v1/acme/blog/posts`. The admin prefix is `backend.uri`, `/backend` by default. A model's `Fillable` method decides which form fields the API may write; see [Forms](forms.md). The admin finds a model's columns through its struct fields, including those of an embedded struct such as `gorm.Model`. A field with a `gorm:"column:..."` tag is known by that column alone; an untagged field is known by GORM's default column name.
|
||||||
|
|
||||||
## Compilation at boot
|
## Compilation at boot
|
||||||
|
|
||||||
|
|||||||
@@ -785,21 +785,9 @@ func projectRecord(cc *CompiledController, model any) map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func modelColumns(model any) map[string]struct{} {
|
func modelColumns(model any) map[string]struct{} {
|
||||||
t := reflect.TypeOf(model)
|
|
||||||
for t != nil && t.Kind() == reflect.Pointer {
|
|
||||||
t = t.Elem()
|
|
||||||
}
|
|
||||||
cols := map[string]struct{}{}
|
cols := map[string]struct{}{}
|
||||||
if t == nil || t.Kind() != reflect.Struct {
|
for _, mf := range modelFields(reflect.TypeOf(model)) {
|
||||||
return cols
|
if name := gormColumn(mf.Field); name != "" {
|
||||||
}
|
|
||||||
for i := 0; i < t.NumField(); i++ {
|
|
||||||
field := t.Field(i)
|
|
||||||
if field.PkgPath != "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := gormColumn(field)
|
|
||||||
if name != "" {
|
|
||||||
cols[name] = struct{}{}
|
cols[name] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -873,9 +861,9 @@ func castPK(model any, n uint) any {
|
|||||||
if t == nil || t.Kind() != reflect.Struct {
|
if t == nil || t.Kind() != reflect.Struct {
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
for i := 0; i < t.NumField(); i++ {
|
for _, mf := range modelFields(t) {
|
||||||
field := t.Field(i)
|
field := mf.Field
|
||||||
if !strings.Contains(field.Tag.Get("gorm"), "primaryKey") {
|
if !hasPrimaryKeyTag(field) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch field.Type.Kind() {
|
switch field.Type.Kind() {
|
||||||
|
|||||||
@@ -319,9 +319,9 @@ func modelColumnType(ctl pact.AdminController, column string) (reflect.Type, boo
|
|||||||
if t == nil || t.Kind() != reflect.Struct {
|
if t == nil || t.Kind() != reflect.Struct {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
for i := 0; i < t.NumField(); i++ {
|
for _, mf := range modelFields(t) {
|
||||||
field := t.Field(i)
|
field := mf.Field
|
||||||
if field.PkgPath != "" || isListRelation(field.Type) {
|
if isListRelation(field.Type) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := gormColumn(field)
|
name := gormColumn(field)
|
||||||
|
|||||||
@@ -842,21 +842,38 @@ func projectRow(row any, controller pact.AdminController, cols []ListColumn) map
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fieldByColumn returns the model field stored in column, looking through
|
||||||
|
// embedded structs such as gorm.Model. A field with an explicit `column:` tag is
|
||||||
|
// matched by that tag alone; only an untagged field falls back to its Go name
|
||||||
|
// (case-insensitive) or GORM's default column name for it, and a shallower
|
||||||
|
// field shadows an embedded one, as in Go.
|
||||||
func fieldByColumn(v reflect.Value, column string) reflect.Value {
|
func fieldByColumn(v reflect.Value, column string) reflect.Value {
|
||||||
if v.Kind() != reflect.Struct {
|
if v.Kind() != reflect.Struct {
|
||||||
return reflect.Value{}
|
return reflect.Value{}
|
||||||
}
|
}
|
||||||
t := v.Type()
|
fields := modelFields(v.Type())
|
||||||
for i := 0; i < t.NumField(); i++ {
|
best := -1
|
||||||
field := t.Field(i)
|
for i := range fields {
|
||||||
if field.PkgPath != "" {
|
if gormColumn(fields[i].Field) == column && (best < 0 || len(fields[i].Path) < len(fields[best].Path)) {
|
||||||
continue
|
best = i
|
||||||
}
|
|
||||||
if gormColumn(field) == column || strings.EqualFold(field.Name, column) {
|
|
||||||
return v.Field(i)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return reflect.Value{}
|
if best < 0 {
|
||||||
|
for i := range fields {
|
||||||
|
untagged := gormColumn(fields[i].Field) == ""
|
||||||
|
if untagged && (strings.EqualFold(fields[i].Field.Name, column) || defaultColumnName(fields[i].Field) == column) && (best < 0 || len(fields[i].Path) < len(fields[best].Path)) {
|
||||||
|
best = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best < 0 {
|
||||||
|
return reflect.Value{}
|
||||||
|
}
|
||||||
|
field, err := v.FieldByIndexErr(fields[best].Path)
|
||||||
|
if err != nil {
|
||||||
|
return reflect.Value{}
|
||||||
|
}
|
||||||
|
return field
|
||||||
}
|
}
|
||||||
|
|
||||||
func gormColumn(field reflect.StructField) string {
|
func gormColumn(field reflect.StructField) string {
|
||||||
|
|||||||
@@ -397,11 +397,8 @@ func listModelContract(ctl pact.AdminController) (map[string]struct{}, map[strin
|
|||||||
}
|
}
|
||||||
cols := map[string]struct{}{}
|
cols := map[string]struct{}{}
|
||||||
rels := map[string]struct{}{}
|
rels := map[string]struct{}{}
|
||||||
for i := 0; i < t.NumField(); i++ {
|
for _, mf := range modelFields(t) {
|
||||||
field := t.Field(i)
|
field := mf.Field
|
||||||
if field.PkgPath != "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if isListRelation(field.Type) {
|
if isListRelation(field.Type) {
|
||||||
rels[field.Name] = struct{}{}
|
rels[field.Name] = struct{}{}
|
||||||
// Winter YAML spells relations in lowercase (relation: genre)
|
// Winter YAML spells relations in lowercase (relation: genre)
|
||||||
|
|||||||
98
modules/cabana/model_fields.go
Normal file
98
modules/cabana/model_fields.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"database/sql/driver"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
// modelField is an exported struct field of a model together with its index
|
||||||
|
// path from the model type, so a field promoted from an embedded struct
|
||||||
|
// (gorm.Model, a shared timestamps struct) is reachable like a top-level one.
|
||||||
|
type modelField struct {
|
||||||
|
Field reflect.StructField
|
||||||
|
Path []int
|
||||||
|
}
|
||||||
|
|
||||||
|
var modelFieldCache sync.Map // reflect.Type -> []modelField
|
||||||
|
|
||||||
|
// modelFields lists the exported fields of the struct type behind t in
|
||||||
|
// declaration order, with anonymous (embedded) structs flattened in place.
|
||||||
|
// Embedded scalar-like structs (time.Time, sql.Null*, anything that is a
|
||||||
|
// Scanner or Valuer) stay single fields. It returns nil for a non-struct.
|
||||||
|
func modelFields(t reflect.Type) []modelField {
|
||||||
|
for t != nil && t.Kind() == reflect.Pointer {
|
||||||
|
t = t.Elem()
|
||||||
|
}
|
||||||
|
if t == nil || t.Kind() != reflect.Struct {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cached, ok := modelFieldCache.Load(t); ok {
|
||||||
|
return cached.([]modelField)
|
||||||
|
}
|
||||||
|
fields := flattenModelFields(t, nil, 0)
|
||||||
|
modelFieldCache.Store(t, fields)
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
func flattenModelFields(t reflect.Type, prefix []int, depth int) []modelField {
|
||||||
|
var out []modelField
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
f := t.Field(i)
|
||||||
|
path := append(append([]int(nil), prefix...), i)
|
||||||
|
if f.Anonymous && depth < 5 {
|
||||||
|
if inner := embeddedStructType(f.Type); inner != nil {
|
||||||
|
out = append(out, flattenModelFields(inner, path, depth+1)...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.PkgPath != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, modelField{Field: f, Path: path})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
scannerType = reflect.TypeOf((*sql.Scanner)(nil)).Elem()
|
||||||
|
valuerType = reflect.TypeOf((*driver.Valuer)(nil)).Elem()
|
||||||
|
timeType = reflect.TypeOf(time.Time{})
|
||||||
|
)
|
||||||
|
|
||||||
|
// embeddedStructType returns the struct type to flatten for an embedded field,
|
||||||
|
// or nil when the field is a single value.
|
||||||
|
func embeddedStructType(t reflect.Type) reflect.Type {
|
||||||
|
if t.Kind() == reflect.Pointer {
|
||||||
|
t = t.Elem()
|
||||||
|
}
|
||||||
|
if t.Kind() != reflect.Struct || t == timeType {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ptr := reflect.PointerTo(t)
|
||||||
|
if ptr.Implements(scannerType) || t.Implements(valuerType) || ptr.Implements(valuerType) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasPrimaryKeyTag reports whether a field's gorm tag declares it the primary
|
||||||
|
// key. GORM reads the key case-insensitively (gorm.Model writes "primarykey").
|
||||||
|
func hasPrimaryKeyTag(field reflect.StructField) bool {
|
||||||
|
for _, part := range strings.Split(field.Tag.Get("gorm"), ";") {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(part), "primarykey") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultColumnName is GORM's default column for an untagged field.
|
||||||
|
func defaultColumnName(field reflect.StructField) string {
|
||||||
|
return schema.NamingStrategy{}.ColumnName("", field.Name)
|
||||||
|
}
|
||||||
86
modules/cabana/model_fields_test.go
Normal file
86
modules/cabana/model_fields_test.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type embeddedBase struct {
|
||||||
|
ID uint `gorm:"primarykey"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type embeddedModel struct {
|
||||||
|
embeddedBase
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gormModelRow struct {
|
||||||
|
gorm.Model
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type retaggedModel struct {
|
||||||
|
Name string `gorm:"column:title"`
|
||||||
|
Title string `gorm:"column:name"`
|
||||||
|
Plain string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModelHelpersLookThroughEmbeddedStructs pins WR-16: the reflection helpers
|
||||||
|
// that resolve columns see fields promoted from an embedded struct such as
|
||||||
|
// gorm.Model, so list rows keep their id and timestamps and a relation link
|
||||||
|
// writes the real owner key instead of 0.
|
||||||
|
func TestModelHelpersLookThroughEmbeddedStructs(t *testing.T) {
|
||||||
|
row := &embeddedModel{embeddedBase: embeddedBase{ID: 7, CreatedAt: time.Unix(1700000000, 0).UTC()}, Name: "n"}
|
||||||
|
if got := primaryColumn(row); got != "id" {
|
||||||
|
t.Fatalf("primaryColumn = %q, want id (the embedded primarykey field)", got)
|
||||||
|
}
|
||||||
|
if got := pkUint(row); got != 7 {
|
||||||
|
t.Fatalf("pkUint = %d, want 7", got)
|
||||||
|
}
|
||||||
|
cols := modelColumns(row)
|
||||||
|
for _, want := range []string{"created_at", "name"} {
|
||||||
|
if _, ok := cols[want]; !ok {
|
||||||
|
t.Fatalf("modelColumns = %v, missing %s", cols, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
projected := projectRow(row, nil, []ListColumn{{Key: "name"}, {Key: "created_at"}})
|
||||||
|
if projected["id"] != uint(7) || projected["name"] != "n" || projected["created_at"] == nil {
|
||||||
|
t.Fatalf("projectRow = %#v, want the embedded id and created_at", projected)
|
||||||
|
}
|
||||||
|
if castPK(row, 9) != uint(9) {
|
||||||
|
t.Fatalf("castPK = %#v", castPK(row, 9))
|
||||||
|
}
|
||||||
|
|
||||||
|
gm := &gormModelRow{Model: gorm.Model{ID: 11}, Name: "g"}
|
||||||
|
if primaryColumn(gm) != "id" || pkUint(gm) != 11 {
|
||||||
|
t.Fatalf("gorm.Model primary key = %q/%d, want id/11", primaryColumn(gm), pkUint(gm))
|
||||||
|
}
|
||||||
|
if !fieldByColumn(reflect.ValueOf(gm).Elem(), "updated_at").IsValid() {
|
||||||
|
t.Fatal("gorm.Model's UpdatedAt is not reachable by column")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFieldByColumnTagBeatsGoName pins the other half of WR-16: a field with an
|
||||||
|
// explicit column tag is matched by that tag alone, never by its Go name.
|
||||||
|
func TestFieldByColumnTagBeatsGoName(t *testing.T) {
|
||||||
|
row := retaggedModel{Name: "in-title-column", Title: "in-name-column", Plain: "p"}
|
||||||
|
v := reflect.ValueOf(row)
|
||||||
|
if got := fieldByColumn(v, "name"); !got.IsValid() || got.String() != "in-name-column" {
|
||||||
|
t.Fatalf("column name resolved to %v, want the field tagged column:name", got)
|
||||||
|
}
|
||||||
|
if got := fieldByColumn(v, "title"); !got.IsValid() || got.String() != "in-title-column" {
|
||||||
|
t.Fatalf("column title resolved to %v, want the field tagged column:title", got)
|
||||||
|
}
|
||||||
|
if got := fieldByColumn(v, "plain"); !got.IsValid() || got.String() != "p" {
|
||||||
|
t.Fatalf("an untagged field must still match its Go name case-insensitively, got %v", got)
|
||||||
|
}
|
||||||
|
if got := fieldByColumn(reflect.ValueOf(struct {
|
||||||
|
Name string `gorm:"column:title"`
|
||||||
|
}{Name: "x"}), "name"); got.IsValid() {
|
||||||
|
t.Fatalf("a tagged field matched by its Go name: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -477,24 +477,14 @@ func relationFieldName(model any, relation string) (string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func primaryColumn(model any) string {
|
func primaryColumn(model any) string {
|
||||||
t := reflect.TypeOf(model)
|
for _, mf := range modelFields(reflect.TypeOf(model)) {
|
||||||
for t != nil && t.Kind() == reflect.Pointer {
|
if !hasPrimaryKeyTag(mf.Field) {
|
||||||
t = t.Elem()
|
|
||||||
}
|
|
||||||
if t == nil || t.Kind() != reflect.Struct {
|
|
||||||
return "id"
|
|
||||||
}
|
|
||||||
for i := 0; i < t.NumField(); i++ {
|
|
||||||
field := t.Field(i)
|
|
||||||
if field.PkgPath != "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.Contains(field.Tag.Get("gorm"), "primaryKey") {
|
if name := gormColumn(mf.Field); name != "" {
|
||||||
if name := gormColumn(field); name != "" {
|
return name
|
||||||
return name
|
|
||||||
}
|
|
||||||
return field.Name
|
|
||||||
}
|
}
|
||||||
|
return defaultColumnName(mf.Field)
|
||||||
}
|
}
|
||||||
return "id"
|
return "id"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,10 +245,9 @@ func structFieldByColumn(model any, column string) (reflect.StructField, bool) {
|
|||||||
if t == nil || t.Kind() != reflect.Struct {
|
if t == nil || t.Kind() != reflect.Struct {
|
||||||
return reflect.StructField{}, false
|
return reflect.StructField{}, false
|
||||||
}
|
}
|
||||||
for i := 0; i < t.NumField(); i++ {
|
for _, mf := range modelFields(t) {
|
||||||
field := t.Field(i)
|
if gormColumn(mf.Field) == column {
|
||||||
if field.PkgPath == "" && gormColumn(field) == column {
|
return mf.Field, true
|
||||||
return field, true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return reflect.StructField{}, false
|
return reflect.StructField{}, false
|
||||||
|
|||||||
Reference in New Issue
Block a user