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:
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user