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

@@ -179,6 +179,11 @@ type RelationMutationResult struct {
// RelationService executes compiled relation reads and writes.
type RelationService struct {
DB *gorm.DB
// SessionKey is the parent form's session key (X-Session-Key). With
// it, owner id 0 is the record being created in that session: relation
// work on it is held in deferred_bindings until the record's first save
// (D-03). Without it, id 0 is not found.
SessionKey string
// bucket deletes the blobs of child files a save removes, after commit;
// tr localizes date bound messages. Both may be nil.
@@ -609,14 +614,11 @@ func (s RelationService) query(ctx context.Context, cc *CompiledController, rela
}
var result *RelationResult
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
parent, err := newWritableModel(cc)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil {
return err
}
if err := loadRecord(ctx, tx, cc, parent, ownerID); err != nil {
return err
}
q, target, err := relationBaseQuery(ctx, tx, cc, cr, parent, candidates)
q, target, err := relationQuery(ctx, tx, cc, cr, parent, candidates)
if err != nil {
return err
}
@@ -877,11 +879,15 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat
var result RelationMutationResult
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
}
result.Linked, err = s.linkRelated(ctx, tx, cc, cr, parent.model, ids, in.Pivot, "ids")
if parent.unsaved() {
result.Linked, err = s.linkDeferred(ctx, tx, cc, cr, parent, ids, in.Pivot)
return err
}
result.Linked, err = s.linkRelated(ctx, tx, cc, cr, parent.model, ids, in.Pivot, relationInvalid("ids", "contains an ineligible target"))
return err
})
return result, err
@@ -890,9 +896,9 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat
// linkRelated links ids to the saved parent inside tx (the shared link
// path of the link route and of the deferred commit). Eligibility is the
// candidate query: RelationExtendManageQuery, ExcludedRelatedIDs, not linked
// yet, and not a child created in a pending session. An ineligible id is a
// 422 on errField.
func (s RelationService) linkRelated(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent any, ids []uint, pivot map[string]any, errField string) (int, error) {
// yet, and not a child created in a pending session. An ineligible id
// fails the link with the ineligible error.
func (s RelationService) linkRelated(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent any, ids []uint, pivot map[string]any, ineligible error) (int, error) {
ownerPK := pkUint(parent)
var pending []uint
var err error
@@ -914,7 +920,7 @@ func (s RelationService) linkRelated(ctx context.Context, tx *gorm.DB, cc *Compi
return 0, err
}
if holder.Elem().Len() != len(pending) {
return 0, relationInvalid(errField, "contains an ineligible target")
return 0, ineligible
}
linked := 0
for i := 0; i < holder.Elem().Len(); i++ {
@@ -944,6 +950,109 @@ func (s RelationService) linkRelated(ctx context.Context, tx *gorm.DB, cc *Compi
return linked, nil
}
// boundIDs are the ids among ids that the unsaved parent's session binds.
func boundIDs(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, parent *relationParent, ids []uint) ([]uint, error) {
target := cr.Contract.NewRelated()
pk := primaryColumn(target)
column := quotedIdent(tx, tableName(target)) + "." + quotedIdent(tx, pk)
var bound []uint
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(target).
Where(clause.IN{Column: clause.Column{Table: tableName(target), Name: pk}, Values: uintValues(ids)}).
Where("CAST("+column+" AS TEXT) IN (?)", parent.boundSlaves(tx, cr)).
Order(clause.OrderByColumn{Column: clause.Column{Table: tableName(target), Name: pk}}).
Pluck(pk, &bound).Error
return bound, err
}
// linkDeferred links ids to an unsaved parent (D-03): each eligible
// candidate (checked now against the zero-key parent, and again by the
// parent's first save) is bound to the session, with the whitelisted and
// validated pivot values in the bind's envelope. Ids already bound are
// skipped.
func (s RelationService) linkDeferred(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent *relationParent, ids []uint, pivot map[string]any) (int, error) {
bound, err := boundIDs(ctx, tx, cr, parent, ids)
if err != nil {
return 0, err
}
pending := make([]uint, 0, len(ids))
for _, id := range ids {
if !slices.Contains(bound, id) {
pending = append(pending, id)
}
}
if len(pending) == 0 {
return 0, nil
}
q, target, err := relationQuery(ctx, tx, cc, cr, parent, true)
if err != nil {
return 0, err
}
var eligible []uint
pk := primaryColumn(target)
err = q.Clauses(clause.Locking{Strength: "UPDATE"}).
Where(clause.IN{Column: clause.Column{Table: tableName(target), Name: pk}, Values: uintValues(pending)}).
Order(clause.OrderByColumn{Column: clause.Column{Table: tableName(target), Name: pk}}).
Pluck(pk, &eligible).Error
if err != nil {
return 0, err
}
if len(eligible) != len(pending) {
return 0, relationInvalid("ids", "contains an ineligible target")
}
var env *lagoon.DeferredEnvelope
if pivot != nil {
if err := s.fillPivot(ctx, tx, cr, cr.Contract.NewPivot(), pivot); err != nil {
return 0, err
}
env = &lagoon.DeferredEnvelope{Pivot: ProjectWritableFields(cr.pivot, pivot)}
}
for _, id := range eligible {
if err := lagoon.DeferredBind(ctx, tx, *parent.key, cr.Contract.Name, parent.morph, uitoa(id), env); err != nil {
return 0, lifecycleFailure(cc, err)
}
}
return len(eligible), nil
}
// unlinkDeferred cancels the unsaved parent's pending binds of ids; a child
// the session created is deleted through its model. Ids not bound in the
// session are ignored.
func (s RelationService) unlinkDeferred(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent *relationParent, ids []uint) (int, error) {
bound, err := boundIDs(ctx, tx, cr, parent, ids)
if err != nil {
return 0, err
}
for _, id := range bound {
cancelled, err := lagoon.DeferredUnbind(ctx, tx, *parent.key, cr.Contract.Name, parent.morph, uitoa(id))
if err != nil {
return 0, lifecycleFailure(cc, err)
}
if cancelled == nil {
continue
}
env, err := cancelled.Envelope()
if err != nil {
return 0, lifecycleFailure(cc, err)
}
if !env.Created {
continue
}
child := cr.Contract.NewRelated()
err = tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Clauses(clause.Locking{Strength: "UPDATE"}).
Where(clause.Eq{Column: clause.Column{Name: primaryColumn(child)}, Value: castPK(child, id)}).Take(child).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
continue
}
if err != nil {
return 0, err
}
if err := tx.WithContext(ctx).Delete(child).Error; err != nil {
return 0, lifecycleFailure(cc, err)
}
}
return len(bound), nil
}
// pendingChildIDs drops the ids a hasMany parent already owns.
func pendingChildIDs(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, ownerID uint, ids []uint) ([]uint, error) {
target := cr.Contract.NewRelated()
@@ -1087,10 +1196,14 @@ func (s RelationService) Unlink(ctx context.Context, cc *CompiledController, rel
var result RelationMutationResult
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() {
result.Removed, err = s.unlinkDeferred(ctx, tx, cc, cr, parent, ids)
return err
}
result.Removed, err = s.unlinkRelated(ctx, tx, cc, cr, parent.model, ids)
return err
})