- FieldRelationContract/FieldRelationProvider bind every type: relation
field to a belongsTo foreign key or a belongsToMany pivot; activation
fails naming plugin, controller and field on a missing or broken contract
- GET /{vendor}/{plugin}/{controller}/fields/{field}/options serves
{value, label} pages scoped by pact.RelationExtendOptionsQuery, behind
the controller permission; read-only and non-relation fields are 404
- Saves apply present relation keys after the Before hook: ids are
revalidated through the same scoped query (422 and full rollback
otherwise), belongsTo sets the foreign key, belongsToMany replaces pivot
rows in submitted order with the order column set to the index
- Show, create and update return relation values in data and meta.labels
- A belongsTo on a protected fill key is read-only (D-26)
- One six-segment GET pattern dispatches relation lists and field options,
which ServeMux cannot register side by side
- Admin OpenAPI documents the options route and RecordEnvelope
653 lines
21 KiB
Go
653 lines
21 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/summercms/pact"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// FieldRelationContract binds one fields.yaml `type: relation` field to its
|
|
// related model (D-17, D-18). Framework code never guesses a plugin table,
|
|
// pivot or foreign key: every name below comes from the controller.
|
|
type FieldRelationContract struct {
|
|
// Field is the fields.yaml key ("genre", "artists").
|
|
Field string
|
|
// Kind is "belongsTo" or "belongsToMany".
|
|
Kind string
|
|
// NewRelated returns a pointer to the related model.
|
|
NewRelated func() any
|
|
// ForeignKey is the belongsTo column on the parent model.
|
|
ForeignKey string
|
|
// NewPivot returns a pointer to the belongsToMany join model.
|
|
NewPivot func() any
|
|
// ParentForeignKey and RelatedForeignKey are the pivot columns.
|
|
ParentForeignKey string
|
|
RelatedForeignKey string
|
|
// OrderColumn is an optional pivot column set to the submitted array index.
|
|
OrderColumn string
|
|
// LabelColumn is the physical label column on the related model. Empty
|
|
// means the field's nameFrom, mapped through pact.ListRelationColumnMapper
|
|
// when the controller implements it.
|
|
LabelColumn string
|
|
}
|
|
|
|
// FieldRelationProvider is implemented by admin controllers whose form
|
|
// declares `type: relation` fields.
|
|
type FieldRelationProvider interface {
|
|
AdminFieldRelations() []FieldRelationContract
|
|
}
|
|
|
|
// RelationOption is one relation choice or label: the related primary key and
|
|
// its label column value.
|
|
type RelationOption struct {
|
|
Value uint `json:"value"`
|
|
Label string `json:"label"`
|
|
}
|
|
|
|
// RecordMeta is the record envelope meta: display labels per relation field,
|
|
// in the same order as the ids in data.
|
|
type RecordMeta struct {
|
|
Labels map[string][]RelationOption `json:"labels"`
|
|
}
|
|
|
|
// RecordEnvelope is the show, create and update response.
|
|
type RecordEnvelope struct {
|
|
Data AdminRecord `json:"data"`
|
|
Meta RecordMeta `json:"meta"`
|
|
}
|
|
|
|
// RecordResult is one projected record plus its relation labels.
|
|
type RecordResult struct {
|
|
Data map[string]any
|
|
Meta RecordMeta
|
|
}
|
|
|
|
// CompiledFieldRelation is one relation field after activation checks.
|
|
type CompiledFieldRelation struct {
|
|
Contract FieldRelationContract
|
|
// LabelColumn is the resolved physical label column.
|
|
LabelColumn string
|
|
// Multiple is true for belongsToMany.
|
|
Multiple bool
|
|
// ReadOnly is true for a belongsTo whose foreign key is a protected fill
|
|
// key (D-26): it is shown with its label but never written and has no
|
|
// options endpoint.
|
|
ReadOnly bool
|
|
// Nullable is true when a belongsTo foreign key accepts null.
|
|
Nullable bool
|
|
}
|
|
|
|
const (
|
|
relationKindBelongsTo = "belongsTo"
|
|
relationKindBelongsToMany = "belongsToMany"
|
|
)
|
|
|
|
// compileFieldRelations binds every `type: relation` field to exactly one
|
|
// controller contract and marks the compiled form fields multiple or
|
|
// read-only. Any mismatch is a boot error naming plugin, controller and field.
|
|
func compileFieldRelations(pluginID string, ctl pact.AdminController, form *FormSchema) (map[string]*CompiledFieldRelation, error) {
|
|
out := map[string]*CompiledFieldRelation{}
|
|
fail := func(field string, err error) error {
|
|
return bootErr(pluginID, ctl.ID(), "config_form.yaml", fmt.Errorf("field %s: %w", field, err))
|
|
}
|
|
indexes := map[string]int{}
|
|
var order []string
|
|
if form != nil {
|
|
for i, field := range form.Fields {
|
|
if field.Type == "relation" {
|
|
indexes[field.Name] = i
|
|
order = append(order, field.Name)
|
|
}
|
|
}
|
|
}
|
|
provider, hasProvider := ctl.(FieldRelationProvider)
|
|
var contracts []FieldRelationContract
|
|
if hasProvider && provider != nil {
|
|
contracts = provider.AdminFieldRelations()
|
|
}
|
|
if len(order) == 0 && len(contracts) == 0 {
|
|
return out, nil
|
|
}
|
|
byField := map[string]FieldRelationContract{}
|
|
for _, contract := range contracts {
|
|
if _, dup := byField[contract.Field]; dup {
|
|
return nil, fail(contract.Field, errors.New("duplicate relation contract"))
|
|
}
|
|
byField[contract.Field] = contract
|
|
if _, ok := indexes[contract.Field]; !ok {
|
|
return nil, fail(contract.Field, errors.New("relation contract names a field that is not a relation field in fields.yaml"))
|
|
}
|
|
}
|
|
src, ok := ctl.(pact.AdminRecordSource)
|
|
if !ok || src == nil || src.NewRecord() == nil {
|
|
return nil, fail(order[0], errors.New("relation fields require an AdminRecordSource"))
|
|
}
|
|
parent := src.NewRecord()
|
|
for _, name := range order {
|
|
contract, declared := byField[name]
|
|
if !declared {
|
|
if !hasProvider {
|
|
return nil, fail(name, errors.New("type relation requires AdminFieldRelations on the controller"))
|
|
}
|
|
return nil, fail(name, errors.New("has no relation contract"))
|
|
}
|
|
field := &form.Fields[indexes[name]]
|
|
compiled, err := compileFieldRelation(ctl, *field, contract, parent)
|
|
if err != nil {
|
|
return nil, fail(name, err)
|
|
}
|
|
field.Multiple = compiled.Multiple
|
|
field.ReadOnly = compiled.ReadOnly
|
|
out[name] = compiled
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func compileFieldRelation(ctl pact.AdminController, field FormField, contract FieldRelationContract, parent any) (*CompiledFieldRelation, error) {
|
|
if contract.NewRelated == nil || contract.NewRelated() == nil {
|
|
return nil, errors.New("relation contract requires a related model")
|
|
}
|
|
related := contract.NewRelated()
|
|
relatedCols := modelColumns(related)
|
|
if _, ok := relatedCols[primaryColumn(related)]; !ok {
|
|
return nil, fmt.Errorf("related model has no primary key column %s", primaryColumn(related))
|
|
}
|
|
label := strings.TrimSpace(contract.LabelColumn)
|
|
if label == "" {
|
|
label = field.NameFrom
|
|
if mapper, ok := ctl.(pact.ListRelationColumnMapper); ok && mapper != nil && label != "" {
|
|
if mapped, ok := mapper.ListRelationColumn(field.Name, label); ok && mapped != "" {
|
|
label = mapped
|
|
}
|
|
}
|
|
}
|
|
if !identifier(label) {
|
|
return nil, errors.New("relation needs nameFrom or a LabelColumn")
|
|
}
|
|
if _, ok := relatedCols[label]; !ok {
|
|
return nil, fmt.Errorf("related model is missing label column %s", label)
|
|
}
|
|
out := &CompiledFieldRelation{Contract: contract, LabelColumn: label}
|
|
parentCols := modelColumns(parent)
|
|
switch contract.Kind {
|
|
case relationKindBelongsTo:
|
|
if contract.NewPivot != nil || contract.ParentForeignKey != "" || contract.RelatedForeignKey != "" || contract.OrderColumn != "" {
|
|
return nil, errors.New("belongsTo cannot declare pivot columns")
|
|
}
|
|
if !identifier(contract.ForeignKey) {
|
|
return nil, errors.New("belongsTo requires a ForeignKey")
|
|
}
|
|
if _, ok := parentCols[contract.ForeignKey]; !ok {
|
|
return nil, fmt.Errorf("parent model is missing foreign key column %s", contract.ForeignKey)
|
|
}
|
|
fk, ok := structFieldByColumn(parent, contract.ForeignKey)
|
|
if !ok || !uintLike(fk.Type) {
|
|
return nil, fmt.Errorf("foreign key %s must be an unsigned integer column", contract.ForeignKey)
|
|
}
|
|
out.Nullable = fk.Type.Kind() == reflect.Pointer
|
|
out.ReadOnly = protectedFillKey(contract.ForeignKey)
|
|
case relationKindBelongsToMany:
|
|
if contract.ForeignKey != "" {
|
|
return nil, errors.New("belongsToMany cannot declare a ForeignKey")
|
|
}
|
|
if contract.NewPivot == nil || contract.NewPivot() == nil {
|
|
return nil, errors.New("belongsToMany requires a pivot model")
|
|
}
|
|
pivot := contract.NewPivot()
|
|
if reflect.TypeOf(pivot).Kind() != reflect.Pointer || reflect.TypeOf(pivot).Elem().Kind() != reflect.Struct {
|
|
return nil, errors.New("pivot model must be a struct pointer")
|
|
}
|
|
pivotCols := modelColumns(pivot)
|
|
for _, col := range []string{contract.ParentForeignKey, contract.RelatedForeignKey} {
|
|
if !identifier(col) {
|
|
return nil, errors.New("belongsToMany requires ParentForeignKey and RelatedForeignKey")
|
|
}
|
|
if _, ok := pivotCols[col]; !ok {
|
|
return nil, fmt.Errorf("pivot model is missing column %s", col)
|
|
}
|
|
}
|
|
if contract.ParentForeignKey == contract.RelatedForeignKey {
|
|
return nil, errors.New("pivot foreign keys must differ")
|
|
}
|
|
if contract.OrderColumn != "" {
|
|
if !identifier(contract.OrderColumn) {
|
|
return nil, fmt.Errorf("order column %q is not an identifier", contract.OrderColumn)
|
|
}
|
|
if _, ok := pivotCols[contract.OrderColumn]; !ok {
|
|
return nil, fmt.Errorf("pivot model is missing order column %s", contract.OrderColumn)
|
|
}
|
|
if contract.OrderColumn == contract.ParentForeignKey || contract.OrderColumn == contract.RelatedForeignKey {
|
|
return nil, errors.New("order column must not be a pivot foreign key")
|
|
}
|
|
}
|
|
out.Multiple = true
|
|
default:
|
|
return nil, fmt.Errorf("unknown relation kind %s (want belongsTo or belongsToMany)", contract.Kind)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func structFieldByColumn(model any, column string) (reflect.StructField, bool) {
|
|
t := reflect.TypeOf(model)
|
|
for t != nil && t.Kind() == reflect.Pointer {
|
|
t = t.Elem()
|
|
}
|
|
if t == nil || t.Kind() != reflect.Struct {
|
|
return reflect.StructField{}, false
|
|
}
|
|
for i := 0; i < t.NumField(); i++ {
|
|
field := t.Field(i)
|
|
if field.PkgPath == "" && gormColumn(field) == column {
|
|
return field, true
|
|
}
|
|
}
|
|
return reflect.StructField{}, false
|
|
}
|
|
|
|
func uintLike(t reflect.Type) bool {
|
|
if t.Kind() == reflect.Pointer {
|
|
t = t.Elem()
|
|
}
|
|
switch t.Kind() {
|
|
case reflect.Uint, reflect.Uint32, reflect.Uint64, reflect.Int, reflect.Int32, reflect.Int64:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// writableFieldRelation returns a relation field that offers options and
|
|
// accepts values. Read-only and unknown fields are not found.
|
|
func writableFieldRelation(cc *CompiledController, name string) (*CompiledFieldRelation, bool) {
|
|
if cc == nil || !identifier(name) {
|
|
return nil, false
|
|
}
|
|
fr, ok := cc.FieldRelations[name]
|
|
if !ok || fr == nil || fr.ReadOnly {
|
|
return nil, false
|
|
}
|
|
return fr, true
|
|
}
|
|
|
|
// scopedRelationQuery is the one query that both serves options and
|
|
// revalidates submitted ids, so the options hook is never only cosmetic.
|
|
func scopedRelationQuery(ctx context.Context, db *gorm.DB, cc *CompiledController, field string, fr *CompiledFieldRelation) *gorm.DB {
|
|
q := db.WithContext(ctx).Model(fr.Contract.NewRelated())
|
|
if ext, ok := cc.Controller.(pact.RelationExtendOptionsQuery); ok && ext != nil {
|
|
if next := ext.RelationExtendOptionsQuery(ctx, field, q); next != nil {
|
|
q = next
|
|
}
|
|
}
|
|
return q
|
|
}
|
|
|
|
func labelExpression(db *gorm.DB, table, column string) string {
|
|
return "COALESCE(CAST(" + quotedIdent(db, table) + "." + quotedIdent(db, column) + " AS TEXT), '')"
|
|
}
|
|
|
|
// RelationOptions serves one page of a relation field's choices (D-17):
|
|
// scoped by RelationExtendOptionsQuery, searched case-insensitively on the
|
|
// label column, ordered by label then primary key.
|
|
func (s CRUDService) RelationOptions(ctx context.Context, cc *CompiledController, field string, in RelationQuery) ([]RelationOption, ListMeta, error) {
|
|
if s.DB == nil {
|
|
return nil, ListMeta{}, errors.New("cabana: database is not configured")
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
fr, ok := writableFieldRelation(cc, field)
|
|
if !ok {
|
|
return nil, ListMeta{}, recordNotFound{}
|
|
}
|
|
page, per, err := normalizeRelationPage(in.Page, in.PerPage)
|
|
if err != nil {
|
|
return nil, ListMeta{}, err
|
|
}
|
|
related := fr.Contract.NewRelated()
|
|
table := tableName(related)
|
|
pk := primaryColumn(related)
|
|
q := scopedRelationQuery(ctx, s.DB, cc, field, fr)
|
|
label := labelExpression(q, table, fr.LabelColumn)
|
|
if term := strings.TrimSpace(in.Search); term != "" {
|
|
q = q.Where(label+" ILIKE ? ESCAPE '\\'", "%"+escapeLike(term)+"%")
|
|
}
|
|
var total int64
|
|
if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil {
|
|
return nil, ListMeta{}, lifecycleFailure(cc, err)
|
|
}
|
|
rows := make([]RelationOption, 0)
|
|
err = q.Select(quotedIdent(q, table) + "." + quotedIdent(q, pk) + " AS value, " + label + " AS label").
|
|
Order(label).
|
|
Order(clause.OrderByColumn{Column: clause.Column{Table: table, Name: pk}}).
|
|
Offset((page - 1) * per).Limit(per).
|
|
Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, ListMeta{}, lifecycleFailure(cc, err)
|
|
}
|
|
last := 1
|
|
if total > 0 {
|
|
last = int((total + int64(per) - 1) / int64(per))
|
|
}
|
|
return rows, ListMeta{Page: page, PerPage: per, Total: total, LastPage: last}, nil
|
|
}
|
|
|
|
// relationValue is one present, writable relation key lifted from a body.
|
|
type relationValue struct {
|
|
field string
|
|
fr *CompiledFieldRelation
|
|
ids []uint
|
|
null bool
|
|
}
|
|
|
|
// liftRelationValues takes the writable relation keys present in body. Read
|
|
// only fields, fields outside the operation's context and absent keys are
|
|
// skipped. Shape errors are one validation_failed with every bad field.
|
|
func liftRelationValues(cc *CompiledController, body map[string]any, op string) ([]relationValue, error) {
|
|
if cc == nil || len(cc.FieldRelations) == 0 || body == nil {
|
|
return nil, nil
|
|
}
|
|
names := make([]string, 0, len(cc.FieldRelations))
|
|
for name := range cc.FieldRelations {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
details := map[string]any{}
|
|
var out []relationValue
|
|
for _, name := range names {
|
|
fr := cc.FieldRelations[name]
|
|
if fr == nil || fr.ReadOnly || !contextAllows(cc, name, op) {
|
|
continue
|
|
}
|
|
raw, present := body[name]
|
|
if !present {
|
|
continue
|
|
}
|
|
value := relationValue{field: name, fr: fr}
|
|
if fr.Multiple {
|
|
items, ok := raw.([]any)
|
|
if !ok {
|
|
details[name] = []string{"The " + name + " field must be a list of ids."}
|
|
continue
|
|
}
|
|
seen := map[uint]struct{}{}
|
|
ids := make([]uint, 0, len(items))
|
|
bad := ""
|
|
for _, item := range items {
|
|
id, err := relationID(item)
|
|
if err != nil {
|
|
bad = "The " + name + " field must be a list of integer ids."
|
|
break
|
|
}
|
|
if _, dup := seen[id]; dup {
|
|
bad = "The " + name + " field contains a duplicate id."
|
|
break
|
|
}
|
|
seen[id] = struct{}{}
|
|
ids = append(ids, id)
|
|
}
|
|
if bad != "" {
|
|
details[name] = []string{bad}
|
|
continue
|
|
}
|
|
value.ids = ids
|
|
} else if raw == nil {
|
|
if !fr.Nullable {
|
|
details[name] = []string{"The " + name + " field cannot be empty."}
|
|
continue
|
|
}
|
|
value.null = true
|
|
} else {
|
|
id, err := relationID(raw)
|
|
if err != nil {
|
|
details[name] = []string{"The " + name + " field must be an integer id."}
|
|
continue
|
|
}
|
|
value.ids = []uint{id}
|
|
}
|
|
out = append(out, value)
|
|
}
|
|
if len(details) > 0 {
|
|
return nil, &ValidationError{Details: details}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// relationID accepts a JSON integer only: no strings, booleans or fractions.
|
|
func relationID(v any) (uint, error) {
|
|
switch n := v.(type) {
|
|
case json.Number:
|
|
return asUint(n)
|
|
case float64:
|
|
if n != math.Trunc(n) {
|
|
return 0, errBadID
|
|
}
|
|
return asUint(n)
|
|
case int, int64, uint, uint32, uint64:
|
|
return asUint(n)
|
|
default:
|
|
return 0, errBadID
|
|
}
|
|
}
|
|
|
|
// checkRelationScope re-runs the scoped options query for every submitted id
|
|
// inside the save transaction. An id the query does not return (unknown or
|
|
// out of scope) is validation_failed on that field and rolls the save back.
|
|
func checkRelationScope(ctx context.Context, tx *gorm.DB, cc *CompiledController, values []relationValue) error {
|
|
details := map[string]any{}
|
|
for _, value := range values {
|
|
if len(value.ids) == 0 {
|
|
continue
|
|
}
|
|
related := value.fr.Contract.NewRelated()
|
|
table := tableName(related)
|
|
pk := primaryColumn(related)
|
|
var found []uint
|
|
err := scopedRelationQuery(ctx, tx, cc, value.field, value.fr).
|
|
Where(clause.IN{Column: clause.Column{Table: table, Name: pk}, Values: uintValues(value.ids)}).
|
|
Pluck(quotedIdent(tx, table)+"."+quotedIdent(tx, pk), &found).Error
|
|
if err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
have := map[uint]struct{}{}
|
|
for _, id := range found {
|
|
have[id] = struct{}{}
|
|
}
|
|
for _, id := range value.ids {
|
|
if _, ok := have[id]; !ok {
|
|
details[value.field] = []string{"The selected " + value.field + " is invalid."}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if len(details) > 0 {
|
|
return &ValidationError{Details: details}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// assignBelongsTo writes validated belongsTo ids (or null) onto the parent's
|
|
// foreign key before the row write. Read-only keys never reach this point.
|
|
func assignBelongsTo(cc *CompiledController, model any, values []relationValue) error {
|
|
for _, value := range values {
|
|
if value.fr.Multiple || value.fr.ReadOnly || protectedFillKey(value.fr.Contract.ForeignKey) {
|
|
continue
|
|
}
|
|
var id any
|
|
if !value.null {
|
|
id = value.ids[0]
|
|
}
|
|
if err := setModelColumn(model, value.fr.Contract.ForeignKey, id); err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// syncBelongsToMany replaces the parent's pivot rows in submitted order: an
|
|
// explicit delete, then one bulk insert (Phase 5 join-table contract).
|
|
func syncBelongsToMany(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any, values []relationValue) error {
|
|
parentPK := pkUint(model)
|
|
for _, value := range values {
|
|
if !value.fr.Multiple {
|
|
continue
|
|
}
|
|
c := value.fr.Contract
|
|
proto := c.NewPivot()
|
|
err := tx.WithContext(ctx).Unscoped().
|
|
Where(clause.Eq{Column: clause.Column{Name: c.ParentForeignKey}, Value: parentPK}).
|
|
Delete(proto).Error
|
|
if err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
if len(value.ids) == 0 {
|
|
continue
|
|
}
|
|
rows := reflect.MakeSlice(reflect.SliceOf(reflect.TypeOf(proto).Elem()), 0, len(value.ids))
|
|
for i, id := range value.ids {
|
|
pivot := c.NewPivot()
|
|
if err := setModelColumn(pivot, c.ParentForeignKey, parentPK); err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
if err := setModelColumn(pivot, c.RelatedForeignKey, id); err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
if c.OrderColumn != "" {
|
|
if err := setModelColumn(pivot, c.OrderColumn, i); err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
}
|
|
rows = reflect.Append(rows, reflect.ValueOf(pivot).Elem())
|
|
}
|
|
holder := reflect.New(rows.Type())
|
|
holder.Elem().Set(rows)
|
|
if err := tx.WithContext(ctx).Create(holder.Interface()).Error; err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// projectRelationFields adds every relation field's value to data (belongsTo
|
|
// id or null; belongsToMany ids in pivot order, then related id) and returns
|
|
// the labels in the same order. Read-only fields are included.
|
|
func projectRelationFields(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any, data map[string]any) (RecordMeta, error) {
|
|
meta := RecordMeta{Labels: map[string][]RelationOption{}}
|
|
if cc == nil || len(cc.FieldRelations) == 0 {
|
|
return meta, nil
|
|
}
|
|
v := reflect.ValueOf(model)
|
|
for v.Kind() == reflect.Pointer {
|
|
v = v.Elem()
|
|
}
|
|
for name, fr := range cc.FieldRelations {
|
|
c := fr.Contract
|
|
var ids []uint
|
|
if fr.Multiple {
|
|
ids = []uint{}
|
|
q := tx.WithContext(ctx).Model(c.NewPivot()).
|
|
Where(clause.Eq{Column: clause.Column{Name: c.ParentForeignKey}, Value: pkUint(model)})
|
|
if c.OrderColumn != "" {
|
|
q = q.Order(clause.OrderByColumn{Column: clause.Column{Name: c.OrderColumn}})
|
|
}
|
|
if err := q.Order(clause.OrderByColumn{Column: clause.Column{Name: c.RelatedForeignKey}}).Pluck(c.RelatedForeignKey, &ids).Error; err != nil {
|
|
return RecordMeta{}, lifecycleFailure(cc, err)
|
|
}
|
|
data[name] = ids
|
|
} else {
|
|
data[name] = nil
|
|
if id, ok := foreignKeyValue(v, c.ForeignKey); ok {
|
|
data[name] = id
|
|
ids = []uint{id}
|
|
}
|
|
}
|
|
labels, err := relationLabels(ctx, tx, fr, ids)
|
|
if err != nil {
|
|
return RecordMeta{}, lifecycleFailure(cc, err)
|
|
}
|
|
meta.Labels[name] = labels
|
|
}
|
|
return meta, nil
|
|
}
|
|
|
|
func foreignKeyValue(v reflect.Value, column string) (uint, bool) {
|
|
field := fieldByColumn(v, column)
|
|
if !field.IsValid() {
|
|
return 0, false
|
|
}
|
|
for field.Kind() == reflect.Pointer {
|
|
if field.IsNil() {
|
|
return 0, false
|
|
}
|
|
field = field.Elem()
|
|
}
|
|
if !field.CanInterface() {
|
|
return 0, false
|
|
}
|
|
id, err := asUint(field.Interface())
|
|
if err != nil || id == 0 {
|
|
return 0, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
func relationLabels(ctx context.Context, tx *gorm.DB, fr *CompiledFieldRelation, ids []uint) ([]RelationOption, error) {
|
|
out := make([]RelationOption, 0, len(ids))
|
|
if len(ids) == 0 {
|
|
return out, nil
|
|
}
|
|
related := fr.Contract.NewRelated()
|
|
table := tableName(related)
|
|
pk := primaryColumn(related)
|
|
var rows []RelationOption
|
|
err := tx.WithContext(ctx).Model(related).
|
|
Select(quotedIdent(tx, table) + "." + quotedIdent(tx, pk) + " AS value, " + labelExpression(tx, table, fr.LabelColumn) + " AS label").
|
|
Where(clause.IN{Column: clause.Column{Table: table, Name: pk}, Values: uintValues(ids)}).
|
|
Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
byID := make(map[uint]string, len(rows))
|
|
for _, row := range rows {
|
|
byID[row.Value] = row.Label
|
|
}
|
|
for _, id := range ids {
|
|
if label, ok := byID[id]; ok {
|
|
out = append(out, RelationOption{Value: id, Label: label})
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// fieldOptions serves GET /{vendor}/{plugin}/{controller}/fields/{field}/options.
|
|
func (s *service) fieldOptions(w http.ResponseWriter, r *http.Request) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
field := r.PathValue("field")
|
|
if _, ok := writableFieldRelation(cc, field); !ok {
|
|
writeNotFound(w, r)
|
|
return
|
|
}
|
|
svc, err := s.crud()
|
|
if err != nil {
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
rows, meta, err := svc.RelationOptions(r.Context(), cc, field, RelationQuery{Search: q.Get("search"), Page: q.Get("page"), PerPage: q.Get("per_page")})
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
WriteData(w, http.StatusOK, rows, meta)
|
|
})
|
|
}
|