feat(12.2-03): defer relation work on unsaved records and add child file routes

- 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()
This commit is contained in:
Jakub Zych
2026-10-02 19:08:16 +02:00
parent afb05b6ee4
commit fe9e8baaf1
16 changed files with 3643 additions and 436 deletions

View File

@@ -16,28 +16,82 @@ import (
"gorm.io/gorm/clause"
)
// relationParent is the parent record of a relation route, loaded through
// FormExtendQuery.
// 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). A missing or hidden parent, and
// id 0, are recordNotFound.
func (s RelationService) loadParent(ctx context.Context, tx *gorm.DB, cc *CompiledController, ownerID uint) (*relationParent, error) {
// 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
}
if ownerID == 0 {
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{}
}
if err := loadRecord(ctx, tx, cc, parent, castPK(parent, ownerID)); err != nil {
return nil, err
principal, _ := bouncer.User(ctx)
if principal == nil || !principal.Backend || principal.ID == 0 {
return nil, recordNotFound{}
}
return &relationParent{model: parent, id: ownerID}, nil
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
@@ -98,7 +152,7 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController
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, ownerID)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil {
return err
}
@@ -106,7 +160,7 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController
if err != nil {
return err
}
if cr.hasMany() {
if cr.hasMany() && !parent.unsaved() {
if err := setModelColumn(child, cr.Contract.ForeignKey, parent.id); err != nil {
return lifecycleFailure(cc, err)
}
@@ -122,11 +176,22 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController
if err := tx.WithContext(ctx).Create(child).Error; err != nil {
return lifecycleFailure(cc, err)
}
if !cr.hasMany() {
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)
@@ -144,8 +209,9 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController
// 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. A child of another parent is recordNotFound (404, never 403).
// lock adds FOR UPDATE.
// 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{}
@@ -155,9 +221,13 @@ func loadChild(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, parent *r
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)})
if cr.hasMany() {
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})
} else {
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)) + ")"
@@ -201,7 +271,7 @@ func (s RelationService) ShowChild(ctx context.Context, cc *CompiledController,
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, ownerID)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil {
return err
}
@@ -236,7 +306,7 @@ func (s RelationService) UpdateChild(ctx context.Context, cc *CompiledController
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, ownerID)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil {
return err
}
@@ -255,6 +325,9 @@ func (s RelationService) UpdateChild(ctx context.Context, cc *CompiledController
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)
@@ -290,7 +363,7 @@ func (s RelationService) DeleteChildren(ctx context.Context, cc *CompiledControl
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, ownerID)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil {
return err
}
@@ -303,6 +376,11 @@ func (s RelationService) DeleteChildren(ctx context.Context, cc *CompiledControl
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
}
@@ -377,10 +455,24 @@ func (s RelationService) ShowPivot(ctx context.Context, cc *CompiledController,
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, ownerID)
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
@@ -408,10 +500,14 @@ func (s RelationService) UpdatePivot(ctx context.Context, cc *CompiledController
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, ownerID)
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
@@ -428,6 +524,77 @@ func (s RelationService) UpdatePivot(ctx context.Context, cc *CompiledController
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 {
@@ -436,6 +603,64 @@ func pivotRecord(cr *CompiledRelation, row any, childID uint) map[string]any {
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.
@@ -494,12 +719,16 @@ func (s *service) relationChildCreate(w http.ResponseWriter, r *http.Request) {
writeRelationError(w, err)
return
}
svc, err := s.relations()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
svc, ok := s.relationsFor(w, r)
if !ok {
return
}
rec, err := svc.CreateChild(r.Context(), cc, cr.Contract.Name, id, RecordInput{Body: body})
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
@@ -563,9 +792,8 @@ func (s *service) relationChildShow(w http.ResponseWriter, r *http.Request) {
writeCRUDError(w, err)
return
}
svc, err := s.relations()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
svc, ok := s.relationsFor(w, r)
if !ok {
return
}
rec, err := svc.ShowChild(r.Context(), cc, cr.Contract.Name, id, child)
@@ -594,12 +822,16 @@ func (s *service) relationChildUpdate(w http.ResponseWriter, r *http.Request) {
writeRelationError(w, err)
return
}
svc, err := s.relations()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
svc, ok := s.relationsFor(w, r)
if !ok {
return
}
rec, err := svc.UpdateChild(r.Context(), cc, cr.Contract.Name, id, child, RecordInput{Body: body})
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
@@ -626,9 +858,8 @@ func (s *service) relationChildDelete(w http.ResponseWriter, r *http.Request) {
writeRelationError(w, err)
return
}
svc, err := s.relations()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
svc, ok := s.relationsFor(w, r)
if !ok {
return
}
result, err := svc.DeleteChildren(r.Context(), cc, cr.Contract.Name, id, in)
@@ -678,9 +909,8 @@ func (s *service) relationPivotShow(w http.ResponseWriter, r *http.Request) {
writeCRUDError(w, err)
return
}
svc, err := s.relations()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
svc, ok := s.relationsFor(w, r)
if !ok {
return
}
data, err := svc.ShowPivot(r.Context(), cc, cr.Contract.Name, id, child)
@@ -709,9 +939,8 @@ func (s *service) relationPivotUpdate(w http.ResponseWriter, r *http.Request) {
writeRelationError(w, err)
return
}
svc, err := s.relations()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
svc, ok := s.relationsFor(w, r)
if !ok {
return
}
data, err := svc.UpdatePivot(r.Context(), cc, cr.Contract.Name, id, child, body)