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

@@ -216,6 +216,12 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", 200, "cabana.Envelope-cabana_RelationMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/link", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true)
}, into[cabana.Envelope[cabana.RelationMutationResult]](), nil},
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}", 200, "cabana.Envelope-cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/pivot/%d", e.gadgetID, e.memberID), nil, true)
}, into[cabana.Envelope[cabana.AdminRecord]](), nil},
{"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}", 200, "cabana.Envelope-cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/pivot/%d", e.gadgetID, e.memberID), map[string]any{"note": "note-" + e.stamp}, true)
}, into[cabana.Envelope[cabana.AdminRecord]](), nil},
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members", e.gadgetID), nil, true)
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]](), nil},
@@ -223,8 +229,19 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/unlink", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true)
}, into[cabana.Envelope[cabana.RelationMutationResult]](), nil},
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records", 201, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records", e.gadgetID), map[string]any{"label": "part-" + e.stamp}, true)
rec := e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records", e.gadgetID), map[string]any{"label": "part-" + e.stamp}, true)
e.partID = dataID(t, rec.Body.Bytes())
return rec
}, into[cabana.RecordEnvelope](), nil},
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/%d", e.gadgetID, e.partID), nil, true)
}, into[cabana.RecordEnvelope](), nil},
{"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/%d", e.gadgetID, e.partID), map[string]any{"label": "part-" + e.stamp + "-renamed"}, true)
}, into[cabana.RecordEnvelope](), nil},
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/delete", e.gadgetID), map[string]any{"ids": []uint{e.partID}}, true)
}, into[cabana.Envelope[cabana.BulkResult]](), nil},
{"POST /{vendor}/{plugin}/{controller}/toolbar/{action}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/acme/conform/gadgets/toolbar/recount", map[string]any{}, true)
}, into[cabana.Envelope[cabana.AdminActionResult]](), nil},
@@ -344,6 +361,7 @@ type conformEnv struct {
groupID uint
memberID uint
gadgetID uint
partID uint
}
// sendWith sends a raw body with extra headers through the assembled router.
@@ -583,9 +601,12 @@ type conformMember struct {
func (conformMember) TableName() string { return "cabana_conform_members" }
type conformGadgetMember struct {
ID uint `gorm:"column:id;primaryKey"`
GadgetID uint `gorm:"column:gadget_id"`
MemberID uint `gorm:"column:member_id"`
ID uint `gorm:"column:id;primaryKey"`
GadgetID uint `gorm:"column:gadget_id"`
MemberID uint `gorm:"column:member_id"`
Note string `gorm:"column:note"`
// Stamp is a hook column RelationBeforeLink writes.
Stamp string `gorm:"column:stamp"`
}
func (conformGadgetMember) TableName() string { return "cabana_conform_gadget_members" }
@@ -648,11 +669,27 @@ func (conformController) AdminRelationContracts() []cabana.RelationContract {
return []cabana.RelationContract{{
Name: "members", NewRelated: func() any { return &conformMember{} }, NewPivot: func() any { return &conformGadgetMember{} },
ParentForeignKey: "gadget_id", RelatedForeignKey: "member_id", Columns: map[string]string{"email": "email"},
HookPivotColumns: []string{"stamp"},
}, {
Name: "parts", Kind: cabana.RelationHasMany, NewRelated: func() any { return &conformPart{} },
ForeignKey: "gadget_id", Columns: map[string]string{"label": "label"},
}}
}
// RelationBeforeLink stamps the server-owned pivot column of a members link.
func (conformController) RelationBeforeLink(_ context.Context, relation string, _, _ any, pivot map[string]any) error {
if relation == "members" {
pivot["stamp"] = "linked"
}
return nil
}
// FormExtendQuery hides gadgets whose name starts with hidden-, so the
// smoke tests can check that a hidden parent scopes its children away.
func (conformController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
return db.Where("name NOT LIKE ?", "hidden-%")
}
func (conformController) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{{Field: "group", Kind: "belongsTo", NewRelated: func() any { return &conformGroup{} }, ForeignKey: "group_id"}}
}
@@ -769,6 +806,8 @@ update:
email:
label: Email
showSearch: true
pivot:
form: models/member/pivot_fields.yaml
parts:
label: Parts
view:
@@ -776,7 +815,7 @@ parts:
columns:
label:
label: Label
toolbarButtons: create
toolbarButtons: create|update|delete|link|unlink
manage:
form: $/acme/conform/models/part/fields.yaml
list:
@@ -852,6 +891,11 @@ parts:
fileTypes: [pdf, png, svg, txt]
useCaption: true
context: update
`),
"models/member/pivot_fields.yaml": file(`fields:
pivot[note]:
label: Note
type: text
`),
"models/part/fields.yaml": file(`fields:
label: