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

@@ -67,11 +67,19 @@ type ListEnvelope[T any] struct {
type AdminRecord map[string]any
// AdminIDsRequest is the body of the id-list writes: bulk delete, relation
// link and relation unlink.
// unlink and relation child delete.
type AdminIDsRequest struct {
IDs []uint64 `json:"ids"`
}
// AdminRelationLinkRequest is the body of the relation link route: the
// related ids and, on a belongsToMany relation with a pivot form, the pivot
// form values of exactly one linked id. Unknown keys are refused.
type AdminRelationLinkRequest struct {
IDs []uint64 `json:"ids"`
Pivot map[string]any `json:"pivot,omitempty"`
}
// AdminLoginRequest is the admin login body. Either login or email
// identifies the backend user.
type AdminLoginRequest struct {
@@ -582,7 +590,7 @@ func AdminRelationCandidates() {}
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param name path string true "Relation name"
// @Param body body AdminIDsRequest true "Related record ids"
// @Param body body AdminRelationLinkRequest true "Related record ids and optional pivot form values"
// @Success 200 {object} Envelope[RelationMutationResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
@@ -635,6 +643,119 @@ func AdminRelationUnlink() {}
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records [post]
func AdminRelationChildCreate() {}
// AdminRelationChildShow documents the relation child show route.
//
// @Summary Show a related record
// @Description One child of the owner, projected through the manage form when the relation declares the update button, else through the view form (view.form, or the top-level form). A record that is not a child of this owner (hasMany: its foreign key; belongsToMany: a pivot row) is 404. Without either form the route answers 403.
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param name path string true "Relation name"
// @Param child path integer true "Related record id"
// @Success 200 {object} RecordEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child} [get]
func AdminRelationChildShow() {}
// AdminRelationChildUpdate documents the relation child update route.
//
// @Summary Update a related record
// @Description Saves one child of the owner through the manage form, with the related model's rules and hooks. The view panel must declare the update toolbar button, otherwise 403. A record that is not a child of this owner is 404.
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param name path string true "Relation name"
// @Param child path integer true "Related record id"
// @Param body body AdminRecord true "Field values of the manage form keyed by field name"
// @Success 200 {object} RecordEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 413 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child} [put]
func AdminRelationChildUpdate() {}
// AdminRelationChildDelete documents the relation child delete route.
//
// @Summary Delete related records
// @Description Deletes children of the owner through their model: a hasMany child is deleted (hooks and soft delete run); a belongsToMany record loses this owner's pivot row and is then deleted. Every id must be a child of this owner, otherwise the whole request is 404 and nothing is deleted. The view panel must declare the delete toolbar button, otherwise 403.
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param name path string true "Relation name"
// @Param body body AdminIDsRequest true "Related record ids"
// @Success 200 {object} Envelope[BulkResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 413 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete [post]
func AdminRelationChildDelete() {}
// AdminRelationPivotShow documents the pivot show route.
//
// @Summary Show the pivot values of a link
// @Description The pivot form (pivot.form) values of the pivot row linking the owner and one related record, keyed by field name; id is the related record's id. Needs a pivot form and the link or update toolbar button (403 otherwise); a record not linked to this owner is 404.
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param name path string true "Relation name"
// @Param child path integer true "Related record id"
// @Success 200 {object} Envelope[AdminRecord]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child} [get]
func AdminRelationPivotShow() {}
// AdminRelationPivotUpdate documents the pivot update route.
//
// @Summary Update the pivot values of a link
// @Description Saves pivot form values on the pivot row linking the owner and one related record. Only pivot form fields are accepted (422 per unknown key); the pivot foreign keys, timestamps and hook columns can never be set. Gated and scoped like the pivot show route.
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param name path string true "Relation name"
// @Param child path integer true "Related record id"
// @Param body body AdminRecord true "Pivot form values keyed by field name"
// @Success 200 {object} Envelope[AdminRecord]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 413 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child} [put]
func AdminRelationPivotUpdate() {}
// FileMutationResult is the payload of a file removal: the number of files
// removed (always 1 on success).
type FileMutationResult struct {