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:
@@ -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`).
|
||||
|
||||
222
modules/cabana/clear_on_change_field_test.go
Normal file
222
modules/cabana/clear_on_change_field_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
119
modules/cabana/clear_on_change_test.go
Normal file
119
modules/cabana/clear_on_change_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user