- record id 0 with X-Session-Key manages deferrable relations: create, link, unlink, delete and pivot edits are held in deferred_bindings
- the record's create save applies relation bindings with the file bindings; an ineligible link is a 422 on the relation-manager field
- child forms upload files through .../records/{child}/files/{field} keyed by X-Child-Session-Key; the child save commits them
- boot refuses a deferrable relation with create whose related model no plugin lists in Models()
15 KiB
title, description, section, order
| title | description | section | order |
|---|---|---|---|
| Relation manager | Edit belongsTo and belongsToMany fields, and manage linked and hasMany records with config_relation.yaml, bound to models the controller names. | backend | 40 |
Relation manager
WinterCMS edits relations in two ways: a relation form field that picks the related record, and the Relation behaviour, which embeds a list of linked records with link and unlink buttons. cabana has both. The one rule that differs from WinterCMS: the framework never guesses a table, pivot or foreign key name. The controller supplies every name, and a missing or wrong one stops the start-up.
Relation fields
A type: relation field in fields.yaml picks a belongsTo record or a set of belongsToMany records. nameFrom names the related model's label column:
category:
label: acme.blog::lang.posts.category
type: relation
nameFrom: name
The controller implements cabana.FieldRelationProvider and returns a cabana.FieldRelationContract per field: its Kind (belongsTo or belongsToMany), a factory for the related model, and the ForeignKey of a belongsTo or the pivot model and its two key columns for a belongsToMany. An optional OrderColumn on the pivot stores the order in which the administrator picked the records.
The SPA loads the choices, paginated, from .../fields/{field}/options, and every record response carries the display labels of the linked records. A controller that implements pact.RelationExtendOptionsQuery narrows the choices, and the same scoped query rechecks the submitted IDs on save, so a record it does not offer cannot be attached.
Relation managers
A type: relation-manager field embeds a relation manager in the form. Its relation key names an entry in the controller's config_relation.yaml, which describes the two panels: the linked records (view) and the candidates shown when linking (manage):
editors:
label: acme.blog::lang.posts.editors
view:
list:
columns:
name:
label: acme.blog::lang.editors.name
email:
label: acme.blog::lang.editors.email
toolbarButtons: link|unlink
showSearch: true
manage:
list:
columns:
name:
label: acme.blog::lang.editors.name
showSearch: true
The controller implements cabana.AdminRelationContractProvider and returns a cabana.RelationContract per relation: the related and pivot model factories, the pivot's two foreign keys, a map from column names in the YAML to physical columns, and optionally the pivot columns a hook may set and a function that excludes candidate IDs, such as the parent itself. A relation in the YAML without a contract, a contract without a relation, or a relation without a relation-manager field stops the start-up.
cabana.RelationService serves the panels: linked records, link candidates, link and unlink, under .../{id}/relations/{name}. Link and unlink run in a transaction. pact.RelationExtendManageQuery scopes the candidates, and pact.RelationBeforeLink can check or fill pivot columns before a link is written.
Relation kinds
RelationContract.Kind says how the related records hang off the parent:
belongsToMany(cabana.RelationBelongsToMany) links records through a pivot model:NewPivot,ParentForeignKey,RelatedForeignKeyand optionallyHookPivotColumns. A contract that leavesKindempty is a belongsToMany, so contracts written before hasMany existed keep working unchanged.hasMany(cabana.RelationHasMany) owns records through a column on the related model:ForeignKeynames it. A hasMany contract declares no pivot fields; a pivot field, aForeignKeythat is not an integer column of the related model, or an unknown kind stops the start-up.
// AdminRelationContracts binds the posts form's relation managers to their
// models; the framework never guesses a table or column.
func (PostsController) AdminRelationContracts() []cabana.RelationContract {
return []cabana.RelationContract{{
Name: "comments",
Kind: cabana.RelationHasMany,
NewRelated: func() any { return &Comment{} },
ForeignKey: "post_id",
Columns: map[string]string{"author": "author", "body": "body"},
}}
}
A hasMany ForeignKey of a pointer type (*uint, a nullable column) is needed for unlink and for managing the relation before the parent is saved.
Relation forms
The relation manager creates and edits related records in a modal through a form of their own. manage.form names the form used to create and update a child, view.form the read-only preview; a top-level form is the fallback of both, as in WinterCMS:
comments:
label: acme.blog::lang.posts.comments
form: $/acme/blog/models/comment/fields.yaml
view:
list:
columns:
author:
label: acme.blog::lang.comments.author
toolbarButtons: create|update|delete
manage:
list:
columns:
author:
label: acme.blog::lang.comments.author
A form path is plugin-relative, ~/plugins/<vendor>/<plugin>/..., or WinterCMS's $/<vendor>/<plugin>/.... A $/ path resolves inside the same plugin; a path into another plugin stops the start-up, because the plugin's embedded tree cannot read it. The form is compiled at boot like a controller form, against the related model: every field must be a column of that model, and the related model must implement lagoon.HasFillable and Rules when create or update is declared.
A relation form accepts the scalar field types (text, textarea, number, checkbox, switch, dropdown) plus datepicker and fileupload. relation, relation-manager, widget and partial stop the start-up, and so does a field named like a hasMany ForeignKey: the server sets that column from the parent.
Toolbar buttons
The view panel's toolbarButtons take WinterCMS's buttons, and each one is the capability of its routes: a route whose button the relation does not declare answers 403 forbidden, whatever the controller permission.
| Button | Allows |
|---|---|
create |
Creating a related record through the manage form. Needs a manage form. |
update |
Editing a related record. Needs a manage form. |
delete |
Deleting related records. |
link |
Linking existing records. |
unlink |
Unlinking records. On a hasMany it needs a nullable ForeignKey. |
The manage panel may declare only link. An unknown or duplicate button stops the start-up. One difference from WinterCMS: there, clicking a row opens the update form whatever the toolbar lists; here a row is editable only when update is listed.
Creating related records
POST .../{id}/relations/{name}/records creates a related record from the manage form's fields and attaches it to the parent, in one transaction. The parent is loaded through pact.FormExtendQuery, so a parent the administrator cannot open answers 404. The child is filled and validated like a controller save (the related model's Fill, its rules merged with the form's required flags, a 422 validation_failed envelope on failure) and its GORM hooks run. On a hasMany the server sets the ForeignKey to the parent's key; a body that names that column cannot change it. On a belongsToMany the record is inserted and its pivot row written, with pact.RelationBeforeLink stamping the hook columns.
Editing, deleting, linking and unlinking
| Route | Button | Effect |
|---|---|---|
GET .../{id}/relations/{name}/records/{child} |
update, or a view form |
One child, through the manage form when update is listed, else through the view form. |
PUT .../{id}/relations/{name}/records/{child} |
update |
Saves the child through the manage form, with the related model's rules and hooks. |
POST .../{id}/relations/{name}/delete |
delete |
{ids}: deletes the children. |
POST .../{id}/relations/{name}/link |
link |
{ids}, optionally with pivot: links existing records. |
POST .../{id}/relations/{name}/unlink |
unlink |
{ids}: unlinks records. |
What delete, link and unlink do depends on the kind:
- On a hasMany,
deletedeletes the child through its model, so its hooks and soft delete run.linkadopts records whoseForeignKeyis NULL, setting it to the parent's key through the child model;unlinksets it back to NULL. - On a belongsToMany,
deleteremoves this parent's pivot row and then deletes the related record through its model.linkwrites pivot rows andunlinkdeletes them, as before.
Every child route is scoped to its parent. The parent is loaded through pact.FormExtendQuery, and the child must belong to it: on a hasMany by its ForeignKey, on a belongsToMany by a pivot row. A child of another parent, or any child of a parent the administrator cannot open, answers 404 not_found, never 403, so a request cannot tell a foreign record from a missing one. A delete is all or nothing: when one of the ids is not a child of the parent, nothing is deleted.
A controller can hook into the child writes with the optional pact.RelationBeforeCreate and pact.RelationAfterCreate, pact.RelationBeforeUpdate and pact.RelationAfterUpdate, and pact.RelationBeforeDelete and pact.RelationAfterDelete, each called with the relation name, the parent and the child. A hook error rolls the whole write back and answers the generic lifecycle error.
Pivot forms
A belongsToMany relation can edit columns of its pivot rows with pivot.form (a pivot form on a hasMany stops the start-up):
editors:
label: acme.blog::lang.posts.editors
pivot:
form: $/acme/blog/models/posteditor/pivot_fields.yaml
WinterCMS names pivot form fields pivot[role]; both pivot[role] and the bare role are accepted and compile to the pivot column role. Each field must be a scalar or datepicker column of the pivot model, and never one of the pivot's foreign keys, id, created_at, updated_at, deleted_at or a HookPivotColumns entry: those stay server-owned.
The SPA sends pivot values when it links one record: {"ids": [7], "pivot": {"role": "reviewer"}}. A pivot object with more than one id answers 422 on ids, and a key that is not a pivot form field answers 422 on that key. The values are filled into the pivot model through the pivot form's fields only, validated, and then pact.RelationBeforeLink stamps its hook columns as before. Later, GET and PUT .../{id}/relations/{name}/pivot/{child} read and save the same values on an existing link. Both need a pivot form and the link or update button, and a record not linked to the parent answers 404.
Managers on the create screen
A relation manager also works on a record that is not saved yet, as WinterCMS's RelationController does with deferred binding. A relation is deferrable when it can hold its changes until the record exists: a belongsToMany always (the pivot rows are written on save), a hasMany only with a nullable ForeignKey. The relation schema and the relation-manager form field carry deferrable, and the SPA shows deferrable managers on the create screen.
On the create screen the record id in every relation route is 0, and the SPA sends its form session key in X-Session-Key, the same key the record's file uploads use. Id 0 is accepted only for a deferrable relation, with a valid key, on a controller that declares create, when the relation-manager field is not hidden from the create context; otherwise it is 404. The work is held in deferred_bindings against the key and the signed-in administrator:
- Creating a child inserts it at once (a hasMany child with a NULL
ForeignKey) and binds it to the session, marked as created. - Linking binds existing records, with any pivot values, after the same eligibility checks as a link on a saved record. A record another session created is never a candidate, so it cannot be adopted before its own form is saved.
- Unlinking and deleting cancel a pending bind; a child the session created is deleted.
- The linked list shows the session's pending records, and the child and pivot routes read and edit them.
The record's first save with the same X-Session-Key applies all of it inside its transaction, in the order it happened: hasMany children get the new record's key, belongsToMany links get their pivot rows with the stored pivot values and pact.RelationBeforeLink stamps. A linked record is checked again with the saved record, because ExcludedRelatedIDs and pact.RelationExtendManageQuery saw a record without a key when it was linked; if it is no longer eligible the save answers 422 on the relation-manager field, nothing is saved, and the pending work stays for the next attempt. Pending work of another administrator's key is never read.
Nothing has to cancel an abandoned form. deferred:purge (also run daily by the scheduler) removes expired bindings and deletes the children they created. A deferrable relation that declares create therefore needs its related model in some plugin's pact.HasModels Models() list; otherwise the start-up stops, because the purge could not delete those children.
Existing belongsToMany managers become deferrable too. A manager without context: update on its relation-manager field now appears on the create screen; keep context: update to show it only after the first save.
Files in child forms
A fileupload field in a relation's manage form works inside the child modal through its own routes under .../{id}/relations/{name}/records/{child}/files/{field}: list, upload, caption, remove, reorder, download and thumb, as for a record's own files. The child modal has its own form session key, sent in X-Child-Session-Key; the child's create or update with the same header attaches the files in the child's transaction. {child} 0 is the child being created and needs the create button and the child key; a saved child is scoped to the parent like the other child routes and needs update to change files. On a record that is not saved yet, the modal sends both headers: X-Session-Key for the parent and X-Child-Session-Key for the child.
Messages
A relation's messages block overrides the relation manager's copy, each key a phrase key; an omitted key falls back to backend::lang.messages.relation.<key in snake_case>. The keys are link, linkHint, candidateSearch, linked, unlinkSelected, unlinkConfirm, unlinked and empty for the link panels, and create, createTitle, updateTitle, previewTitle, created, updated, deleteSelected, deleteConfirm, deleteOneConfirm, deleted, pivotTitle, pivotSaved, editPivot, createSubmit, updateSubmit, pivotSubmit and linkSubmit for the child and pivot modals. A key that names a missing phrase stops the start-up.
Relations in lists
A list column can show a related value with relation and select in columns.yaml; see Lists and filters. A controller that maps a relation column to a physical column itself implements pact.ListRelationColumnMapper.