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