feat(12.2-03): add parent-scoped child show, update, delete and pivot routes

- loadChild finds a child with one query carrying the parent predicate; a foreign child is 404
- GET/PUT .../records/{child} and POST .../delete (all or nothing) per relation kind
- hasMany link adopts NULL-key rows and unlink clears the key; pending created children are never candidates
- link accepts pivot values for one id through the pivot.form whitelist; GET/PUT .../pivot/{child}
- Link and Unlink share linkRelated/unlinkRelated for the deferred commit
This commit is contained in:
Jakub Zych
2026-10-02 18:44:34 +02:00
parent 48a5b8045a
commit afb05b6ee4
13 changed files with 2327 additions and 90 deletions

View File

@@ -164,6 +164,10 @@ type RelationResult struct {
// RelationMutationInput is the only accepted relation write payload.
type RelationMutationInput struct {
IDs []any `json:"ids"`
// Pivot holds pivot form values for a belongsToMany link of exactly one
// id (D-14). Only the relation's pivot.form fields are accepted; the
// pivot foreign keys, timestamps and hook columns never are.
Pivot map[string]any `json:"pivot,omitempty"`
}
// RelationMutationResult reports inserted/deleted pivot rows.
@@ -668,6 +672,10 @@ func relationBaseQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController,
}
notExists := "NOT EXISTS (SELECT 1 FROM " + quotedIdent(tx, pivotTable) + " p WHERE p." + quotedIdent(tx, cr.Contract.ParentForeignKey) + " = ? AND p." + quotedIdent(tx, cr.Contract.RelatedForeignKey) + " = " + quotedIdent(tx, targetTable) + "." + quotedIdent(tx, pk) + ")"
q = q.Where(notExists, ownerPK)
var err error
if q, err = excludePendingCreated(tx, q, target); err != nil {
return nil, nil, lifecycleFailure(cc, err)
}
} else {
join := "JOIN " + quotedIdent(tx, pivotTable) + " p ON p." + quotedIdent(tx, cr.Contract.RelatedForeignKey) + " = " + quotedIdent(tx, targetTable) + "." + quotedIdent(tx, pk)
q = q.Joins(join).Where("p."+quotedIdent(tx, cr.Contract.ParentForeignKey)+" = ?", ownerPK)
@@ -699,9 +707,27 @@ func hasManyBaseQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController,
q = q.Where(clause.Not(clause.IN{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}, Values: uintValues(excluded)}))
}
}
q, err := excludePendingCreated(tx, q, target)
if err != nil {
return nil, nil, lifecycleFailure(cc, err)
}
return q.Where(clause.Eq{Column: fk, Value: nil}), target, nil
}
// excludePendingCreated drops from a candidate query every row that a
// pending form session created (a live bind whose envelope says created,
// D-22): another parent must not adopt a child before the session that
// created it saves or the purge removes it (Pitfall 8).
func excludePendingCreated(tx *gorm.DB, q *gorm.DB, target any) (*gorm.DB, error) {
morph, err := lagoon.MorphType(tx, target)
if err != nil {
return nil, err
}
column := quotedIdent(tx, tableName(target)) + "." + quotedIdent(tx, primaryColumn(target))
pending := "NOT EXISTS (SELECT 1 FROM " + quotedIdent(tx, "deferred_bindings") + " b WHERE b.slave_type = ? AND b.is_bind AND b.slave_id = CAST(" + column + " AS TEXT) AND b.pivot_data LIKE ?)"
return q.Where(pending, morph, `{"created":true%`), nil
}
// normalizeRelationPage applies the Phase 9 relation paging limits: page is
// a positive integer (default 1), per_page is 1..100 (default 20).
func normalizeRelationPage(rawPage, rawPerPage string) (int, int, error) {
@@ -830,7 +856,12 @@ func relationSelects(db *gorm.DB, cr *CompiledRelation, target any, cols []Relat
return out
}
// Link inserts only currently eligible targets and never restamps existing rows.
// Link links eligible related records to the parent and never restamps
// existing links. On a belongsToMany it writes pivot rows (with the pivot
// form's values when the body carries a pivot object for one id); on a
// hasMany it sets each child's ForeignKey to the parent's key through the
// child model. Records already linked to this parent are skipped; any other
// id that is not a candidate fails the whole link with 422 on ids.
func (s RelationService) Link(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RelationMutationInput) (RelationMutationResult, error) {
ids, err := normalizeIDs(in.IDs)
if err != nil {
@@ -840,48 +871,172 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat
if err != nil {
return RelationMutationResult{}, err
}
if cr.hasMany() {
return RelationMutationResult{}, recordNotFound{}
if err := checkPivotInput(cr, ids, in.Pivot); err != nil {
return RelationMutationResult{}, err
}
var result RelationMutationResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
parent, err := newWritableModel(cc)
parent, err := s.loadParent(ctx, tx, cc, ownerID)
if err != nil {
return err
}
if err := loadRecord(ctx, tx, cc, parent, ownerID); err != nil {
return err
}
ownerPK := pkUint(parent)
pending, err := pendingRelationIDs(tx, cr, ownerPK, ids)
if err != nil || len(pending) == 0 {
return err
}
q, target, err := relationBaseQuery(ctx, tx, cc, cr, parent, true)
if err != nil {
return err
}
q = q.Clauses(clause.Locking{Strength: "UPDATE"}).Where(clause.IN{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}, Values: uintValues(pending)})
holder := reflect.New(reflect.SliceOf(reflect.TypeOf(target).Elem()))
if err := q.Order(clause.OrderByColumn{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}}).Find(holder.Interface()).Error; err != nil {
return err
}
if holder.Elem().Len() != len(pending) {
return relationInvalid("ids", "contains an ineligible target")
}
for i := 0; i < holder.Elem().Len(); i++ {
related := holder.Elem().Index(i).Addr().Interface()
if err := insertPivot(ctx, tx, cc, cr, parent, related, nil); err != nil {
return err
}
result.Linked++
}
return nil
result.Linked, err = s.linkRelated(ctx, tx, cc, cr, parent.model, ids, in.Pivot, "ids")
return err
})
return result, err
}
// 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) {
ownerPK := pkUint(parent)
var pending []uint
var err error
if cr.hasMany() {
pending, err = pendingChildIDs(ctx, tx, cr, ownerPK, ids)
} else {
pending, err = pendingRelationIDs(tx, cr, ownerPK, ids)
}
if err != nil || len(pending) == 0 {
return 0, err
}
q, target, err := relationBaseQuery(ctx, tx, cc, cr, parent, true)
if err != nil {
return 0, err
}
q = q.Clauses(clause.Locking{Strength: "UPDATE"}).Where(clause.IN{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}, Values: uintValues(pending)})
holder := reflect.New(reflect.SliceOf(reflect.TypeOf(target).Elem()))
if err := q.Order(clause.OrderByColumn{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}}).Find(holder.Interface()).Error; err != nil {
return 0, err
}
if holder.Elem().Len() != len(pending) {
return 0, relationInvalid(errField, "contains an ineligible target")
}
linked := 0
for i := 0; i < holder.Elem().Len(); i++ {
related := holder.Elem().Index(i).Addr().Interface()
if cr.hasMany() {
if err := setModelColumn(related, cr.Contract.ForeignKey, ownerPK); err != nil {
return 0, lifecycleFailure(cc, err)
}
if err := tx.WithContext(ctx).Save(related).Error; err != nil {
return 0, lifecycleFailure(cc, err)
}
linked++
continue
}
var row any
if pivot != nil {
row = cr.Contract.NewPivot()
if err := s.fillPivot(ctx, tx, cr, row, pivot); err != nil {
return 0, err
}
}
if err := insertPivot(ctx, tx, cc, cr, parent, related, row); err != nil {
return 0, err
}
linked++
}
return linked, 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()
var owned []uint
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(target).
Where(clause.Eq{Column: clause.Column{Name: cr.Contract.ForeignKey}, Value: ownerID}).
Where(clause.IN{Column: clause.Column{Name: primaryColumn(target)}, Values: uintValues(ids)}).
Pluck(primaryColumn(target), &owned).Error
if err != nil {
return nil, err
}
pending := make([]uint, 0, len(ids))
for _, id := range ids {
if !slices.Contains(owned, id) {
pending = append(pending, id)
}
}
return pending, nil
}
// checkPivotInput validates a link body's pivot object before any query
// (D-14): it needs a pivot form, exactly one id, and only pivot form keys.
func checkPivotInput(cr *CompiledRelation, ids []uint, pivot map[string]any) error {
if pivot == nil {
return nil
}
if cr.pivot == nil {
return relationInvalid("pivot", "This relation has no pivot form.")
}
if len(ids) != 1 {
return relationInvalid("ids", "A link with pivot values takes exactly one id.")
}
return checkPivotKeys(cr, pivot)
}
// checkPivotKeys refuses every key that is not a writable pivot form field.
func checkPivotKeys(cr *CompiledRelation, values map[string]any) error {
details := map[string]any{}
for key := range values {
if !slices.ContainsFunc(cr.pivot.Writable, func(f WritableField) bool { return f.Name == key }) {
details[key] = []string{"The " + key + " field is not a pivot field."}
}
}
if len(details) > 0 {
return &ValidationError{Details: details}
}
return nil
}
// fillPivot fills a pivot model from pivot form values (D-14): only the
// pivot form's writable fields are filled (the form is the whitelist, the
// pivot model needs no Fillable), then the form's required flags, the pivot
// model's rules for those fields and the datepicker bounds are checked.
// The pivot foreign keys, timestamps and hook columns are never in the form.
func (s RelationService) fillPivot(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, pivot any, values map[string]any) error {
if err := checkPivotKeys(cr, values); err != nil {
return err
}
form := cr.pivot
allowed := make([]string, 0, len(form.Writable))
for _, field := range form.Writable {
allowed = append(allowed, field.FillKey)
}
projected := ProjectWritableFields(form, values)
if err := lagoon.Fill(pivot, allowed, projected, false); err != nil {
var typed *lagoon.FillTypeError
if errors.As(err, &typed) {
return &ValidationError{Details: fillTypeDetails(typed.Key)}
}
return &CapabilityError{ControllerID: controllerID(form)}
}
rules := map[string]string{}
for key, rule := range mergedRules(form, pivot, "") {
if slices.Contains(allowed, key) {
rules[key] = rule
}
}
msgs, err := lagoon.Validate(ctx, tx, pivot, rules, valuesForRules(pivot, rules), nil)
if err != nil {
return &CapabilityError{ControllerID: controllerID(form)}
}
for field, extra := range dateBoundDetails(ctx, s.tr, form, pivot, "") {
if msgs == nil {
msgs = map[string][]string{}
}
msgs[field] = append(msgs[field], extra...)
}
if len(msgs) > 0 {
return &ValidationError{Details: validationDetails(msgs)}
}
return nil
}
// insertPivot writes the pivot row linking related to the saved parent of a
// belongsToMany relation. A filled pivot model (from the pivot form) may be
// passed; RelationBeforeLink then stamps only its HookPivotColumns, and the
@@ -913,50 +1068,92 @@ func insertPivot(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *C
return tx.WithContext(ctx).Create(pivot).Error
}
// Unlink deletes explicit pivot models so their lifecycle hooks run.
// Unlink detaches related records from the parent: on a belongsToMany it
// deletes this parent's pivot rows through the pivot model, on a hasMany it
// sets each owned child's ForeignKey to NULL through the child model. Ids
// that are not linked to this parent are ignored.
func (s RelationService) Unlink(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RelationMutationInput) (RelationMutationResult, error) {
ids, err := normalizeIDs(in.IDs)
if err != nil {
return RelationMutationResult{}, err
}
if in.Pivot != nil {
return RelationMutationResult{}, relationInvalid("pivot", "Unlink takes no pivot values.")
}
cr, err := relationOf(cc, relation)
if err != nil {
return RelationMutationResult{}, err
}
if cr.hasMany() {
return RelationMutationResult{}, recordNotFound{}
}
var result RelationMutationResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
parent, err := newWritableModel(cc)
parent, err := s.loadParent(ctx, tx, cc, ownerID)
if err != nil {
return err
}
if err := loadRecord(ctx, tx, cc, parent, ownerID); err != nil {
return err
}
proto := cr.Contract.NewPivot()
t := reflect.TypeOf(proto)
holder := reflect.New(reflect.SliceOf(t.Elem()))
q := tx.WithContext(ctx).Model(proto).Clauses(clause.Locking{Strength: "UPDATE"}).
Where(clause.Eq{Column: clause.Column{Name: cr.Contract.ParentForeignKey}, Value: pkUint(parent)}).
Where(clause.IN{Column: clause.Column{Name: cr.Contract.RelatedForeignKey}, Values: uintValues(ids)}).
Order(clause.OrderByColumn{Column: clause.Column{Name: cr.Contract.RelatedForeignKey}})
if err := q.Find(holder.Interface()).Error; err != nil {
return err
}
for i := 0; i < holder.Elem().Len(); i++ {
if err := tx.WithContext(ctx).Delete(holder.Elem().Index(i).Addr().Interface()).Error; err != nil {
return err
}
result.Removed++
}
return nil
result.Removed, err = s.unlinkRelated(ctx, tx, cc, cr, parent.model, ids)
return err
})
return result, err
}
// unlinkRelated detaches ids from the saved parent inside tx (the shared
// unlink path of the unlink route and of the deferred commit).
func (s RelationService) unlinkRelated(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent any, ids []uint) (int, error) {
if cr.hasMany() {
target := cr.Contract.NewRelated()
holder := reflect.New(reflect.SliceOf(reflect.TypeOf(target).Elem()))
err := tx.WithContext(ctx).Model(target).Clauses(clause.Locking{Strength: "UPDATE"}).
Where(clause.Eq{Column: clause.Column{Name: cr.Contract.ForeignKey}, Value: pkUint(parent)}).
Where(clause.IN{Column: clause.Column{Name: primaryColumn(target)}, Values: uintValues(ids)}).
Order(clause.OrderByColumn{Column: clause.Column{Name: primaryColumn(target)}}).
Find(holder.Interface()).Error
if err != nil {
return 0, err
}
for i := 0; i < holder.Elem().Len(); i++ {
child := holder.Elem().Index(i).Addr().Interface()
if err := setModelColumn(child, cr.Contract.ForeignKey, nil); err != nil {
return 0, lifecycleFailure(cc, err)
}
if err := tx.WithContext(ctx).Save(child).Error; err != nil {
return 0, lifecycleFailure(cc, err)
}
}
return holder.Elem().Len(), nil
}
rows, err := lockPivotRows(ctx, tx, cr, pkUint(parent), ids)
if err != nil {
return 0, err
}
for _, row := range rows {
if err := tx.WithContext(ctx).Delete(row).Error; err != nil {
return 0, err
}
}
return len(rows), nil
}
// lockPivotRows loads this parent's pivot rows for the related ids FOR
// UPDATE, in related id order.
func lockPivotRows(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, ownerPK uint, ids []uint) ([]any, error) {
proto := cr.Contract.NewPivot()
holder := reflect.New(reflect.SliceOf(reflect.TypeOf(proto).Elem()))
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(proto).Clauses(clause.Locking{Strength: "UPDATE"}).
Where(clause.Eq{Column: clause.Column{Name: cr.Contract.ParentForeignKey}, Value: ownerPK}).
Where(clause.IN{Column: clause.Column{Name: cr.Contract.RelatedForeignKey}, Values: uintValues(ids)}).
Order(clause.OrderByColumn{Column: clause.Column{Name: cr.Contract.RelatedForeignKey}}).
Find(holder.Interface()).Error
if err != nil {
return nil, err
}
out := make([]any, holder.Elem().Len())
for i := range out {
out[i] = holder.Elem().Index(i).Addr().Interface()
}
return out, nil
}
func relationOf(cc *CompiledController, name string) (*CompiledRelation, error) {
if cc == nil || !identifier(name) || cc.Relations == nil || cc.Relations[name] == nil {
return nil, recordNotFound{}