- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
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/modules/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)
|
|
})
|
|
}
|