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

@@ -25,7 +25,7 @@ Use cases:
- Boot-time schema compilation: `cabana.CompileList` and `cabana.CompileForm` read a controller's YAML from the plugin's embedded tree, check that `modelClass` matches the controller's model name, and cache a locale-neutral schema. Each request gets a translated copy (`cabana.ListSchema.Localize`, `cabana.FormSchema.Localize`, `cabana.RelationSchema.Localize`) through [phrasebook](../phrasebook/README.md), with CLDR plural forms for the SPA's messages.
- Generic CRUD with `cabana.CRUDService`: list, show, create, update, delete and bulk delete. Writes run in transactions, and reads and writes are scoped by the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` hooks. `cabana.ExecuteList` applies search, sort, filters and pagination only on columns declared in the schema, so request parameters never reach SQL directly.
- Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies; only a `type: repeater` field's list of rows is exempt from the nested-value drop. Declared `mltext`, `mltextarea` and `mlmarkdown` locale maps are lifted before that drop: the default locale becomes the host scalar and the other locales are written through a published `cabana.TranslationWriter` inside the same permissioned save transaction. Values are filled and validated through [lagoon](../lagoon/README.md); a value that does not fit its column (a `lagoon.FillTypeError`, such as a fraction for an integer field) is a 422 `validation_failed` on that field, and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write.
- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates, linking and unlinking, and creating related records. A contract is a belongsToMany (`cabana.RelationBelongsToMany`, the kind of a contract that leaves `Kind` empty) with a pivot model, or a hasMany (`cabana.RelationHasMany`) whose `ForeignKey` column on the related model points at the parent. A relation's child form comes from `manage.form` in `config_relation.yaml` (or a top-level `form`, WinterCMS's fallback), its read-only preview from `view.form` (same fallback), and a belongsToMany's pivot columns are edited through `pivot.form` (WinterCMS `pivot[x]` field names compile to `x`; pivot keys, timestamps and hook columns are refused); WinterCMS `$/<vendor>/<plugin>/...` paths resolve inside the same plugin only. A relation form accepts the scalar field types plus `datepicker` and `fileupload`; `relation`, `relation-manager`, `widget` and `partial` fail boot. The view panel's `toolbarButtons` (`create`, `update`, `delete`, `link`, `unlink`) are the capability of their routes. A relation's `messages` block takes the link keys (`link`, `linkHint`, `candidateSearch`, `linked`, `unlinkSelected`, `unlinkConfirm`, `unlinked`, `empty`) and the child and pivot modal keys (`create`, `createTitle`, `updateTitle`, `previewTitle`, `created`, `updated`, `deleteSelected`, `deleteConfirm`, `deleteOneConfirm`, `deleted`, `pivotTitle`, `pivotSaved`, `editPivot`, `createSubmit`, `updateSubmit`, `pivotSubmit`, `linkSubmit`), each defaulting to `backend::lang.messages.relation.*`. Framework code never guesses table, pivot or foreign-key names: the controller supplies them. A belongsTo field over a protected foreign key is read-only unless its contract sets `WritableForeignKey`; the protected key list itself does not change, and the submitted id is still rechecked through the scoped options query. A controller implementing `cabana.RelationLockProvider` returns a `cabana.RelationLock` per field and request: options and labels carry `locked: true` for those ids, and a create or update that adds or removes a locked id is answered 403 `forbidden` before any row is written.
- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates, linking and unlinking, and creating related records. A contract is a belongsToMany (`cabana.RelationBelongsToMany`, the kind of a contract that leaves `Kind` empty) with a pivot model, or a hasMany (`cabana.RelationHasMany`) whose `ForeignKey` column on the related model points at the parent. A relation's child form comes from `manage.form` in `config_relation.yaml` (or a top-level `form`, WinterCMS's fallback), its read-only preview from `view.form` (same fallback), and a belongsToMany's pivot columns are edited through `pivot.form` (WinterCMS `pivot[x]` field names compile to `x`; pivot keys, timestamps and hook columns are refused); WinterCMS `$/<vendor>/<plugin>/...` paths resolve inside the same plugin only. A relation form accepts the scalar field types plus `datepicker` and `fileupload`; `relation`, `relation-manager`, `widget` and `partial` fail boot. The view panel's `toolbarButtons` (`create`, `update`, `delete`, `link`, `unlink`) are the capability of their routes. A relation's `messages` block takes the link keys (`link`, `linkHint`, `candidateSearch`, `linked`, `unlinkSelected`, `unlinkConfirm`, `unlinked`, `empty`) and the child and pivot modal keys (`create`, `createTitle`, `updateTitle`, `previewTitle`, `created`, `updated`, `deleteSelected`, `deleteConfirm`, `deleteOneConfirm`, `deleted`, `pivotTitle`, `pivotSaved`, `editPivot`, `createSubmit`, `updateSubmit`, `pivotSubmit`, `linkSubmit`), each defaulting to `backend::lang.messages.relation.*`. Framework code never guesses table, pivot or foreign-key names: the controller supplies them. A belongsTo field over a protected foreign key is read-only unless its contract sets `WritableForeignKey`; the protected key list itself does not change, and the submitted id is still rechecked through the scoped options query. `ClearOnChange` on a belongsTo `cabana.FieldRelationContract` or a hasMany `cabana.RelationContract` names nullable columns (of the record, or of the related child) that are set to NULL whenever the relation changes the foreign key, so a value that only makes sense for one parent (a role in an organisation) never moves to another or outlives the membership; a form save that writes such a column through its own field keeps the submitted value. A controller implementing `cabana.RelationLockProvider` returns a `cabana.RelationLock` per field and request: options and labels carry `locked: true` for those ids, and a create or update that adds or removes a locked id is answered 403 `forbidden` before any row is written.
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Like the list schema's `toolbarActions`, the form schema carries a widget field only when the requesting administrator may run its action. The field's `context` applies to the action route as it does on save: a request without `record_id` is the create form's and one with it the update form's, and a widget its context hides on that form answers 404. Besides `record_id` and `values`, the request may carry `payload`, the widget's own JSON value (at most 64 KiB, a 422 above that; refused on toolbar and record routes), which reaches the action unfiltered as the `Payload` field of `pact.AdminActionInput`; the action may answer with `Data` on `pact.AdminActionResult`, written to the response as `data` exactly as it encodes (any JSON up to 256 KiB encoded, a 500 above that or when it cannot be encoded), not subject to the fill allowlist and omitted when nil. The element asks for its action with a `summer-action` event whose optional `detail.payload` becomes the request `payload`, and receives the answer as a `data` attribute plus a `summer-result` event with `{data, fill, message}`. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot.
- Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file.
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
@@ -99,7 +99,7 @@ Guarded routes run surf's `locale.from-principal` after the backend guard: an in
| GET `/{vendor}/{plugin}/{controller}/singleton` | The id of a `pact.AdminSingleton` controller's one record, created when absent. |
| GET `.../fields/{field}/dependent-options` | Choices of a dropdown with `dependsOn` for the sibling value passed as `values[<sibling>]`, from the controller's `pact.DependentOptionsProvider`: `{applies, options: [{value, label, depth}]}` (`cabana.AdminDependentOptionsResult`). A field that is not a dependsOn dropdown answers 404. |
| GET `.../{id}/relations/{name}`, GET `.../{id}/relations/{name}/candidates` | Linked records and link candidates of a relation manager. |
| POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. A link body may carry `pivot` values for one id on a relation with a pivot form. On a hasMany, link sets and unlink clears the related record's foreign key. |
| POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. A link body may carry `pivot` values for one id on a relation with a pivot form. On a hasMany, link sets and unlink clears the related record's foreign key (and the contract's `ClearOnChange` columns). |
| POST `.../{id}/relations/{name}/records` | Create a related record through the relation's manage form and attach it to the record (a hasMany foreign key is set by the server; a belongsToMany gets its pivot row). Needs `create` in the view panel's `toolbarButtons` (403 otherwise). |
| GET and PUT `.../{id}/relations/{name}/records/{child}` | Show a child (needs `update` or a view form) and save it through the manage form (needs `update`). |
| POST `.../{id}/relations/{name}/delete` | `{ids}`: delete children through their model (needs `delete`). A belongsToMany record loses this record's pivot row first. All or nothing. |
@@ -269,10 +269,10 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.SettingsMeta` | Meta of a settings result: `secrets` (field to `stored`, `env` or `empty`) and the richeditor `notices`; omitted when empty. |
| `cabana.SecretFallbacks` | Implemented by a settings model whose password fields fall back to a config key when the column is empty: field name to compass key. |
| `cabana.SecretStored` / `cabana.SecretEnv` / `cabana.SecretEmpty` | The states of a write-only settings secret. |
| `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. `WritableForeignKey` on a belongsTo contract makes a protected foreign key writable through that field. |
| `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. `WritableForeignKey` on a belongsTo contract makes a protected foreign key writable through that field; `ClearOnChange` lists nullable columns of the record set to NULL when a save changes the key, unless the save writes them. |
| `cabana.RelationLock` | The related ids of one relation field the requesting administrator may not add or remove, and the message of the 403. |
| `cabana.RelationLockProvider` | Controller capability that returns the `cabana.RelationLock` of a relation field per request; enforced on create and update. |
| `cabana.AdminRelationContractProvider` / `cabana.RelationContract` | Controller-supplied bindings for relation managers. |
| `cabana.AdminRelationContractProvider` / `cabana.RelationContract` | Controller-supplied bindings for relation managers. `ClearOnChange` on a hasMany lists nullable columns of the child set to NULL by link and unlink. |
| `cabana.RelationBelongsToMany` / `cabana.RelationHasMany` | The two `RelationContract.Kind` values; an empty `Kind` is a belongsToMany. |
| `cabana.AdminRelationChildCreate` / `cabana.AdminRelationChildShow` / `cabana.AdminRelationChildUpdate` / `cabana.AdminRelationChildDelete` / `cabana.AdminRelationPivotShow` / `cabana.AdminRelationPivotUpdate` | Swag annotations of the relation child and pivot routes. |
| `cabana.RelationMutationInput` | Body of the link and unlink routes: `IDs` and, for a link of one id, `Pivot` form values. |
@@ -387,6 +387,8 @@ The Phase 18 pieces (money field and column, write-only settings secrets, riched
The Phase 18.1 pieces (the taglist field and the flat reorder) are covered by Docker-free unit tests in `taglist_field_test.go` (TestTaglistFieldCompile, TestTaglistFieldValidate for the save check in both modes, TestTaglistFieldHydrate for the record GET) and `reorder_flat_test.go` (TestReorderFlatCompile, which also checks the localized list schema, and TestReorderFlatNodes for the save's node check), and by Postgres tests in `catalog_smoke_test.go` (TestTaglistFieldSmoke, TestReorderFlatSmoke with a partitioned subtest) and `reorder_test.go` (TestReorderPostgres for the tree reorder). The admin specs are `admin/tests/form/TaglistField.test.ts` and `admin/tests/views/ReorderView.test.ts`. `scripts/check-phase18.1.sh` runs them by name.
`ClearOnChange` is covered by `clear_on_change_field_test.go` (TestClearOnChangeFieldBoot, Docker-free, and TestClearOnChangeBelongsTo on Postgres: a changed, emptied, kept or co-submitted belongsTo key) and `clear_on_change_test.go` (TestClearOnChangeRelationBoot and, on Postgres, TestClearOnChangeHasMany: link, unlink and a deferred link committed by the parent's first save).
A Detached bulk action (`pact.AdminBulkAction.Detached`) is covered by the Postgres test TestDetachedBulkAction in `bulk_detached_test.go`, on the roster fixture with two test-only actions: Run sees no transaction (`TxFromContext`) and holds no row lock (another connection's `SELECT ... FOR UPDATE NOWAIT` succeeds during Run); the empty, absent and partial-selection rules match the locked path; a `ValidationError` from Run is 422 and the rows written before it stay written; the same action without Detached still runs in the transaction holding the row locks. `scripts/check-phase18.1.sh` requires it in its postgres stage.
The localization of refusals is covered by Docker-free unit tests in `localize_error_test.go` (TestLocalizeControllerErrorValidation, TestLocalizeControllerErrorForbidden and TestWriteCRUDErrorLocalized, on a two-locale catalog; the plugin's error value stays unchanged and text without `::` is never looked up) and by the Postgres tests TestValidationErrorLocalized (a form hook's `cabana.ValidationError` in en and pl) and TestReorderHookRefusalLocalized (both refusal types from `pact.ReorderAfterSave`).

View File

@@ -0,0 +1,222 @@
package cabana
import (
"context"
"strings"
"testing"
"testing/fstest"
)
// cocTeam is the related model of the ClearOnChange belongsTo fixture.
type cocTeam struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
}
func (cocTeam) TableName() string { return "cabana_coc_teams" }
// cocMember belongs to a team through the protected organisation_id; its
// role only makes sense inside that team.
type cocMember struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
OrganisationID *uint `gorm:"column:organisation_id"`
Role *string `gorm:"column:role"`
Note *string `gorm:"column:note"`
}
func (cocMember) TableName() string { return "cabana_coc_members" }
func (cocMember) Fillable() []string { return []string{"name", "role", "note"} }
func (cocMember) Rules() map[string]string { return map[string]string{"name": "required"} }
type cocController struct {
relations []FieldRelationContract
}
func (cocController) ID() string { return "acme.demo.members" }
func (cocController) ModelName() string { return "Member" }
func (cocController) ConfigDir() string { return "controllers/members" }
func (cocController) NewRecord() any { return &cocMember{} }
func (c cocController) AdminFieldRelations() []FieldRelationContract {
return c.relations
}
// cocTeamContract is the team field's contract with ClearOnChange role.
func cocTeamContract(clear ...string) FieldRelationContract {
return FieldRelationContract{
Field: "team", Kind: relationKindBelongsTo, NewRelated: func() any { return &cocTeam{} },
ForeignKey: "organisation_id", WritableForeignKey: true, ClearOnChange: clear,
}
}
func cocFS() fstest.MapFS {
return fstest.MapFS{
"controllers/members/config_list.yaml": &fstest.MapFile{Data: []byte("modelClass: Member\nlist: ~/plugins/acme/demo/models/member/columns.yaml\n")},
"controllers/members/config_form.yaml": &fstest.MapFile{Data: []byte("name: members\nform: ~/plugins/acme/demo/models/member/fields.yaml\nmodelClass: Member\n")},
"models/member/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n")},
"models/member/fields.yaml": &fstest.MapFile{Data: []byte(`fields:
name:
label: Name
type: text
team:
label: Team
type: relation
nameFrom: name
role:
label: Role
type: text
note:
label: Note
type: text
`)},
}
}
func cocCompile(relations ...FieldRelationContract) (*CompiledController, error) {
reg, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: cocFS()}, ctl: cocController{relations: relations}}})
if err != nil {
return nil, err
}
cc, _ := reg.Get("acme.demo.members")
return cc, nil
}
// TestClearOnChangeFieldBoot: ClearOnChange on a form relation field is
// refused at boot on a belongsToMany, and for a column the record lacks, a
// column that is not nullable, the foreign key and the primary key; the
// error names the controller, the field and the column.
func TestClearOnChangeFieldBoot(t *testing.T) {
if _, err := cocCompile(cocTeamContract("role", "note")); err != nil {
t.Fatalf("a valid ClearOnChange: %v", err)
}
many := cocTeamContract("role")
many.Kind, many.ForeignKey, many.WritableForeignKey = relationKindBelongsToMany, "", false
for name, tc := range map[string]struct {
contract FieldRelationContract
want string
}{
"belongsToMany": {many, "ClearOnChange is only valid on belongsTo"},
"missing column": {cocTeamContract("rank"), "ClearOnChange column rank is not a column of the model"},
"not nullable": {cocTeamContract("name"), "ClearOnChange column name is not nullable"},
"foreign key": {cocTeamContract("organisation_id"), "ClearOnChange column organisation_id is the foreign key"},
"primary key": {cocTeamContract("id"), "ClearOnChange column id is the primary key"},
} {
t.Run(name, func(t *testing.T) {
_, err := cocCompile(tc.contract)
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "acme.demo.members") || !strings.Contains(err.Error(), "field team") {
t.Fatalf("err = %v, want %q naming the controller and the field", err, tc.want)
}
})
}
}
// TestClearOnChangeBelongsTo: a save that changes a belongsTo key clears the
// contract's ClearOnChange columns unless the same save writes them; a save
// that keeps the key, and a contract without ClearOnChange, leave them.
func TestClearOnChangeBelongsTo(t *testing.T) {
_, db := newListService(t)
if err := db.Migrator().DropTable(&cocMember{}, &cocTeam{}); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&cocMember{}, &cocTeam{}); err != nil {
t.Fatal(err)
}
red, blue := cocTeam{Name: "Red"}, cocTeam{Name: "Blue"}
for _, team := range []*cocTeam{&red, &blue} {
if err := db.Create(team).Error; err != nil {
t.Fatal(err)
}
}
cc, err := cocCompile(cocTeamContract("role"))
if err != nil {
t.Fatal(err)
}
svc := CRUDService{DB: db}
ctx := context.Background()
str := func(s string) *string { return &s }
seed := func(t *testing.T, team *uint, role string) uint {
t.Helper()
m := cocMember{Name: "M", OrganisationID: team, Role: str(role), Note: str("note")}
if err := db.Create(&m).Error; err != nil {
t.Fatal(err)
}
return m.ID
}
load := func(t *testing.T, id uint) cocMember {
t.Helper()
var m cocMember
if err := db.Take(&m, id).Error; err != nil {
t.Fatal(err)
}
return m
}
update := func(t *testing.T, cc *CompiledController, id uint, body map[string]any) cocMember {
t.Helper()
body["name"] = "M"
if _, err := svc.Update(ctx, cc, id, RecordInput{Body: body}); err != nil {
t.Fatalf("update %v: %v", body, err)
}
return load(t, id)
}
roleIs := func(t *testing.T, m cocMember, want string) {
t.Helper()
switch {
case want == "" && m.Role != nil:
t.Fatalf("role = %q, want NULL", *m.Role)
case want != "" && (m.Role == nil || *m.Role != want):
t.Fatalf("role = %v, want %q", m.Role, want)
}
if m.Note == nil || *m.Note != "note" {
t.Fatalf("a column outside ClearOnChange changed: note = %v", m.Note)
}
}
t.Run("another record clears", func(t *testing.T) {
m := update(t, cc, seed(t, &red.ID, "owner"), map[string]any{"team": blue.ID})
if m.OrganisationID == nil || *m.OrganisationID != blue.ID {
t.Fatalf("organisation_id = %v", m.OrganisationID)
}
roleIs(t, m, "")
})
t.Run("none clears", func(t *testing.T) {
m := update(t, cc, seed(t, &red.ID, "owner"), map[string]any{"team": nil})
if m.OrganisationID != nil {
t.Fatalf("organisation_id = %v", *m.OrganisationID)
}
roleIs(t, m, "")
})
t.Run("the same key keeps", func(t *testing.T) {
roleIs(t, update(t, cc, seed(t, &red.ID, "owner"), map[string]any{"team": red.ID}), "owner")
})
t.Run("no relation value keeps", func(t *testing.T) {
roleIs(t, update(t, cc, seed(t, &red.ID, "owner"), map[string]any{}), "owner")
})
t.Run("a submitted column wins", func(t *testing.T) {
roleIs(t, update(t, cc, seed(t, &red.ID, "owner"), map[string]any{"team": blue.ID, "role": "member"}), "member")
})
t.Run("a create that sets the key", func(t *testing.T) {
rec, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "New", "team": red.ID, "note": "note"}})
if err != nil {
t.Fatal(err)
}
id, _ := asUint(rec["id"])
m := load(t, id)
if m.OrganisationID == nil || *m.OrganisationID != red.ID {
t.Fatalf("organisation_id = %v", m.OrganisationID)
}
roleIs(t, m, "")
rec, err = svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "New", "team": red.ID, "role": "admin", "note": "note"}})
if err != nil {
t.Fatal(err)
}
id, _ = asUint(rec["id"])
roleIs(t, load(t, id), "admin")
})
t.Run("without ClearOnChange nothing is cleared", func(t *testing.T) {
plain, err := cocCompile(cocTeamContract())
if err != nil {
t.Fatal(err)
}
roleIs(t, update(t, plain, seed(t, &red.ID, "owner"), map[string]any{"team": blue.ID}), "owner")
})
}

View File

@@ -0,0 +1,119 @@
package cabana_test
import (
"net/http"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/cabana"
)
// clearOnChangeContracts is dfContracts with ClearOnChange columns on the
// parts hasMany.
func clearOnChangeContracts(columns ...string) func() []cabana.RelationContract {
return func() []cabana.RelationContract {
cs := dfContracts()
cs[1].ClearOnChange = columns
return cs
}
}
// TestClearOnChangeRelationBoot: ClearOnChange on a relation contract is
// refused at boot on a belongsToMany, and for a column the child lacks, a
// column that is not nullable, the ForeignKey and the primary key; the
// error names the controller, the relation and the column.
func TestClearOnChangeRelationBoot(t *testing.T) {
member := func() []cabana.RelationContract {
cs := dfContracts()
cs[0].ClearOnChange = []string{"email"}
return cs
}
for name, tc := range map[string]struct {
contracts func() []cabana.RelationContract
want string
}{
"belongsToMany": {member, "relation members: ClearOnChange is only valid on a hasMany contract"},
"missing column": {clearOnChangeContracts("rank"), "relation parts: ClearOnChange column rank is not a column of the model"},
"not nullable": {clearOnChangeContracts("label"), "relation parts: ClearOnChange column label is not nullable"},
"foreign key": {clearOnChangeContracts("gadget_id"), "relation parts: ClearOnChange column gadget_id is the foreign key"},
"primary key": {clearOnChangeContracts("id"), "relation parts: ClearOnChange column id is the primary key"},
} {
t.Run(name, func(t *testing.T) {
err := activateDeferred(t, withContracts(nil, tc.contracts)(t), nil)
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "acme.deferred.gadgets") {
t.Fatalf("err = %v, want %q naming the controller", err, tc.want)
}
})
}
if err := activateDeferred(t, withContracts(nil, clearOnChangeContracts("due_on"))(t), nil); err != nil {
t.Fatalf("a valid ClearOnChange: %v", err)
}
}
// TestClearOnChangeHasMany: a hasMany link and unlink in the relation
// manager clear the contract's ClearOnChange columns of the child, a
// deferred link committed by the parent's first save too, and a contract
// without ClearOnChange leaves them.
func TestClearOnChangeHasMany(t *testing.T) {
env := newDeferredEnvWith(t, withContracts(nil, clearOnChangeContracts("due_on"))(t))
g := env.gadget(t, "g-"+env.stamp, false)
parts := func(gadget uint, rest string) string { return dfPath(gadget, "/relations/parts"+rest) }
dated := func(t *testing.T, owner uint, label string) uint {
t.Helper()
id := env.part(t, owner, label)
if err := env.db.Exec(`UPDATE cabana_deferred_parts SET due_on = '2026-11-01' WHERE id = ?`, id).Error; err != nil {
t.Fatal(err)
}
return id
}
dueOn := func(t *testing.T, id uint) bool {
t.Helper()
p, ok := env.partRow(t, id)
if !ok {
t.Fatalf("part %d is gone", id)
}
return p.DueOn != nil
}
t.Run("link clears", func(t *testing.T) {
free := dated(t, 0, "free")
want(t, "link", env.a.do(t, http.MethodPost, parts(g, "/link"), map[string]any{"ids": []uint{free}}, nil), http.StatusOK)
if p, _ := env.partRow(t, free); p.GadgetID == nil || *p.GadgetID != g || p.Label != "free" || dueOn(t, free) {
t.Fatalf("linked part %+v, want owned by %d with due_on NULL", p, g)
}
})
t.Run("unlink clears", func(t *testing.T) {
owned := dated(t, g, "owned")
want(t, "unlink", env.a.do(t, http.MethodPost, parts(g, "/unlink"), map[string]any{"ids": []uint{owned}}, nil), http.StatusOK)
if p, _ := env.partRow(t, owned); p.GadgetID != nil || dueOn(t, owned) {
t.Fatalf("unlinked part %+v, want a NULL key and due_on", p)
}
})
t.Run("deferred link clears on the parent's first save", func(t *testing.T) {
h := sk(newSessionKey(t))
free := dated(t, 0, "deferred")
want(t, "deferred link", env.a.do(t, http.MethodPost, parts(0, "/link"), map[string]any{"ids": []uint{free}}, h), http.StatusOK)
if !dueOn(t, free) {
t.Fatal("a deferred link wrote the child before the save")
}
saved := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "d-" + env.stamp}, h)
want(t, "save", saved, http.StatusCreated)
parent := dataID(t, saved.Body.Bytes())
if p, _ := env.partRow(t, free); p.GadgetID == nil || *p.GadgetID != parent || dueOn(t, free) {
t.Fatalf("committed part %+v, want owned by %d with due_on NULL", p, parent)
}
})
t.Run("without ClearOnChange nothing is cleared", func(t *testing.T) {
plain := newDeferredEnv(t)
pg := plain.gadget(t, "p-"+plain.stamp, false)
free := plain.part(t, 0, "plain")
if err := plain.db.Exec(`UPDATE cabana_deferred_parts SET due_on = '2026-11-01' WHERE id = ?`, free).Error; err != nil {
t.Fatal(err)
}
want(t, "link", plain.a.do(t, http.MethodPost, dfPath(pg, "/relations/parts/link"), map[string]any{"ids": []uint{free}}, nil), http.StatusOK)
want(t, "unlink", plain.a.do(t, http.MethodPost, dfPath(pg, "/relations/parts/unlink"), map[string]any{"ids": []uint{free}}, nil), http.StatusOK)
if p, _ := plain.partRow(t, free); p.DueOn == nil {
t.Fatalf("a contract without ClearOnChange cleared due_on: %+v", p)
}
})
}

View File

@@ -768,7 +768,8 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
}
// The Form hooks read what this save writes for plugin field types.
ctx = withFieldWrites(ctx, decoded.writes)
if err := lagoon.Fill(target, fillAllowed(cc, target, op), projected, false); err != nil {
allowed := fillAllowed(cc, target, op)
if err := lagoon.Fill(target, allowed, projected, false); err != nil {
// A value that does not fit its column is the admin's input,
// not a missing capability: answer it on the field.
var typed *lagoon.FillTypeError
@@ -819,7 +820,15 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
if err := checkRelationLocks(ctx, tx, cc, target, relations); err != nil {
return err
}
if err := assignBelongsTo(cc, target, relations); err != nil {
// A belongsTo whose key changes clears its ClearOnChange columns,
// except those this save writes from its own form fields.
written := map[string]bool{}
for _, key := range allowed {
if _, ok := projected[key]; ok {
written[key] = true
}
}
if err := assignBelongsTo(cc, target, relations, written); err != nil {
return err
}
if update {

View File

@@ -25,6 +25,8 @@ type Member struct {
// OrganisationID is a protected column: the form writes it only through
// the team relation field.
OrganisationID *uint `gorm:"column:organisation_id"`
// TeamRole only makes sense inside the member's team.
TeamRole *string `gorm:"column:team_role"`
}
// Team is what a member belongs to; Group is what a member is put into.
@@ -156,7 +158,8 @@ func (MembersController) AdminSetPermissionValues(_ context.Context, field strin
// 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",
@@ -164,6 +167,7 @@ func (MembersController) AdminFieldRelations() []cabana.FieldRelationContract {
NewRelated: func() any { return &Team{} },
ForeignKey: "organisation_id",
WritableForeignKey: true,
ClearOnChange: []string{"team_role"},
}, {
Field: "groups",
Kind: "belongsToMany",

View File

@@ -55,6 +55,15 @@ type RelationContract struct {
Columns map[string]string
HookPivotColumns []string
ExcludedRelatedIDs func(parent any) ([]uint, error)
// ClearOnChange names nullable columns of the related child (hasMany
// only) that are set to NULL whenever this relation changes the child's
// ForeignKey: a link and an unlink in the relation manager, deferred ones
// included. Values that only make sense for one parent (a member's role
// in an organisation, for example) then never move to another parent or
// outlive the membership. Each column must be a pointer field of the
// related model and neither the ForeignKey nor the primary key. The zero
// value changes nothing.
ClearOnChange []string
}
// kind is the contract's normalized kind.
@@ -482,10 +491,16 @@ func validateRelationContract(ctl pact.AdminController, contract RelationContrac
if err := validatePivotContract(contract); err != nil {
return err
}
if len(contract.ClearOnChange) > 0 {
return fmt.Errorf("relation %s: ClearOnChange is only valid on a hasMany contract", contract.Name)
}
case RelationHasMany:
if err := validateHasManyContract(contract); err != nil {
return err
}
if err := validateClearOnChange(contract.NewRelated(), contract.ClearOnChange, contract.ForeignKey); err != nil {
return fmt.Errorf("relation %s: %w", contract.Name, err)
}
default:
return fmt.Errorf("relation %s has unknown kind %q (want %s or %s)", contract.Name, contract.Kind, RelationBelongsToMany, RelationHasMany)
}
@@ -578,6 +593,46 @@ func validateHasManyContract(contract RelationContract) error {
return nil
}
// validateClearOnChange checks the ClearOnChange columns of a contract
// against model: each must be a column of the model, held in a pointer
// field (a nullable column), and neither foreignKey nor the primary key.
func validateClearOnChange(model any, columns []string, foreignKey string) error {
pk := primaryColumn(model)
for _, col := range columns {
if !identifier(col) {
return fmt.Errorf("ClearOnChange column %q is not an identifier", col)
}
if col == foreignKey {
return fmt.Errorf("ClearOnChange column %s is the foreign key", col)
}
if col == pk {
return fmt.Errorf("ClearOnChange column %s is the primary key", col)
}
field, ok := structFieldByColumn(model, col)
if !ok {
return fmt.Errorf("ClearOnChange column %s is not a column of the model", col)
}
if field.Type.Kind() != reflect.Pointer {
return fmt.Errorf("ClearOnChange column %s is not nullable (it needs a pointer field)", col)
}
}
return nil
}
// clearColumns sets each column of model to NULL, except the columns in
// keep.
func clearColumns(model any, columns []string, keep map[string]bool) error {
for _, col := range columns {
if keep[col] {
continue
}
if err := setModelColumn(model, col, nil); err != nil {
return err
}
}
return nil
}
// relationDeferrable reports whether a relation can be managed before its
// parent is saved: a belongsToMany always (the pivot row is written on
// save), a hasMany only with a nullable ForeignKey (the child is inserted
@@ -945,6 +1000,11 @@ func (s RelationService) linkRelated(ctx context.Context, tx *gorm.DB, cc *Compi
if err := setModelColumn(related, cr.Contract.ForeignKey, ownerPK); err != nil {
return 0, lifecycleFailure(cc, err)
}
// A link always changes the key: it adopts only children whose
// key is NULL.
if err := clearColumns(related, cr.Contract.ClearOnChange, nil); err != nil {
return 0, lifecycleFailure(cc, err)
}
if err := tx.WithContext(ctx).Save(related).Error; err != nil {
return 0, lifecycleFailure(cc, err)
}
@@ -1245,6 +1305,9 @@ func (s RelationService) unlinkRelated(ctx context.Context, tx *gorm.DB, cc *Com
if err := setModelColumn(child, cr.Contract.ForeignKey, nil); err != nil {
return 0, lifecycleFailure(cc, err)
}
if err := clearColumns(child, cr.Contract.ClearOnChange, nil); err != nil {
return 0, lifecycleFailure(cc, err)
}
if err := tx.WithContext(ctx).Save(child).Error; err != nil {
return 0, lifecycleFailure(cc, err)
}

View File

@@ -42,6 +42,15 @@ type FieldRelationContract struct {
// WritableForeignKey declares a belongsTo foreign key that is a protected
// fill key writable through this relation field.
WritableForeignKey bool
// ClearOnChange names nullable columns of the record (belongsTo only)
// that are set to NULL whenever a save changes this field's foreign key
// (to another record or to none), unless the same save writes the column
// through its own form field. Values that only make sense for one parent
// (a member's role in an organisation, for example) then never move to
// another parent or outlive the membership. Each column must be a
// pointer field of the record and neither the ForeignKey nor the primary
// key. The zero value changes nothing.
ClearOnChange []string
}
// RelationLock names the related records of one relation field that the
@@ -245,10 +254,16 @@ func compileFieldRelation(ctl pact.AdminController, field FormField, contract Fi
}
out.Nullable = fk.Type.Kind() == reflect.Pointer
out.ReadOnly = protectedFillKey(contract.ForeignKey) && !contract.WritableForeignKey
if err := validateClearOnChange(parent, contract.ClearOnChange, contract.ForeignKey); err != nil {
return nil, err
}
case relationKindBelongsToMany:
if contract.WritableForeignKey {
return nil, errors.New("WritableForeignKey is only valid on belongsTo")
}
if len(contract.ClearOnChange) > 0 {
return nil, errors.New("ClearOnChange is only valid on belongsTo")
}
if contract.ForeignKey != "" {
return nil, errors.New("belongsToMany cannot declare a ForeignKey")
}
@@ -644,16 +659,26 @@ func checkRelationScope(ctx context.Context, tx *gorm.DB, cc *CompiledController
// assignBelongsTo writes validated belongsTo ids (or null) onto the parent's
// foreign key before the row write. Read-only keys never reach this point: a
// protected foreign key is written only when its contract declares
// WritableForeignKey, the same rule compileFieldRelation applies.
func assignBelongsTo(cc *CompiledController, model any, values []relationValue) error {
// WritableForeignKey, the same rule compileFieldRelation applies. When the
// submitted key differs from the record's current one, the contract's
// ClearOnChange columns are set to NULL, except those in written (the fill
// keys this save wrote from its own form fields).
func assignBelongsTo(cc *CompiledController, model any, values []relationValue, written map[string]bool) error {
for _, value := range values {
c := value.fr.Contract
if value.fr.Multiple || value.fr.ReadOnly || (protectedFillKey(c.ForeignKey) && !c.WritableForeignKey) {
continue
}
var id any
var next uint
if !value.null {
id = value.ids[0]
next = value.ids[0]
}
if len(c.ClearOnChange) > 0 && currentKey(model, c.ForeignKey) != next {
if err := clearColumns(model, c.ClearOnChange, written); err != nil {
return lifecycleFailure(cc, err)
}
}
if err := setModelColumn(model, value.fr.Contract.ForeignKey, id); err != nil {
return lifecycleFailure(cc, err)
@@ -662,6 +687,33 @@ func assignBelongsTo(cc *CompiledController, model any, values []relationValue)
return nil
}
// currentKey is the value of model's foreign key column; a nil pointer and
// zero both read as 0 (no related record).
func currentKey(model any, column string) uint {
v := reflect.ValueOf(model)
for v.Kind() == reflect.Pointer {
if v.IsNil() {
return 0
}
v = v.Elem()
}
field := fieldByColumn(v, column)
for field.IsValid() && field.Kind() == reflect.Pointer {
if field.IsNil() {
return 0
}
field = field.Elem()
}
if !field.IsValid() || !field.CanInterface() {
return 0
}
n, err := asUint(field.Interface())
if err != nil {
return 0
}
return n
}
// syncBelongsToMany replaces the parent's pivot rows in submitted order: an
// explicit delete, then one bulk insert (Phase 5 join-table contract).
func syncBelongsToMany(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any, values []relationValue) error {