- record id 0 with X-Session-Key manages deferrable relations: create, link, unlink, delete and pivot edits are held in deferred_bindings
- the record's create save applies relation bindings with the file bindings; an ineligible link is a 422 on the relation-manager field
- child forms upload files through .../records/{child}/files/{field} keyed by X-Child-Session-Key; the child save commits them
- boot refuses a deferrable relation with create whose related model no plugin lists in Models()
954 lines
32 KiB
Go
954 lines
32 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// relationParent is the parent record of a relation route: a saved record
|
|
// loaded through FormExtendQuery, or (id 0) the record being created in the
|
|
// admin's form session, whose relation work is held against key.
|
|
type relationParent struct {
|
|
model any
|
|
id uint
|
|
// key is the unsaved parent's deferred-binding key (D-03), nil for a
|
|
// saved parent; morph is the related model's morph type, the slave type
|
|
// of the relation's bindings.
|
|
key *lagoon.DeferredKey
|
|
morph string
|
|
}
|
|
|
|
// unsaved reports whether the parent is the record being created.
|
|
func (p *relationParent) unsaved() bool { return p != nil && p.key != nil }
|
|
|
|
// loadParent loads the parent record of a relation route through
|
|
// loadRecord (FormExtendQuery, FOR UPDATE). Id 0 is the record being
|
|
// created in the s.SessionKey session: it needs a deferrable relation, the
|
|
// controller's create operation, the relation-manager field in the create
|
|
// context and a backend admin; the parent is then a fresh zero-key record.
|
|
// A missing or hidden parent, and id 0 without all of that, are
|
|
// recordNotFound.
|
|
func (s RelationService) loadParent(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, ownerID uint) (*relationParent, error) {
|
|
parent, err := newWritableModel(cc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
morph, err := lagoon.MorphType(tx, cr.Contract.NewRelated())
|
|
if err != nil {
|
|
return nil, lifecycleFailure(cc, err)
|
|
}
|
|
if ownerID > 0 {
|
|
if err := loadRecord(ctx, tx, cc, parent, castPK(parent, ownerID)); err != nil {
|
|
return nil, err
|
|
}
|
|
return &relationParent{model: parent, id: ownerID, morph: morph}, nil
|
|
}
|
|
if s.SessionKey == "" || !cr.deferrable || !cc.operationDeclared("create") || !contextAllows(cc, cr.fieldName, "create") {
|
|
return nil, recordNotFound{}
|
|
}
|
|
principal, _ := bouncer.User(ctx)
|
|
if principal == nil || !principal.Backend || principal.ID == 0 {
|
|
return nil, recordNotFound{}
|
|
}
|
|
master, err := lagoon.MorphType(tx, parent)
|
|
if err != nil {
|
|
return nil, lifecycleFailure(cc, err)
|
|
}
|
|
key := lagoon.DeferredKey{SessionKey: s.SessionKey, AdminID: principal.ID, MasterType: master}
|
|
return &relationParent{model: parent, key: &key, morph: morph}, nil
|
|
}
|
|
|
|
// boundSlaves is the subquery of the unsaved parent's pending binds for the
|
|
// relation (WinterCMS withDeferred): CAST(pk AS TEXT) IN (?).
|
|
func (p *relationParent) boundSlaves(tx *gorm.DB, cr *CompiledRelation) *gorm.DB {
|
|
return lagoon.DeferredSlaves(tx, *p.key, cr.Contract.Name, p.morph, true)
|
|
}
|
|
|
|
// relationQuery is relationBaseQuery for a resolved parent. On an unsaved
|
|
// parent the linked rows are the session's pending binds, and candidates
|
|
// leave those out.
|
|
func relationQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent *relationParent, candidates bool) (*gorm.DB, any, error) {
|
|
if !parent.unsaved() {
|
|
return relationBaseQuery(ctx, tx, cc, cr, parent.model, candidates)
|
|
}
|
|
target := cr.Contract.NewRelated()
|
|
column := quotedIdent(tx, tableName(target)) + "." + quotedIdent(tx, primaryColumn(target))
|
|
if !candidates {
|
|
return tx.WithContext(ctx).Model(target).Where("CAST("+column+" AS TEXT) IN (?)", parent.boundSlaves(tx, cr)), target, nil
|
|
}
|
|
q, target, err := relationBaseQuery(ctx, tx, cc, cr, parent.model, true)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return q.Where("CAST("+column+" AS TEXT) NOT IN (?)", parent.boundSlaves(tx, cr)), target, nil
|
|
}
|
|
|
|
// fillChild fills and validates a child of a relation form like the
|
|
// controller save does: the body is projected through the form's writable
|
|
// fields for op, filled with lagoon.Fill, checked by BeforeValidate, the
|
|
// model rules merged with the form's required flags, and the datepicker
|
|
// bounds. A failure is a 422 ValidationError.
|
|
func (s RelationService) fillChild(ctx context.Context, tx *gorm.DB, form *CompiledController, model any, body map[string]any, op string) error {
|
|
projected := projectOperation(form, body, op)
|
|
if err := lagoon.Fill(model, fillAllowed(form, model, op), projected, false); err != nil {
|
|
var typed *lagoon.FillTypeError
|
|
if errors.As(err, &typed) {
|
|
return &ValidationError{Details: fillTypeDetails(typed.Key)}
|
|
}
|
|
return &CapabilityError{ControllerID: controllerID(form)}
|
|
}
|
|
if hook, ok := model.(lagoon.HasBeforeValidate); ok && hook != nil {
|
|
if err := hook.BeforeValidate(tx); err != nil {
|
|
return &CapabilityError{ControllerID: controllerID(form)}
|
|
}
|
|
}
|
|
rules := mergedRules(form, model, op)
|
|
msgs, err := lagoon.Validate(ctx, tx, model, rules, valuesForRules(model, rules), nil)
|
|
if err != nil {
|
|
return &CapabilityError{ControllerID: controllerID(form)}
|
|
}
|
|
for field, extra := range dateBoundDetails(ctx, s.tr, form, model, op) {
|
|
if msgs == nil {
|
|
msgs = map[string][]string{}
|
|
}
|
|
msgs[field] = append(msgs[field], extra...)
|
|
}
|
|
if len(msgs) > 0 {
|
|
return &ValidationError{Details: validationDetails(msgs)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CreateChild creates a related record through the relation's manage form
|
|
// (D-11, D-16) and attaches it to the parent: a hasMany child gets the
|
|
// parent's key in its ForeignKey (set by the server, never from the body),
|
|
// a belongsToMany record gets a pivot row (RelationBeforeLink stamps its
|
|
// hook columns). The parent is loaded through FormExtendQuery. The child is
|
|
// filled and validated like a controller save, and the controller's
|
|
// optional pact.RelationBeforeCreate and pact.RelationAfterCreate hooks run
|
|
// around the insert; any failure rolls the whole create back.
|
|
func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RecordInput) (RecordResult, error) {
|
|
if s.DB == nil {
|
|
return RecordResult{}, errors.New("cabana: database is not configured")
|
|
}
|
|
cr, err := relationOf(cc, relation)
|
|
if err != nil {
|
|
return RecordResult{}, err
|
|
}
|
|
if cr.child == nil {
|
|
return RecordResult{}, &CapabilityError{ControllerID: controllerID(cc)}
|
|
}
|
|
var result RecordResult
|
|
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
|
ctx = withTx(ctx, tx)
|
|
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
child, err := newWritableModel(cr.child)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cr.hasMany() && !parent.unsaved() {
|
|
if err := setModelColumn(child, cr.Contract.ForeignKey, parent.id); err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
}
|
|
if err := s.fillChild(ctx, tx, cr.child, child, in.Body, "create"); err != nil {
|
|
return err
|
|
}
|
|
if hook, ok := cc.Controller.(pact.RelationBeforeCreate); ok && hook != nil {
|
|
if err := hook.RelationBeforeCreate(ctx, cr.Contract.Name, parent.model, child); err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
}
|
|
if err := tx.WithContext(ctx).Create(child).Error; err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
switch {
|
|
case parent.unsaved():
|
|
// The child exists now, unattached; the parent's first save
|
|
// attaches it, the purge deletes it if that never happens.
|
|
env := &lagoon.DeferredEnvelope{Created: true}
|
|
if err := lagoon.DeferredBind(ctx, tx, *parent.key, cr.Contract.Name, parent.morph, uitoa(pkUint(child)), env); err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
case !cr.hasMany():
|
|
if err := insertPivot(ctx, tx, cc, cr, parent.model, child, nil); err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
}
|
|
if err := s.commitChildFiles(ctx, tx, cr, child, "create", in); err != nil {
|
|
return err
|
|
}
|
|
if hook, ok := cc.Controller.(pact.RelationAfterCreate); ok && hook != nil {
|
|
if err := hook.RelationAfterCreate(ctx, cr.Contract.Name, parent.model, child); err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
}
|
|
result, err = projectFullRecord(ctx, tx, cr.child, child)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return RecordResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// loadChild finds one child of the parent with a single query that carries
|
|
// the parent predicate (D-15): on a hasMany the child's ForeignKey must be
|
|
// the parent's key, on a belongsToMany a pivot row must link the child to
|
|
// the parent, and on an unsaved parent the child must be bound in the
|
|
// admin's own session. A child of another parent, or another admin's
|
|
// pending child, is recordNotFound (404, never 403). lock adds FOR UPDATE.
|
|
func loadChild(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, parent *relationParent, childID uint, lock bool) (any, error) {
|
|
if parent == nil || childID == 0 {
|
|
return nil, recordNotFound{}
|
|
}
|
|
child := cr.Contract.NewRelated()
|
|
table := tableName(child)
|
|
pk := clause.Column{Table: table, Name: primaryColumn(child)}
|
|
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(child).
|
|
Where(clause.Eq{Column: pk, Value: castPK(child, childID)})
|
|
switch {
|
|
case parent.unsaved():
|
|
column := quotedIdent(tx, table) + "." + quotedIdent(tx, primaryColumn(child))
|
|
q = q.Where("CAST("+column+" AS TEXT) IN (?)", parent.boundSlaves(tx, cr))
|
|
case cr.hasMany():
|
|
q = q.Where(clause.Eq{Column: clause.Column{Table: table, Name: cr.Contract.ForeignKey}, Value: parent.id})
|
|
default:
|
|
linked := "EXISTS (SELECT 1 FROM " + quotedIdent(tx, tableName(cr.Contract.NewPivot())) + " p WHERE p." +
|
|
quotedIdent(tx, cr.Contract.ParentForeignKey) + " = ? AND p." + quotedIdent(tx, cr.Contract.RelatedForeignKey) +
|
|
" = " + quotedIdent(tx, table) + "." + quotedIdent(tx, primaryColumn(child)) + ")"
|
|
q = q.Where(linked, parent.id)
|
|
}
|
|
if lock {
|
|
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
|
|
}
|
|
if err := q.Take(child).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, recordNotFound{}
|
|
}
|
|
return nil, err
|
|
}
|
|
return child, nil
|
|
}
|
|
|
|
// childForm is the form a child is shown with: the manage form when the
|
|
// relation declares update, else the view form.
|
|
func (cr *CompiledRelation) childForm() *CompiledController {
|
|
if cr.allows("update") && cr.child != nil {
|
|
return cr.child
|
|
}
|
|
return cr.view
|
|
}
|
|
|
|
// ShowChild loads one child of the parent (D-15) and projects it through
|
|
// the manage form when the relation declares update, else the view form.
|
|
func (s RelationService) ShowChild(ctx context.Context, cc *CompiledController, relation string, ownerID, childID uint) (RecordResult, error) {
|
|
if s.DB == nil {
|
|
return RecordResult{}, errors.New("cabana: database is not configured")
|
|
}
|
|
cr, err := relationOf(cc, relation)
|
|
if err != nil {
|
|
return RecordResult{}, err
|
|
}
|
|
form := cr.childForm()
|
|
if form == nil {
|
|
return RecordResult{}, recordNotFound{}
|
|
}
|
|
var result RecordResult
|
|
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
|
ctx = withTx(ctx, tx)
|
|
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
child, err := loadChild(ctx, tx, cr, parent, childID, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result, err = projectFullRecord(ctx, tx, form, child)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return RecordResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// UpdateChild saves one child of the parent through the manage form (D-16):
|
|
// the child is loaded under the parent scope and locked, filled and
|
|
// validated like a controller update, and saved through its model between
|
|
// the optional pact.RelationBeforeUpdate and pact.RelationAfterUpdate hooks.
|
|
func (s RelationService) UpdateChild(ctx context.Context, cc *CompiledController, relation string, ownerID, childID uint, in RecordInput) (RecordResult, error) {
|
|
if s.DB == nil {
|
|
return RecordResult{}, errors.New("cabana: database is not configured")
|
|
}
|
|
cr, err := relationOf(cc, relation)
|
|
if err != nil {
|
|
return RecordResult{}, err
|
|
}
|
|
if cr.child == nil {
|
|
return RecordResult{}, &CapabilityError{ControllerID: controllerID(cc)}
|
|
}
|
|
var result RecordResult
|
|
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
|
ctx = withTx(ctx, tx)
|
|
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
child, err := loadChild(ctx, tx, cr, parent, childID, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.fillChild(ctx, tx, cr.child, child, in.Body, "update"); err != nil {
|
|
return err
|
|
}
|
|
if hook, ok := cc.Controller.(pact.RelationBeforeUpdate); ok && hook != nil {
|
|
if err := hook.RelationBeforeUpdate(ctx, cr.Contract.Name, parent.model, child); err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
}
|
|
if err := tx.WithContext(ctx).Save(child).Error; err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
if err := s.commitChildFiles(ctx, tx, cr, child, "update", in); err != nil {
|
|
return err
|
|
}
|
|
if hook, ok := cc.Controller.(pact.RelationAfterUpdate); ok && hook != nil {
|
|
if err := hook.RelationAfterUpdate(ctx, cr.Contract.Name, parent.model, child); err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
}
|
|
result, err = projectFullRecord(ctx, tx, cr.child, child)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return RecordResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// DeleteChildren deletes children of the parent (D-12). Every id must be a
|
|
// child of this parent, or the whole request is recordNotFound and nothing
|
|
// is deleted. Per child the optional pact.RelationBeforeDelete runs, then a
|
|
// hasMany child is deleted through its model (hooks and soft delete run),
|
|
// a belongsToMany record first loses this parent's pivot row and is then
|
|
// deleted through its model, then pact.RelationAfterDelete runs.
|
|
func (s RelationService) DeleteChildren(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in BulkDeleteInput) (BulkResult, error) {
|
|
if s.DB == nil {
|
|
return BulkResult{}, errors.New("cabana: database is not configured")
|
|
}
|
|
ids, err := normalizeIDs(in.IDs)
|
|
if err != nil {
|
|
return BulkResult{}, err
|
|
}
|
|
cr, err := relationOf(cc, relation)
|
|
if err != nil {
|
|
return BulkResult{}, err
|
|
}
|
|
var result BulkResult
|
|
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
|
ctx = withTx(ctx, tx)
|
|
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
children := make([]any, 0, len(ids))
|
|
for _, id := range ids {
|
|
child, err := loadChild(ctx, tx, cr, parent, id, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
children = append(children, child)
|
|
}
|
|
for _, child := range children {
|
|
if parent.unsaved() {
|
|
if _, err := lagoon.DeferredUnbind(ctx, tx, *parent.key, cr.Contract.Name, parent.morph, uitoa(pkUint(child))); err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
}
|
|
if err := s.deleteChild(ctx, tx, cc, cr, parent, child); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
result.Deleted = len(children)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return BulkResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// deleteChild deletes one loaded child of the parent through its model.
|
|
func (s RelationService) deleteChild(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent *relationParent, child any) error {
|
|
if hook, ok := cc.Controller.(pact.RelationBeforeDelete); ok && hook != nil {
|
|
if err := hook.RelationBeforeDelete(ctx, cr.Contract.Name, parent.model, child); err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
}
|
|
if !cr.hasMany() && parent.id > 0 {
|
|
rows, err := lockPivotRows(ctx, tx, cr, parent.id, []uint{pkUint(child)})
|
|
if err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
for _, row := range rows {
|
|
if err := tx.WithContext(ctx).Delete(row).Error; err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
}
|
|
}
|
|
if err := tx.WithContext(ctx).Delete(child).Error; err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
if hook, ok := cc.Controller.(pact.RelationAfterDelete); ok && hook != nil {
|
|
if err := hook.RelationAfterDelete(ctx, cr.Contract.Name, parent.model, child); err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// loadPivotRow loads this parent's pivot row for the related id, locked; a
|
|
// missing row is recordNotFound.
|
|
func loadPivotRow(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, parent *relationParent, childID uint) (any, error) {
|
|
if childID == 0 {
|
|
return nil, recordNotFound{}
|
|
}
|
|
rows, err := lockPivotRows(ctx, tx, cr, parent.id, []uint{childID})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, recordNotFound{}
|
|
}
|
|
return rows[0], nil
|
|
}
|
|
|
|
// ShowPivot returns the pivot form values of the link between the parent
|
|
// and one related record (D-14).
|
|
func (s RelationService) ShowPivot(ctx context.Context, cc *CompiledController, relation string, ownerID, childID uint) (map[string]any, error) {
|
|
if s.DB == nil {
|
|
return nil, errors.New("cabana: database is not configured")
|
|
}
|
|
cr, err := relationOf(cc, relation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cr.pivot == nil {
|
|
return nil, recordNotFound{}
|
|
}
|
|
var data map[string]any
|
|
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
|
ctx = withTx(ctx, tx)
|
|
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if parent.unsaved() {
|
|
bind, err := findPendingBind(ctx, tx, cr, parent, childID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
env, err := bind.Envelope()
|
|
if err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
row := cr.Contract.NewPivot()
|
|
_ = lagoon.Fill(row, pivotFillKeys(cr), ProjectWritableFields(cr.pivot, env.Pivot), false)
|
|
data = pivotRecord(cr, row, childID)
|
|
return nil
|
|
}
|
|
row, err := loadPivotRow(ctx, tx, cr, parent, childID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data = pivotRecord(cr, row, childID)
|
|
return nil
|
|
})
|
|
return data, err
|
|
}
|
|
|
|
// UpdatePivot saves pivot form values on the link between the parent and
|
|
// one related record (D-14): only pivot form fields are accepted (an
|
|
// unknown key is a 422), and the pivot model is saved through GORM.
|
|
func (s RelationService) UpdatePivot(ctx context.Context, cc *CompiledController, relation string, ownerID, childID uint, values map[string]any) (map[string]any, error) {
|
|
if s.DB == nil {
|
|
return nil, errors.New("cabana: database is not configured")
|
|
}
|
|
cr, err := relationOf(cc, relation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cr.pivot == nil {
|
|
return nil, recordNotFound{}
|
|
}
|
|
var data map[string]any
|
|
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
|
ctx = withTx(ctx, tx)
|
|
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if parent.unsaved() {
|
|
data, err = s.updatePendingPivot(ctx, tx, cc, cr, parent, childID, values)
|
|
return err
|
|
}
|
|
row, err := loadPivotRow(ctx, tx, cr, parent, childID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.fillPivot(ctx, tx, cr, row, values); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.WithContext(ctx).Save(row).Error; err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
data = pivotRecord(cr, row, childID)
|
|
return nil
|
|
})
|
|
return data, err
|
|
}
|
|
|
|
// findPendingBind loads, locked, the unsaved parent's pending bind of one
|
|
// related record; a missing bind is recordNotFound.
|
|
func findPendingBind(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, parent *relationParent, childID uint) (*lagoon.DeferredBinding, error) {
|
|
var row lagoon.DeferredBinding
|
|
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where("session_key = ? AND backend_user_id = ? AND master_type = ? AND master_field = ? AND slave_type = ? AND slave_id = ? AND is_bind",
|
|
parent.key.SessionKey, parent.key.AdminID, parent.key.MasterType, cr.Contract.Name, parent.morph, uitoa(childID)).
|
|
Order("id").Take(&row).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, recordNotFound{}
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &row, nil
|
|
}
|
|
|
|
// pivotFillKeys are the pivot form's writable columns.
|
|
func pivotFillKeys(cr *CompiledRelation) []string {
|
|
out := make([]string, 0, len(cr.pivot.Writable))
|
|
for _, field := range cr.pivot.Writable {
|
|
out = append(out, field.FillKey)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// updatePendingPivot saves pivot form values on a pending link of an
|
|
// unsaved parent: they are whitelisted and validated exactly as on a saved
|
|
// parent and stored in the bind's envelope, which the parent's first save
|
|
// writes to the pivot row.
|
|
func (s RelationService) updatePendingPivot(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent *relationParent, childID uint, values map[string]any) (map[string]any, error) {
|
|
bind, err := findPendingBind(ctx, tx, cr, parent, childID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
env, err := bind.Envelope()
|
|
if err != nil {
|
|
return nil, lifecycleFailure(cc, err)
|
|
}
|
|
row := cr.Contract.NewPivot()
|
|
_ = lagoon.Fill(row, pivotFillKeys(cr), ProjectWritableFields(cr.pivot, env.Pivot), false)
|
|
if err := s.fillPivot(ctx, tx, cr, row, values); err != nil {
|
|
return nil, err
|
|
}
|
|
merged := map[string]any{}
|
|
for key, value := range ProjectWritableFields(cr.pivot, env.Pivot) {
|
|
merged[key] = value
|
|
}
|
|
for key, value := range ProjectWritableFields(cr.pivot, values) {
|
|
merged[key] = value
|
|
}
|
|
env.Pivot = merged
|
|
raw, err := json.Marshal(env)
|
|
if err != nil {
|
|
return nil, lifecycleFailure(cc, err)
|
|
}
|
|
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(&lagoon.DeferredBinding{}).
|
|
Where("id = ?", bind.ID).Update("pivot_data", string(raw)).Error; err != nil {
|
|
return nil, lifecycleFailure(cc, err)
|
|
}
|
|
return pivotRecord(cr, row, childID), nil
|
|
}
|
|
|
|
// commitChildFiles applies the child form's own file bindings (the
|
|
// X-Child-Session-Key session, in.SessionKey) to the saved child inside the
|
|
// child's transaction, then rechecks the child's file limits (D-17).
|
|
func (s RelationService) commitChildFiles(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, child any, op string, in RecordInput) error {
|
|
crud := CRUDService{DB: s.DB, bucket: s.bucket, tr: s.tr}
|
|
return crud.commitDeferred(ctx, tx, cr.child, child, op, RecordInput{SessionKey: in.SessionKey})
|
|
}
|
|
|
|
// pivotRecord projects a pivot row through the pivot form, keyed by the
|
|
// related record's id.
|
|
func pivotRecord(cr *CompiledRelation, row any, childID uint) map[string]any {
|
|
data := projectRecord(cr.pivot, row)
|
|
data["id"] = childID
|
|
return data
|
|
}
|
|
|
|
// childFileScope resolves a relation child file route (D-17) inside tx:
|
|
// the relation's manage form field, the parent (a saved record through
|
|
// FormExtendQuery, or id 0 in the X-Session-Key session) and the child.
|
|
// Child 0 is the child not created yet and needs X-Child-Session-Key; a
|
|
// saved child must pass loadChild under the parent. The file key is the
|
|
// child form's key, the admin and the related model's morph type, so the
|
|
// child's create or update save commits the files. Anything else is
|
|
// recordNotFound.
|
|
func childFileScope(ctx context.Context, tx *gorm.DB, r *http.Request, cc *CompiledController, cr *CompiledRelation, childID uint) (*fileScope, error) {
|
|
cf := cr.child.files[r.PathValue("field")]
|
|
if cf == nil {
|
|
return nil, recordNotFound{}
|
|
}
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parentKey, _, err := sessionKeyFrom(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
key, hasKey, err := childSessionKeyFrom(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
op := "update"
|
|
if childID == 0 {
|
|
op = "create"
|
|
if !hasKey {
|
|
return nil, recordNotFound{}
|
|
}
|
|
}
|
|
if !contextAllows(cr.child, cf.name, op) {
|
|
return nil, recordNotFound{}
|
|
}
|
|
parent, err := (RelationService{SessionKey: parentKey}).loadParent(ctx, tx, cc, cr, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sc := &fileScope{cc: cr.child, file: cf, ownerID: childID, morph: parent.morph}
|
|
if hasKey {
|
|
principal, _ := bouncer.User(ctx)
|
|
if principal == nil || principal.ID == 0 {
|
|
return nil, recordNotFound{}
|
|
}
|
|
sc.key = lagoon.DeferredKey{SessionKey: key, AdminID: principal.ID, MasterType: parent.morph}
|
|
sc.hasKey = true
|
|
}
|
|
if childID > 0 {
|
|
child, err := loadChild(ctx, tx, cr, parent, childID, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sc.owner = child
|
|
}
|
|
return sc, nil
|
|
}
|
|
|
|
// relationButton resolves the route's relation and refuses (403) a route
|
|
// whose toolbar button the view panel does not declare. An unknown relation
|
|
// is 404. It writes the response and returns nil on refusal.
|
|
func (s *service) relationButton(w http.ResponseWriter, r *http.Request, cc *CompiledController, button string) *CompiledRelation {
|
|
return s.relationAllowed(w, r, cc, func(cr *CompiledRelation) bool { return cr.allows(button) })
|
|
}
|
|
|
|
// decodeCappedObject decodes a JSON object body capped at
|
|
// http.body_limits.default_bytes; trailing data is refused.
|
|
func (s *service) decodeCappedObject(w http.ResponseWriter, r *http.Request) (map[string]any, error) {
|
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, s.jsonCap()))
|
|
dec.UseNumber()
|
|
var body map[string]any
|
|
if err := dec.Decode(&body); err != nil {
|
|
var tooBig *http.MaxBytesError
|
|
if errors.As(err, &tooBig) {
|
|
return nil, err
|
|
}
|
|
return nil, invalidBody()
|
|
}
|
|
var trailing any
|
|
if err := dec.Decode(&trailing); err != io.EOF {
|
|
return nil, invalidBody()
|
|
}
|
|
if body == nil {
|
|
body = map[string]any{}
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// writeRelationError maps a relation child route failure: a body past the
|
|
// cap is 413 payload_too_large, everything else as writeCRUDError.
|
|
func writeRelationError(w http.ResponseWriter, err error) {
|
|
var tooBig *http.MaxBytesError
|
|
if errors.As(err, &tooBig) {
|
|
WriteError(w, http.StatusRequestEntityTooLarge, "payload_too_large", msgPayloadTooLarge)
|
|
return
|
|
}
|
|
writeCRUDError(w, err)
|
|
}
|
|
|
|
// relationChildCreate serves POST .../{id}/relations/{name}/records.
|
|
func (s *service) relationChildCreate(w http.ResponseWriter, r *http.Request) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
cr := s.relationButton(w, r, cc, "create")
|
|
if cr == nil {
|
|
return
|
|
}
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
body, err := s.decodeCappedObject(w, r)
|
|
if err != nil {
|
|
writeRelationError(w, err)
|
|
return
|
|
}
|
|
svc, ok := s.relationsFor(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
childKey, _, err := childSessionKeyFrom(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
rec, err := svc.CreateChild(r.Context(), cc, cr.Contract.Name, id, RecordInput{Body: body, SessionKey: childKey})
|
|
if err != nil {
|
|
writeRelationError(w, err)
|
|
return
|
|
}
|
|
WriteData(w, http.StatusCreated, rec.Data, rec.Meta)
|
|
})
|
|
}
|
|
|
|
// pathChildID parses {child}; anything but a positive integer is not found.
|
|
func pathChildID(r *http.Request) (uint, error) {
|
|
n, err := strconv.ParseUint(strings.TrimSpace(r.PathValue("child")), 10, 64)
|
|
if err != nil || n == 0 {
|
|
return 0, recordNotFound{}
|
|
}
|
|
return uint(n), nil
|
|
}
|
|
|
|
// relationAllowed resolves the route's relation and refuses (403) unless
|
|
// allowed reports the relation declares what the route needs. An unknown
|
|
// relation is 404. It writes the response and returns nil on refusal.
|
|
func (s *service) relationAllowed(w http.ResponseWriter, r *http.Request, cc *CompiledController, allowed func(*CompiledRelation) bool) *CompiledRelation {
|
|
cr, err := relationOf(cc, r.PathValue("name"))
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return nil
|
|
}
|
|
if !allowed(cr) {
|
|
if principal, _ := bouncer.User(r.Context()); principal != nil {
|
|
s.logAuth(r, "denied", principal.ID)
|
|
}
|
|
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
|
return nil
|
|
}
|
|
return cr
|
|
}
|
|
|
|
// relationIDs parses {id} and {child}.
|
|
func relationIDs(r *http.Request) (uint, uint, error) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
child, err := pathChildID(r)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
return id, child, nil
|
|
}
|
|
|
|
// relationChildShow serves GET .../{id}/relations/{name}/records/{child}:
|
|
// allowed when the relation declares update (with a manage form) or has a
|
|
// view form.
|
|
func (s *service) relationChildShow(w http.ResponseWriter, r *http.Request) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
cr := s.relationAllowed(w, r, cc, func(cr *CompiledRelation) bool { return cr.childForm() != nil })
|
|
if cr == nil {
|
|
return
|
|
}
|
|
id, child, err := relationIDs(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
svc, ok := s.relationsFor(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
rec, err := svc.ShowChild(r.Context(), cc, cr.Contract.Name, id, child)
|
|
if err != nil {
|
|
writeRelationError(w, err)
|
|
return
|
|
}
|
|
WriteData(w, http.StatusOK, rec.Data, rec.Meta)
|
|
})
|
|
}
|
|
|
|
// relationChildUpdate serves PUT .../{id}/relations/{name}/records/{child}.
|
|
func (s *service) relationChildUpdate(w http.ResponseWriter, r *http.Request) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
cr := s.relationButton(w, r, cc, "update")
|
|
if cr == nil {
|
|
return
|
|
}
|
|
id, child, err := relationIDs(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
body, err := s.decodeCappedObject(w, r)
|
|
if err != nil {
|
|
writeRelationError(w, err)
|
|
return
|
|
}
|
|
svc, ok := s.relationsFor(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
childKey, _, err := childSessionKeyFrom(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
rec, err := svc.UpdateChild(r.Context(), cc, cr.Contract.Name, id, child, RecordInput{Body: body, SessionKey: childKey})
|
|
if err != nil {
|
|
writeRelationError(w, err)
|
|
return
|
|
}
|
|
WriteData(w, http.StatusOK, rec.Data, rec.Meta)
|
|
})
|
|
}
|
|
|
|
// relationChildDelete serves POST .../{id}/relations/{name}/delete with
|
|
// {ids}.
|
|
func (s *service) relationChildDelete(w http.ResponseWriter, r *http.Request) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
cr := s.relationButton(w, r, cc, "delete")
|
|
if cr == nil {
|
|
return
|
|
}
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
var in BulkDeleteInput
|
|
if err := s.decodeStrictNumbers(w, r, &in); err != nil {
|
|
writeRelationError(w, err)
|
|
return
|
|
}
|
|
svc, ok := s.relationsFor(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := svc.DeleteChildren(r.Context(), cc, cr.Contract.Name, id, in)
|
|
if err != nil {
|
|
writeRelationError(w, err)
|
|
return
|
|
}
|
|
WriteData(w, http.StatusOK, result, nil)
|
|
})
|
|
}
|
|
|
|
// decodeStrictNumbers decodes a capped JSON body into dest with numbers
|
|
// kept as json.Number, unknown keys and trailing data refused.
|
|
func (s *service) decodeStrictNumbers(w http.ResponseWriter, r *http.Request, dest any) error {
|
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, s.jsonCap()))
|
|
dec.UseNumber()
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(dest); err != nil {
|
|
var tooBig *http.MaxBytesError
|
|
if errors.As(err, &tooBig) {
|
|
return err
|
|
}
|
|
return invalidBody()
|
|
}
|
|
var trailing any
|
|
if err := dec.Decode(&trailing); err != io.EOF {
|
|
return invalidBody()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// pivotAllowed: the pivot routes need a pivot form and the link or update
|
|
// button.
|
|
func pivotAllowed(cr *CompiledRelation) bool {
|
|
return cr.pivot != nil && (cr.allows("link") || cr.allows("update"))
|
|
}
|
|
|
|
// relationPivotShow serves GET .../{id}/relations/{name}/pivot/{child}.
|
|
func (s *service) relationPivotShow(w http.ResponseWriter, r *http.Request) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
cr := s.relationAllowed(w, r, cc, pivotAllowed)
|
|
if cr == nil {
|
|
return
|
|
}
|
|
id, child, err := relationIDs(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
svc, ok := s.relationsFor(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
data, err := svc.ShowPivot(r.Context(), cc, cr.Contract.Name, id, child)
|
|
if err != nil {
|
|
writeRelationError(w, err)
|
|
return
|
|
}
|
|
WriteData(w, http.StatusOK, data, nil)
|
|
})
|
|
}
|
|
|
|
// relationPivotUpdate serves PUT .../{id}/relations/{name}/pivot/{child}.
|
|
func (s *service) relationPivotUpdate(w http.ResponseWriter, r *http.Request) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
cr := s.relationAllowed(w, r, cc, pivotAllowed)
|
|
if cr == nil {
|
|
return
|
|
}
|
|
id, child, err := relationIDs(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
body, err := s.decodeCappedObject(w, r)
|
|
if err != nil {
|
|
writeRelationError(w, err)
|
|
return
|
|
}
|
|
svc, ok := s.relationsFor(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
data, err := svc.UpdatePivot(r.Context(), cc, cr.Contract.Name, id, child, body)
|
|
if err != nil {
|
|
writeRelationError(w, err)
|
|
return
|
|
}
|
|
WriteData(w, http.StatusOK, data, nil)
|
|
})
|
|
}
|