--- title: Relation manager description: Edit belongsTo and belongsToMany fields, and manage linked and hasMany records with config_relation.yaml, bound to models the controller names. section: backend order: 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](../../modules/cabana/README.md) 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: ```yaml 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: ```go src=modules/cabana/example_form_seams_test.go#MembersController.AdminFieldRelations // 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. A save that moves the member to // another team (or to none) clears team_role, unless it writes a new one. func (MembersController) AdminFieldRelations() []cabana.FieldRelationContract { return []cabana.FieldRelationContract{{ Field: "team", Kind: "belongsTo", NewRelated: func() any { return &Team{} }, ForeignKey: "organisation_id", WritableForeignKey: true, ClearOnChange: []string{"team_role"}, }, { 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. ### Clearing dependent columns Some columns only make sense for one parent: a member's role in an organisation means nothing in the next organisation. `ClearOnChange` on a belongsTo contract names such columns of the record; a save that changes the field's foreign key, to another record or to none, sets them to NULL, so the role never moves along with the member. The team contract above clears `team_role` this way. A save that keeps the key leaves the columns alone, and so does a save that writes a column through its own form field: an administrator who moves a member and picks the new role in the same save keeps that role. Each column must be a nullable column of the record (a pointer field), and neither the foreign key nor the primary key; anything else, or `ClearOnChange` on a belongsToMany contract, stops the start-up. The zero value changes nothing. A hasMany relation manager has the same option on `cabana.RelationContract` (see [Relation kinds](#relation-kinds)). ### 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. ```go src=modules/cabana/example_form_seams_test.go#MembersController.AdminRelationLocks // 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 no `locked` key. - 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 `forbidden` with the lock's `Message` (a translation key or text), also as a message on the field, and nothing is written. With an empty `Message` the 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 lock covers the form field only. A relation manager declared for the same relation does not ask the provider: its link and unlink routes change the links whatever the lock says. Give a relation whose links carry privilege one writer, the form field, and declare no relation manager for it. - The provider is asked with the request context: `bouncer.User(ctx)` is the administrator, and inside a save `cabana.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`): ```yaml 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`, `RelatedForeignKey` and optionally `HookPivotColumns`. A contract that leaves `Kind` empty is a belongsToMany, so contracts written before hasMany existed keep working unchanged. - `hasMany` (`cabana.RelationHasMany`) owns records through a column on the related model: `ForeignKey` names it. A hasMany contract declares no pivot fields; a pivot field, a `ForeignKey` that is not an integer column of the related model, or an unknown kind stops the start-up. ```go src=modules/cabana/example_relation_test.go#PostsController.AdminRelationContracts // 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. A hasMany contract may also name `ClearOnChange` columns of the related model: nullable columns that a link and an unlink set to NULL together with the `ForeignKey`, deferred links and unlinks included. With an organisation's `members` relation and `ClearOnChange: []string{"organisation_role"}`, a user added to or removed from the organisation never keeps a role from elsewhere. The rules are those of the belongsTo option: a pointer field, neither the `ForeignKey` nor the primary key, and only on a hasMany; the zero value changes nothing. ### 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: ```yaml 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///...`, or WinterCMS's `$///...`. 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, `delete` deletes the child through its model, so its hooks and soft delete run. `link` adopts records whose `ForeignKey` is NULL, setting it to the parent's key through the child model; `unlink` sets it back to NULL. Both also set the contract's `ClearOnChange` columns to NULL. - On a belongsToMany, `delete` removes this parent's pivot row and then deletes the related record through its model. `link` writes pivot rows and `unlink` deletes 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): ```yaml 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.`. 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](lists-and-filters.md). On a to-many relation the column joins the selected value of every related record with `, `, as WinterCMS does, and stays empty when there are none. A controller that maps a relation column to a physical column itself implements `pact.ListRelationColumnMapper`.