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:
@@ -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.
|
||||
|
||||
@@ -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