- FieldRelationContract.WritableForeignKey makes a belongsTo field over a protected foreign key writable; the protected key list is unchanged - cabana.RelationLockProvider names related ids an administrator may not add or remove: options and labels carry locked, and a create or update that changes the locked subset is 403 before any row is written - columns.yaml invisible keeps a column searchable and out of the rows - a controller implementing pact.FilterOptions serves a scope filter's choices before the model - SPA: locked chips and options in RelationField, DataTable skips invisible columns - README, docs, OpenAPI document, TS types and dist updated
19 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.
Writable foreign keys
Some column names are never written from a request body, whatever the form declares: id, created_at, updated_at, deleted_at, owner_id, user_id, collection_id, organisation_id, organization_id, scope_id, role_id, permissions, is_superuser, is_system, is_activated and password. A belongsTo field whose ForeignKey is one of them is therefore read-only: it shows the linked record's label and has no options endpoint. A contract that sets WritableForeignKey makes this one field writable:
// AdminFieldRelations binds the form's two relation fields. organisation_id
// is a protected column, so the team field would be read-only; the contract
// opts in to writing it through this field.
func (MembersController) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{{
Field: "team",
Kind: "belongsTo",
NewRelated: func() any { return &Team{} },
ForeignKey: "organisation_id",
WritableForeignKey: true,
}, {
Field: "groups",
Kind: "belongsToMany",
NewRelated: func() any { return &Group{} },
NewPivot: func() any { return &MemberGroup{} },
ParentForeignKey: "member_id",
RelatedForeignKey: "group_id",
}}
}
The protection itself does not change. A plain field named organisation_id is still dropped from a body, and the id submitted through the relation field is still rechecked against the scoped options query, so only a record the controller offers can be set. WritableForeignKey on a belongsToMany contract stops the start-up.
Locked options
A controller may let an administrator see a choice without letting them change it, for example membership of a group that grants access. It implements cabana.RelationLockProvider and returns, per field and per request, a cabana.RelationLock: the related ids that are locked for the signed-in administrator, and a message.
// AdminRelationLocks names the groups an administrator without
// acme.roster.manage may not put a member into or take a member out of. Inside
// a save the ids are read with the save's transaction.
func (c MembersController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) {
principal, _ := bouncer.User(ctx)
if field != "groups" || cabana.Allows(principal, []string{"acme.roster.manage"}) {
return cabana.RelationLock{}, nil
}
db := c.DB
if tx, ok := cabana.TxFromContext(ctx); ok {
db = tx
}
lock := cabana.RelationLock{Message: "acme.roster::lang.members.group_locked"}
err := db.WithContext(ctx).Model(&Group{}).Where("code = ?", "staff").Pluck("id", &lock.IDs).Error
return lock, err
}
- The options endpoint and the labels of every record response mark those records with
locked: true. The admin SPA shows a locked chip without a remove button, does not let a locked option be chosen, and prints a short note under the field. A response without locks has nolockedkey. - The server enforces the lock; the flag is only a display aid. A create or update is checked inside its transaction, after the scope check and before any row is written. For a belongsToMany field the locked ids among the record's current links and among the submitted ids must be the same set, so a locked record can be neither added nor removed while other links change freely. For a belongsTo field a change is refused when the current or the submitted record is locked.
- A refused save is answered 403
forbiddenwith the lock'sMessage(a translation key or text), also as a message on the field, and nothing is written. With an emptyMessagethe admin shows its own text. - A save that does not send the field is not checked: it changes nothing. A controller without the provider behaves as before.
- The provider is asked with the request context:
bouncer.User(ctx)is the administrator, and inside a savecabana.TxFromContext(ctx)is the transaction.
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.