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