- FieldRelationContract.WritableForeignKey makes a belongsTo field over a protected foreign key writable; the protected key list is unchanged - cabana.RelationLockProvider names related ids an administrator may not add or remove: options and labels carry locked, and a create or update that changes the locked subset is 403 before any row is written - columns.yaml invisible keeps a column searchable and out of the rows - a controller implementing pact.FilterOptions serves a scope filter's choices before the model - SPA: locked chips and options in RelationField, DataTable skips invisible columns - README, docs, OpenAPI document, TS types and dist updated
812 lines
27 KiB
Go
812 lines
27 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"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
|
|
// WritableForeignKey declares a belongsTo foreign key that is a protected
|
|
// fill key writable through this relation field.
|
|
WritableForeignKey bool
|
|
}
|
|
|
|
// RelationLock names the related records of one relation field that the
|
|
// requesting administrator may not add or remove. IDs are related primary
|
|
// keys. Message is a phrase key or text for the 403 a refused save answers;
|
|
// it may be empty, and the admin then shows its own text.
|
|
type RelationLock struct {
|
|
IDs []uint
|
|
Message string
|
|
}
|
|
|
|
// RelationLockProvider is implemented by an admin controller that locks some
|
|
// choices of its `type: relation` fields for some administrators (D-07). The
|
|
// framework asks it per request with the request context: the principal is
|
|
// read with bouncer.User, and inside a save the write transaction with
|
|
// TxFromContext. The lock is enforced on save: a create or update whose
|
|
// submitted value adds or removes a locked id, or replaces a belongsTo value
|
|
// that is locked or by one that is locked, is answered 403 and writes
|
|
// nothing. The `locked` flag on options and labels is a display aid only. A
|
|
// field with no locks returns the zero RelationLock.
|
|
type RelationLockProvider interface {
|
|
AdminRelationLocks(ctx context.Context, field string) (RelationLock, error)
|
|
}
|
|
|
|
// 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. Locked is true when the controller's
|
|
// RelationLockProvider names the record for the requesting administrator; the
|
|
// key is omitted otherwise.
|
|
type RelationOption struct {
|
|
Value uint `json:"value"`
|
|
Label string `json:"label"`
|
|
Locked bool `json:"locked,omitempty"`
|
|
}
|
|
|
|
// RecordMeta is the record envelope meta: display labels per relation field,
|
|
// in the same order as the ids in data. Actions are the declared record
|
|
// actions the requesting admin may run and that apply to the record, in
|
|
// declared order; only the show response fills it, and it is omitted when no
|
|
// action is offered.
|
|
type RecordMeta struct {
|
|
Labels map[string][]RelationOption `json:"labels"`
|
|
Actions []RecordAction `json:"actions,omitempty"`
|
|
}
|
|
|
|
// 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) and whose contract does not set WritableForeignKey: 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) && !contract.WritableForeignKey
|
|
case relationKindBelongsToMany:
|
|
if contract.WritableForeignKey {
|
|
return nil, errors.New("WritableForeignKey is only valid on belongsTo")
|
|
}
|
|
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 _, mf := range modelFields(t) {
|
|
if gormColumn(mf.Field) == column {
|
|
return mf.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)
|
|
}
|
|
locked, _, err := relationLocks(ctx, cc, field)
|
|
if err != nil {
|
|
return nil, ListMeta{}, err
|
|
}
|
|
markLocked(rows, locked)
|
|
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
|
|
}
|
|
|
|
// relationLocks asks the controller's RelationLockProvider for the locked
|
|
// related ids of field, as a set, and the lock's message. A controller
|
|
// without the provider has no locks. A provider error is a lifecycle failure.
|
|
func relationLocks(ctx context.Context, cc *CompiledController, field string) (map[uint]bool, string, error) {
|
|
if cc == nil || cc.Controller == nil {
|
|
return nil, "", nil
|
|
}
|
|
provider, ok := cc.Controller.(RelationLockProvider)
|
|
if !ok || provider == nil {
|
|
return nil, "", nil
|
|
}
|
|
lock, err := provider.AdminRelationLocks(ctx, field)
|
|
if err != nil {
|
|
slog.Error("cabana: relation locks failed", "controller", controllerID(cc), "field", field, "error", err)
|
|
return nil, "", lifecycleFailure(cc, err)
|
|
}
|
|
if len(lock.IDs) == 0 {
|
|
return nil, lock.Message, nil
|
|
}
|
|
set := make(map[uint]bool, len(lock.IDs))
|
|
for _, id := range lock.IDs {
|
|
set[id] = true
|
|
}
|
|
return set, lock.Message, nil
|
|
}
|
|
|
|
// markLocked flags the options whose id is in locked.
|
|
func markLocked(options []RelationOption, locked map[uint]bool) {
|
|
if len(locked) == 0 {
|
|
return
|
|
}
|
|
for i := range options {
|
|
if locked[options[i].Value] {
|
|
options[i].Locked = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkRelationLocks refuses a save whose relation values change what the
|
|
// controller's RelationLockProvider locks for the requesting administrator
|
|
// (D-07; T-12.1-12). It runs inside the save's transaction after the scope
|
|
// check and before any row or pivot write, on create and on update. For a
|
|
// belongsToMany value the locked subset of the parent's current pivot rows
|
|
// (none on create) and of the submitted ids must be equal as sets. For a
|
|
// belongsTo value a change is refused when the current or the submitted id is
|
|
// locked. Only relation fields present in the body are checked: an absent
|
|
// field changes nothing. The refusal is a ForbiddenError (403) with the
|
|
// lock's message, also on the field.
|
|
func checkRelationLocks(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any, values []relationValue) error {
|
|
for _, value := range values {
|
|
locked, message, err := relationLocks(ctx, cc, value.field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(locked) == 0 {
|
|
continue
|
|
}
|
|
c := value.fr.Contract
|
|
changed := false
|
|
if value.fr.Multiple {
|
|
var current []uint
|
|
if parentPK := pkUint(model); parentPK != 0 {
|
|
err := tx.WithContext(ctx).Model(c.NewPivot()).
|
|
Where(clause.Eq{Column: clause.Column{Name: c.ParentForeignKey}, Value: parentPK}).
|
|
Pluck(c.RelatedForeignKey, ¤t).Error
|
|
if err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
}
|
|
before, after := map[uint]bool{}, map[uint]bool{}
|
|
for _, id := range current {
|
|
if locked[id] {
|
|
before[id] = true
|
|
}
|
|
}
|
|
for _, id := range value.ids {
|
|
if locked[id] {
|
|
after[id] = true
|
|
}
|
|
}
|
|
changed = len(before) != len(after)
|
|
for id := range before {
|
|
if !after[id] {
|
|
changed = true
|
|
}
|
|
}
|
|
} else {
|
|
v := reflect.ValueOf(model)
|
|
for v.Kind() == reflect.Pointer {
|
|
v = v.Elem()
|
|
}
|
|
current, _ := foreignKeyValue(v, c.ForeignKey)
|
|
var submitted uint
|
|
if !value.null && len(value.ids) > 0 {
|
|
submitted = value.ids[0]
|
|
}
|
|
changed = current != submitted && (locked[current] || locked[submitted])
|
|
}
|
|
if changed {
|
|
detail := message
|
|
if detail == "" {
|
|
detail = "backend::lang.form.forbidden"
|
|
}
|
|
return &ForbiddenError{Message: message, Details: map[string]any{value.field: []string{detail}}}
|
|
}
|
|
}
|
|
return 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: a
|
|
// protected foreign key is written only when its contract declares
|
|
// WritableForeignKey, the same rule compileFieldRelation applies.
|
|
func assignBelongsTo(cc *CompiledController, model any, values []relationValue) error {
|
|
for _, value := range values {
|
|
c := value.fr.Contract
|
|
if value.fr.Multiple || value.fr.ReadOnly || (protectedFillKey(c.ForeignKey) && !c.WritableForeignKey) {
|
|
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)
|
|
}
|
|
if len(labels) > 0 {
|
|
locked, _, err := relationLocks(withTx(ctx, tx), cc, name)
|
|
if err != nil {
|
|
return RecordMeta{}, err
|
|
}
|
|
markLocked(labels, locked)
|
|
}
|
|
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)
|
|
})
|
|
}
|