diff --git a/cabana/http.go b/cabana/http.go index ed739fe..2167498 100644 --- a/cabana/http.go +++ b/cabana/http.go @@ -125,6 +125,8 @@ func (s *service) mount(r pact.Router) { g.Get("/auth/me", s.me) g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema) constrainController(g) + g.Get("/{vendor}/{plugin}/{controller}/schema/form", s.formSchema) + constrainController(g) g.Get("/{vendor}/{plugin}/{controller}", s.list) constrainController(g) g.Post("/{vendor}/{plugin}/{controller}", s.create) @@ -146,6 +148,25 @@ func constrainController(g pact.Router) { g.Where("controller", "[A-Za-z0-9_-]+") } +func (s *service) formSchema(w http.ResponseWriter, r *http.Request) { + s.protect(w, r, func(cc *CompiledController) { + if cc.Form == nil { + WriteError(w, http.StatusNotFound, "not_found", msgNotFound) + return + } + view, err := cc.Form.Localize(r.Context(), s.translator(), dropdownProvider(cc.Controller)) + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + meta := map[string]any{} + if view.Meta.Locale != "" { + meta["locale"] = view.Meta.Locale + } + WriteData(w, http.StatusOK, view, meta) + }) +} + func (s *service) listSchema(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { schema := cc.List diff --git a/cabana/list_schema.go b/cabana/list_schema.go index 7dee55a..195d7dd 100644 --- a/cabana/list_schema.go +++ b/cabana/list_schema.go @@ -330,6 +330,9 @@ func listModelContract(ctl pact.AdminController) (map[string]struct{}, map[strin } if isListRelation(field.Type) { rels[field.Name] = struct{}{} + // Winter YAML spells relations in lowercase (relation: genre) + // while the Go field is exported (Genre). + rels[strings.ToLower(field.Name)] = struct{}{} continue } name := gormColumn(field) diff --git a/cabana/query.go b/cabana/query.go index a10bff6..8109d43 100644 --- a/cabana/query.go +++ b/cabana/query.go @@ -125,8 +125,11 @@ func ExecuteList(ctx context.Context, db *gorm.DB, cc *CompiledController, in Li } q = applyListOrder(q, cc.List, model, sortCol, sortDesc) for _, col := range cc.List.Columns { - if col.Relation != "" { - q = q.Preload(col.Relation) + if col.Relation == "" { + continue + } + if field, ok := relationFieldName(model, col.Relation); ok { + q = q.Preload(field) } } offset := (page - 1) * per @@ -230,9 +233,13 @@ func applyListOrder(db *gorm.DB, schema *ListSchema, model any, column string, d if col.Key != column || col.Relation == "" { continue } - orderTable = col.Relation + field, ok := relationFieldName(model, col.Relation) + if !ok { + continue + } + orderTable = field orderColumn = col.Select - db = db.Joins(col.Relation) + db = db.Joins(field) break } db = db.Order(clause.OrderByColumn{ @@ -269,15 +276,16 @@ func applyListSearch(db *gorm.DB, schema *ListSchema, model any, term string) (* table := main column := col.Key if col.Relation != "" { - if _, ok := relatedTable(model, col.Relation); !ok || !identifier(col.Select) { + field, ok := relationFieldName(model, col.Relation) + if !ok || !identifier(col.Select) { return nil, listInvalid("search", "is not searchable") } - // GORM aliases the joined association with the relation field name. - table = col.Relation + // GORM aliases the joined association with the Go field name. + table = field column = col.Select - if _, done := joined[col.Relation]; !done { - db = db.Joins(col.Relation) - joined[col.Relation] = struct{}{} + if _, done := joined[field]; !done { + db = db.Joins(field) + joined[field] = struct{}{} } } else if !identifier(column) { return nil, listInvalid("search", "is not searchable") @@ -439,33 +447,30 @@ func tableName(model any) string { return "" } -func relatedTable(model any, relation string) (string, bool) { +// relationFieldName resolves a Winter relation key onto the exported Go field. +// Exact names win; otherwise the match is case-insensitive, so relation: genre +// selects field Genre. +func relationFieldName(model any, relation string) (string, bool) { t := reflect.TypeOf(model) for t != nil && t.Kind() == reflect.Pointer { t = t.Elem() } - if t == nil || t.Kind() != reflect.Struct { + if t == nil || t.Kind() != reflect.Struct || relation == "" { return "", false } - field, ok := t.FieldByName(relation) - if !ok { - return "", false + if field, ok := t.FieldByName(relation); ok && isListRelation(field.Type) { + return field.Name, true } - rt := field.Type - for rt.Kind() == reflect.Pointer { - rt = rt.Elem() - } - if rt.Kind() == reflect.Slice { - rt = rt.Elem() - for rt.Kind() == reflect.Pointer { - rt = rt.Elem() + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if field.PkgPath != "" || !isListRelation(field.Type) { + continue + } + if strings.EqualFold(field.Name, relation) { + return field.Name, true } } - if rt.Kind() != reflect.Struct { - return "", false - } - name := tableName(reflect.New(rt).Interface()) - return name, name != "" + return "", false } func primaryColumn(model any) string { @@ -501,7 +506,11 @@ func relatedSelect(row reflect.Value, col ListColumn) (any, bool) { if row.Kind() != reflect.Struct || col.Relation == "" { return nil, false } - field := row.FieldByName(col.Relation) + name, ok := relationFieldName(row.Interface(), col.Relation) + if !ok { + return nil, false + } + field := row.FieldByName(name) if !field.IsValid() { return nil, false }