feat(cabana): ClearOnChange clears dependent columns when a relation changes a foreign key

RelationContract (hasMany) and FieldRelationContract (belongsTo) accept
ClearOnChange, a list of nullable columns set to NULL whenever the relation
changes the foreign key: a hasMany link and unlink (deferred ones included)
clear them on the child, and a form save that moves a belongsTo key to
another record or to none clears them on the record unless the same save
writes the column through its own field. Boot refuses the option on a
belongsToMany and for a missing, non-nullable, foreign-key or primary-key
column. The zero value changes nothing.
This commit is contained in:
Jakub Zych
2026-10-22 03:30:35 +02:00
parent 9563bc836a
commit 9516a2b36f
8 changed files with 492 additions and 11 deletions

View File

@@ -30,7 +30,8 @@ Some column names are never written from a request body, whatever the form decla
```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.
// 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",
@@ -38,6 +39,7 @@ func (MembersController) AdminFieldRelations() []cabana.FieldRelationContract {
NewRelated: func() any { return &Team{} },
ForeignKey: "organisation_id",
WritableForeignKey: true,
ClearOnChange: []string{"team_role"},
}, {
Field: "groups",
Kind: "belongsToMany",
@@ -51,6 +53,12 @@ func (MembersController) AdminFieldRelations() []cabana.FieldRelationContract {
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.
@@ -132,6 +140,8 @@ func (PostsController) AdminRelationContracts() []cabana.RelationContract {
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:
@@ -187,7 +197,7 @@ The `manage` panel may declare only `link`. An unknown or duplicate button stops
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.
- 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.