feat(12.2-03): add hasMany relation contracts, relation forms and child create

- RelationContract gains Kind (empty is belongsToMany) and ForeignKey, with kind-aware boot checks
- manage.form, view.form and pivot.form compile against the related or pivot model; $/ paths resolve inside the plugin
- view toolbarButtons accept create|update|delete|link|unlink, each the capability of its routes
- POST .../relations/{name}/records creates a child through the manage form; the server sets the hasMany key
- relation schema carries kind, deferrable and the localized forms; 17 new relation message keys in en and pl
This commit is contained in:
Jakub Zych
2026-10-02 18:37:13 +02:00
parent 0b4ef9c311
commit 48a5b8045a
23 changed files with 1621 additions and 100 deletions

View File

@@ -615,6 +615,10 @@
"default": { "default": {
"$ref": "#/components/schemas/cabana.jsonScalar" "$ref": "#/components/schemas/cabana.jsonScalar"
}, },
"deferrable": {
"description": "Deferrable is true on a relation-manager field whose relation can be\nmanaged before the record is first saved (RelationSchema.Deferrable):\nthe SPA shows it on the create screen.",
"type": "boolean"
},
"displayFormat": { "displayFormat": {
"type": "string" "type": "string"
}, },
@@ -1380,6 +1384,33 @@
"candidateSearch": { "candidateSearch": {
"$ref": "#/components/schemas/cabana.MessageForms" "$ref": "#/components/schemas/cabana.MessageForms"
}, },
"create": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"createSubmit": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"createTitle": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"created": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"deleteConfirm": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"deleteOneConfirm": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"deleteSelected": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"deleted": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"editPivot": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"empty": { "empty": {
"$ref": "#/components/schemas/cabana.MessageForms" "$ref": "#/components/schemas/cabana.MessageForms"
}, },
@@ -1389,9 +1420,24 @@
"linkHint": { "linkHint": {
"$ref": "#/components/schemas/cabana.MessageForms" "$ref": "#/components/schemas/cabana.MessageForms"
}, },
"linkSubmit": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"linked": { "linked": {
"$ref": "#/components/schemas/cabana.MessageForms" "$ref": "#/components/schemas/cabana.MessageForms"
}, },
"pivotSaved": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"pivotSubmit": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"pivotTitle": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"previewTitle": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"unlinkConfirm": { "unlinkConfirm": {
"$ref": "#/components/schemas/cabana.MessageForms" "$ref": "#/components/schemas/cabana.MessageForms"
}, },
@@ -1400,17 +1446,43 @@
}, },
"unlinked": { "unlinked": {
"$ref": "#/components/schemas/cabana.MessageForms" "$ref": "#/components/schemas/cabana.MessageForms"
},
"updateSubmit": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"updateTitle": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"updated": {
"$ref": "#/components/schemas/cabana.MessageForms"
} }
}, },
"required": [ "required": [
"candidateSearch", "candidateSearch",
"create",
"createSubmit",
"createTitle",
"created",
"deleteConfirm",
"deleteOneConfirm",
"deleteSelected",
"deleted",
"editPivot",
"empty", "empty",
"link", "link",
"linkHint", "linkHint",
"linkSubmit",
"linked", "linked",
"pivotSaved",
"pivotSubmit",
"pivotTitle",
"previewTitle",
"unlinkConfirm", "unlinkConfirm",
"unlinkSelected", "unlinkSelected",
"unlinked" "unlinked",
"updateSubmit",
"updateTitle",
"updated"
], ],
"type": "object" "type": "object"
}, },
@@ -1464,12 +1536,27 @@
}, },
"cabana.RelationSchema": { "cabana.RelationSchema": {
"properties": { "properties": {
"deferrable": {
"description": "Deferrable is true when the relation can be managed on a record that\nis not saved yet: always for belongsToMany, and for a hasMany whose\nForeignKey is nullable.",
"type": "boolean"
},
"kind": {
"description": "Kind is the contract kind: belongsToMany or hasMany.",
"type": "string"
},
"label": { "label": {
"type": "string" "type": "string"
}, },
"manage": { "manage": {
"$ref": "#/components/schemas/cabana.RelationPanel" "$ref": "#/components/schemas/cabana.RelationPanel"
}, },
"manageForm": {
"description": "ManageForm is the child create and update form (manage.form, or the\ntop-level form); ViewForm is the read-only preview form (view.form,\nor the top-level form); PivotForm edits pivot columns of a\nbelongsToMany link (pivot.form). Each is omitted when not declared.",
"items": {
"$ref": "#/components/schemas/cabana.FormField"
},
"type": "array"
},
"messages": { "messages": {
"allOf": [ "allOf": [
{ {
@@ -1481,11 +1568,25 @@
"name": { "name": {
"type": "string" "type": "string"
}, },
"pivotForm": {
"items": {
"$ref": "#/components/schemas/cabana.FormField"
},
"type": "array"
},
"view": { "view": {
"$ref": "#/components/schemas/cabana.RelationPanel" "$ref": "#/components/schemas/cabana.RelationPanel"
},
"viewForm": {
"items": {
"$ref": "#/components/schemas/cabana.FormField"
},
"type": "array"
} }
}, },
"required": [ "required": [
"deferrable",
"kind",
"label", "label",
"manage", "manage",
"messages", "messages",
@@ -5104,6 +5205,140 @@
] ]
} }
}, },
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records": {
"post": {
"description": "Creates a record through the relation's manage form (manage.form, or the top-level form of config_relation.yaml) and attaches it to the owner: a hasMany child gets the owner's key in its foreign key (the server sets it; the body cannot), a belongsToMany record gets a pivot row. The view panel must declare the create toolbar button, otherwise 403. The owner is scoped like the record show route.",
"parameters": [
{
"description": "Vendor",
"in": "path",
"name": "vendor",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Plugin",
"in": "path",
"name": "plugin",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Controller",
"in": "path",
"name": "controller",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Owner id",
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "integer"
}
},
{
"description": "Relation name",
"in": "path",
"name": "name",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.AdminRecord"
}
}
},
"description": "Field values of the manage form keyed by field name",
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.RecordEnvelope"
}
}
},
"description": "Created"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Forbidden"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Not Found"
},
"413": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Request Entity Too Large"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unprocessable Entity"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "Create a related record",
"tags": [
"admin"
]
}
},
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink": { "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink": {
"post": { "post": {
"parameters": [ "parameters": [

View File

@@ -2628,6 +2628,106 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Create a related record
* @description Creates a record through the relation's manage form (manage.form, or the top-level form of config_relation.yaml) and attaches it to the owner: a hasMany child gets the owner's key in its foreign key (the server sets it; the body cannot), a belongsToMany record gets a pivot row. The view panel must declare the create toolbar button, otherwise 403. The owner is scoped like the record show route.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id */
id: number;
/** @description Relation name */
name: string;
};
cookie?: never;
};
/** @description Field values of the manage form keyed by field name */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminRecord"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.RecordEnvelope"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Request Entity Too Large */
413: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink": { "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2893,6 +2993,12 @@ export interface components {
comment?: string; comment?: string;
context?: components["schemas"]["cabana.fieldContext"]; context?: components["schemas"]["cabana.fieldContext"];
default?: components["schemas"]["cabana.jsonScalar"]; default?: components["schemas"]["cabana.jsonScalar"];
/**
* @description Deferrable is true on a relation-manager field whose relation can be
* managed before the record is first saved (RelationSchema.Deferrable):
* the SPA shows it on the create screen.
*/
deferrable?: boolean;
displayFormat?: string; displayFormat?: string;
emptyOption?: string; emptyOption?: string;
/** @description FileTypes are the allowed lower-case extensions of a fileupload field. */ /** @description FileTypes are the allowed lower-case extensions of a fileupload field. */
@@ -3143,13 +3249,30 @@ export interface components {
}; };
"cabana.RelationMessages": { "cabana.RelationMessages": {
candidateSearch: components["schemas"]["cabana.MessageForms"]; candidateSearch: components["schemas"]["cabana.MessageForms"];
create: components["schemas"]["cabana.MessageForms"];
createSubmit: components["schemas"]["cabana.MessageForms"];
createTitle: components["schemas"]["cabana.MessageForms"];
created: components["schemas"]["cabana.MessageForms"];
deleteConfirm: components["schemas"]["cabana.MessageForms"];
deleteOneConfirm: components["schemas"]["cabana.MessageForms"];
deleteSelected: components["schemas"]["cabana.MessageForms"];
deleted: components["schemas"]["cabana.MessageForms"];
editPivot: components["schemas"]["cabana.MessageForms"];
empty: components["schemas"]["cabana.MessageForms"]; empty: components["schemas"]["cabana.MessageForms"];
link: components["schemas"]["cabana.MessageForms"]; link: components["schemas"]["cabana.MessageForms"];
linkHint: components["schemas"]["cabana.MessageForms"]; linkHint: components["schemas"]["cabana.MessageForms"];
linkSubmit: components["schemas"]["cabana.MessageForms"];
linked: components["schemas"]["cabana.MessageForms"]; linked: components["schemas"]["cabana.MessageForms"];
pivotSaved: components["schemas"]["cabana.MessageForms"];
pivotSubmit: components["schemas"]["cabana.MessageForms"];
pivotTitle: components["schemas"]["cabana.MessageForms"];
previewTitle: components["schemas"]["cabana.MessageForms"];
unlinkConfirm: components["schemas"]["cabana.MessageForms"]; unlinkConfirm: components["schemas"]["cabana.MessageForms"];
unlinkSelected: components["schemas"]["cabana.MessageForms"]; unlinkSelected: components["schemas"]["cabana.MessageForms"];
unlinked: components["schemas"]["cabana.MessageForms"]; unlinked: components["schemas"]["cabana.MessageForms"];
updateSubmit: components["schemas"]["cabana.MessageForms"];
updateTitle: components["schemas"]["cabana.MessageForms"];
updated: components["schemas"]["cabana.MessageForms"];
}; };
"cabana.RelationMutationResult": { "cabana.RelationMutationResult": {
linked?: number; linked?: number;
@@ -3165,15 +3288,32 @@ export interface components {
toolbarButtons: string[]; toolbarButtons: string[];
}; };
"cabana.RelationSchema": { "cabana.RelationSchema": {
/**
* @description Deferrable is true when the relation can be managed on a record that
* is not saved yet: always for belongsToMany, and for a hasMany whose
* ForeignKey is nullable.
*/
deferrable: boolean;
/** @description Kind is the contract kind: belongsToMany or hasMany. */
kind: string;
label: string; label: string;
manage: components["schemas"]["cabana.RelationPanel"]; manage: components["schemas"]["cabana.RelationPanel"];
/**
* @description ManageForm is the child create and update form (manage.form, or the
* top-level form); ViewForm is the read-only preview form (view.form,
* or the top-level form); PivotForm edits pivot columns of a
* belongsToMany link (pivot.form). Each is omitted when not declared.
*/
manageForm?: components["schemas"]["cabana.FormField"][];
/** /**
* @description Messages is the relation manager's copy (D-13); the cached schema * @description Messages is the relation manager's copy (D-13); the cached schema
* carries each phrase key as its own form. * carries each phrase key as its own form.
*/ */
messages: components["schemas"]["cabana.RelationMessages"]; messages: components["schemas"]["cabana.RelationMessages"];
name: string; name: string;
pivotForm?: components["schemas"]["cabana.FormField"][];
view: components["schemas"]["cabana.RelationPanel"]; view: components["schemas"]["cabana.RelationPanel"];
viewForm?: components["schemas"]["cabana.FormField"][];
}; };
"cabana.RowAction": { "cabana.RowAction": {
label?: string; label?: string;

View File

@@ -2,6 +2,8 @@
"data": { "data": {
"name": "members", "name": "members",
"label": "Members", "label": "Members",
"kind": "belongsToMany",
"deferrable": true,
"view": { "view": {
"list": { "list": {
"columns": [ "columns": [
@@ -72,6 +74,58 @@
}, },
"empty": { "empty": {
"other": "This widget has no members." "other": "This widget has no members."
},
"create": {
"other": "New record"
},
"createTitle": {
"other": "New record"
},
"updateTitle": {
"other": "Edit record"
},
"previewTitle": {
"other": "Record preview"
},
"created": {
"other": "Record created"
},
"updated": {
"other": "Record saved"
},
"deleteSelected": {
"other": "Delete selected"
},
"deleteConfirm": {
"other": "Delete the selected (:count)? This cannot be undone."
},
"deleteOneConfirm": {
"other": "Delete this record? This cannot be undone."
},
"deleted": {
"one": "Deleted :count record",
"other": "Deleted :count records"
},
"pivotTitle": {
"other": "Link details"
},
"pivotSaved": {
"other": "Link details saved"
},
"editPivot": {
"other": "Edit link details: :name"
},
"createSubmit": {
"other": "Create record"
},
"updateSubmit": {
"other": "Save record"
},
"pivotSubmit": {
"other": "Save link details"
},
"linkSubmit": {
"other": "Add link"
} }
} }
}, },

View File

@@ -1,6 +1,6 @@
--- ---
title: Relation manager title: Relation manager
description: Edit belongsTo and belongsToMany relations in admin forms and manage linked records with config_relation.yaml, bound to models the controller names. description: Edit belongsTo and belongsToMany fields, and manage linked and hasMany records with config_relation.yaml, bound to models the controller names.
section: backend section: backend
order: 40 order: 40
--- ---
@@ -49,7 +49,79 @@ editors:
The controller implements `cabana.AdminRelationContractProvider` and returns a `cabana.RelationContract` per relation: the related and pivot model factories, the pivot's two foreign keys, a map from column names in the YAML to physical columns, and optionally the pivot columns a hook may set and a function that excludes candidate IDs, such as the parent itself. A relation in the YAML without a contract, a contract without a relation, or a relation without a `relation-manager` field stops the start-up. The controller implements `cabana.AdminRelationContractProvider` and returns a `cabana.RelationContract` per relation: the related and pivot model factories, the pivot's two foreign keys, a map from column names in the YAML to physical columns, and optionally the pivot columns a hook may set and a function that excludes candidate IDs, such as the parent itself. A relation in the YAML without a contract, a contract without a relation, or a relation without a `relation-manager` field stops the start-up.
`cabana.RelationService` serves the panels: linked records, link candidates, link and unlink, under `.../{id}/relations/{name}`. Link and unlink run in a transaction. The `view` panel's `toolbarButtons` decide which of the two the server accepts: a relation that does not list `unlink` answers 403 `forbidden` on the unlink route, and likewise for `link`. `pact.RelationExtendManageQuery` scopes the candidates, and `pact.RelationBeforeLink` can check or fill pivot columns before a link is written. `cabana.RelationService` serves the panels: linked records, link candidates, link and unlink, under `.../{id}/relations/{name}`. Link and unlink run in a transaction. `pact.RelationExtendManageQuery` scopes the candidates, and `pact.RelationBeforeLink` can check or fill pivot columns before a link is written.
### Relation kinds
`RelationContract.Kind` says how the related records hang off the parent:
- `belongsToMany` (`cabana.RelationBelongsToMany`) links records through a pivot model: `NewPivot`, `ParentForeignKey`, `RelatedForeignKey` and optionally `HookPivotColumns`. A contract that leaves `Kind` empty is a belongsToMany, so contracts written before hasMany existed keep working unchanged.
- `hasMany` (`cabana.RelationHasMany`) owns records through a column on the related model: `ForeignKey` names it. A hasMany contract declares no pivot fields; a pivot field, a `ForeignKey` that is not an integer column of the related model, or an unknown kind stops the start-up.
```go src=modules/cabana/example_relation_test.go#PostsController.AdminRelationContracts
// AdminRelationContracts binds the posts form's relation managers to their
// models; the framework never guesses a table or column.
func (PostsController) AdminRelationContracts() []cabana.RelationContract {
return []cabana.RelationContract{{
Name: "comments",
Kind: cabana.RelationHasMany,
NewRelated: func() any { return &Comment{} },
ForeignKey: "post_id",
Columns: map[string]string{"author": "author", "body": "body"},
}}
}
```
A hasMany `ForeignKey` of a pointer type (`*uint`, a nullable column) is needed for `unlink` and for managing the relation before the parent is saved.
### Relation forms
The relation manager creates and edits related records in a modal through a form of their own. `manage.form` names the form used to create and update a child, `view.form` the read-only preview; a top-level `form` is the fallback of both, as in WinterCMS:
```yaml
comments:
label: acme.blog::lang.posts.comments
form: $/acme/blog/models/comment/fields.yaml
view:
list:
columns:
author:
label: acme.blog::lang.comments.author
toolbarButtons: create|update|delete
manage:
list:
columns:
author:
label: acme.blog::lang.comments.author
```
A form path is plugin-relative, `~/plugins/<vendor>/<plugin>/...`, or WinterCMS's `$/<vendor>/<plugin>/...`. A `$/` path resolves inside the same plugin; a path into another plugin stops the start-up, because the plugin's embedded tree cannot read it. The form is compiled at boot like a controller form, against the related model: every field must be a column of that model, and the related model must implement `lagoon.HasFillable` and `Rules` when `create` or `update` is declared.
A relation form accepts the scalar field types (`text`, `textarea`, `number`, `checkbox`, `switch`, `dropdown`) plus `datepicker` and `fileupload`. `relation`, `relation-manager`, `widget` and `partial` stop the start-up, and so does a field named like a hasMany `ForeignKey`: the server sets that column from the parent.
### Toolbar buttons
The `view` panel's `toolbarButtons` take WinterCMS's buttons, and each one is the capability of its routes: a route whose button the relation does not declare answers 403 `forbidden`, whatever the controller permission.
| Button | Allows |
|--------|--------|
| `create` | Creating a related record through the manage form. Needs a manage form. |
| `update` | Editing a related record. Needs a manage form. |
| `delete` | Deleting related records. |
| `link` | Linking existing records. |
| `unlink` | Unlinking records. On a hasMany it needs a nullable `ForeignKey`. |
The `manage` panel may declare only `link`. An unknown or duplicate button stops the start-up. One difference from WinterCMS: there, clicking a row opens the update form whatever the toolbar lists; here a row is editable only when `update` is listed.
### Creating related records
`POST .../{id}/relations/{name}/records` creates a related record from the manage form's fields and attaches it to the parent, in one transaction. The parent is loaded through `pact.FormExtendQuery`, so a parent the administrator cannot open answers 404. The child is filled and validated like a controller save (the related model's `Fill`, its rules merged with the form's `required` flags, a 422 `validation_failed` envelope on failure) and its GORM hooks run. On a hasMany the server sets the `ForeignKey` to the parent's key; a body that names that column cannot change it. On a belongsToMany the record is inserted and its pivot row written, with `pact.RelationBeforeLink` stamping the hook columns.
A controller can hook into the child writes with the optional `pact.RelationBeforeCreate` and `pact.RelationAfterCreate` (and the update and delete pairs), each called with the relation name, the parent and the child. A hook error rolls the whole write back and answers the generic lifecycle error.
### Messages
A relation's `messages` block overrides the relation manager's copy, each key a phrase key; an omitted key falls back to `backend::lang.messages.relation.<key in snake_case>`. The keys are `link`, `linkHint`, `candidateSearch`, `linked`, `unlinkSelected`, `unlinkConfirm`, `unlinked` and `empty` for the link panels, and `create`, `createTitle`, `updateTitle`, `previewTitle`, `created`, `updated`, `deleteSelected`, `deleteConfirm`, `deleteOneConfirm`, `deleted`, `pivotTitle`, `pivotSaved`, `editPivot`, `createSubmit`, `updateSubmit`, `pivotSubmit` and `linkSubmit` for the child and pivot modals. A key that names a missing phrase stops the start-up.
## Relations in lists ## Relations in lists

View File

@@ -13,7 +13,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- 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. - 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. - 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. 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. - 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. 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 and for linking and unlinking. Framework code never guesses table, pivot or foreign-key names: the controller supplies them. - 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); 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.
- 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. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot. - 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. 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. - 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. - 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.
@@ -48,6 +48,7 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
| GET `.../fields/{field}/options`, GET `.../filters/{scope}/options` | Choices for a relation field and for a model-backed list filter. | | GET `.../fields/{field}/options`, GET `.../filters/{scope}/options` | Choices for a relation field and for a model-backed list filter. |
| GET `.../{id}/relations/{name}`, GET `.../{id}/relations/{name}/candidates` | Linked records and link candidates of a relation manager. | | 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. | | POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. |
| 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 `.../{id}/files/{field}` | The files of a `type: fileupload` field: attached files minus the session's pending removals, plus its pending uploads, in `sort_order`. `{id}` 0 is the record being created and needs `X-Session-Key`. | | GET `.../{id}/files/{field}` | The files of a `type: fileupload` field: attached files minus the session's pending removals, plus its pending uploads, in `sort_order`. `{id}` 0 is the record being created and needs `X-Session-Key`. |
| POST `.../{id}/files/{field}` | Upload one multipart `file_data` part; it is bound to the `X-Session-Key` session (required) and attached by the record's next save. Answers 201 with the pending `cabana.FileItem`. | | POST `.../{id}/files/{field}` | Upload one multipart `file_data` part; it is bound to the `X-Session-Key` session (required) and attached by the record's next save. Answers 201 with the pending `cabana.FileItem`. |
| PUT `.../{id}/files/{field}/{file}` | Save a file's `title` and `description` at once; the field must declare `useCaption` (403 otherwise). | | PUT `.../{id}/files/{field}/{file}` | Save a file's `title` and `description` at once; the field must declare `useCaption` (403 otherwise). |
@@ -163,6 +164,8 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.SettingsService` | Reads and transactionally updates singleton settings rows. | | `cabana.SettingsService` | Reads and transactionally updates singleton settings rows. |
| `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. | | `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. |
| `cabana.AdminRelationContractProvider` / `cabana.RelationContract` | Controller-supplied bindings for relation managers. | | `cabana.AdminRelationContractProvider` / `cabana.RelationContract` | Controller-supplied bindings for relation managers. |
| `cabana.RelationBelongsToMany` / `cabana.RelationHasMany` | The two `RelationContract.Kind` values; an empty `Kind` is a belongsToMany. |
| `cabana.AdminRelationChildCreate` | Swag annotation of the relation child create route. |
| `cabana.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. | | `cabana.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. |
| `cabana.Allows` | Checks a principal against required permission codes. | | `cabana.Allows` | Checks a principal against required permission codes. |
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. | | `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
@@ -196,7 +199,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `backend.cookie_secure` | `true` | Set `false` to drop the cookie's Secure attribute for plain-HTTP development. Refused in the `production` environment. | | `backend.cookie_secure` | `true` | Set `false` to drop the cookie's Secure attribute for plain-HTTP development. Refused in the `production` environment. |
| `app.url` | empty | Base URL used for the token issuer. | | `app.url` | empty | Base URL used for the token issuer. |
| `http.body_limits.upload_bytes` | none | Read from the HTTP configuration: caps the body of a file upload (together with the field's `maxFilesize` plus 64 KiB), and no fileupload field may declare a larger `maxFilesize`. Without it the cap is the field's limit, or 128 MiB. | | `http.body_limits.upload_bytes` | none | Read from the HTTP configuration: caps the body of a file upload (together with the field's `maxFilesize` plus 64 KiB), and no fileupload field may declare a larger `maxFilesize`. Without it the cap is the field's limit, or 128 MiB. |
| `http.body_limits.default_bytes` | none | Read from the HTTP configuration: caps the JSON bodies of the file caption and reorder routes (1 MiB when not set). | | `http.body_limits.default_bytes` | none | Read from the HTTP configuration: caps the JSON bodies of the file caption and reorder routes and of the relation child routes (1 MiB when not set; 413 `payload_too_large` past it). |
The backend user, role and token blacklist tables (`backend_users`, `backend_user_roles`, `backend_jwt_blacklist`) are created by `lagoon.BackendAdminMigrations`, which the `migrate` command runs. The backend user, role and token blacklist tables (`backend_users`, `backend_user_roles`, `backend_jwt_blacklist`) are created by `lagoon.BackendAdminMigrations`, which the `migrate` command runs.

View File

@@ -612,6 +612,29 @@ func AdminRelationLink() {}
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink [post] // @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink [post]
func AdminRelationUnlink() {} func AdminRelationUnlink() {}
// AdminRelationChildCreate documents the relation child create route.
//
// @Summary Create a related record
// @Description Creates a record through the relation's manage form (manage.form, or the top-level form of config_relation.yaml) and attaches it to the owner: a hasMany child gets the owner's key in its foreign key (the server sets it; the body cannot), a belongsToMany record gets a pivot row. The view panel must declare the create toolbar button, otherwise 403. The owner is scoped like the record show route.
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param name path string true "Relation name"
// @Param body body AdminRecord true "Field values of the manage form keyed by field name"
// @Success 201 {object} RecordEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 413 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records [post]
func AdminRelationChildCreate() {}
// FileMutationResult is the payload of a file removal: the number of files // FileMutationResult is the payload of a file removal: the number of files
// removed (always 1 on success). // removed (always 1 on success).
type FileMutationResult struct { type FileMutationResult struct {

View File

@@ -31,6 +31,9 @@ func datepickerFields(extra string) string {
type: relation-manager type: relation-manager
relation: members relation: members
context: [update] context: [update]
parts:
type: relation-manager
relation: parts
lookup: lookup:
label: Lookup label: Lookup
type: widget type: widget

View File

@@ -0,0 +1,26 @@
package cabana_test
import "git.golem15.com/golem15/summercms/modules/cabana"
// Comment is one comment of a post: a hasMany child that points at its post
// through the nullable post_id column.
type Comment struct {
ID uint `gorm:"column:id;primaryKey"`
PostID *uint `gorm:"column:post_id"`
Author string `gorm:"column:author"`
Body string `gorm:"column:body"`
}
func (Comment) TableName() string { return "acme_blog_comments" }
// AdminRelationContracts binds the posts form's relation managers to their
// models; the framework never guesses a table or column.
func (PostsController) AdminRelationContracts() []cabana.RelationContract {
return []cabana.RelationContract{{
Name: "comments",
Kind: cabana.RelationHasMany,
NewRelated: func() any { return &Comment{} },
ForeignKey: "post_id",
Columns: map[string]string{"author": "author", "body": "body"},
}}
}

View File

@@ -304,34 +304,47 @@ func decodeFields(raw []byte) ([]FormField, error) {
} }
func (m *fieldMap) UnmarshalYAML(node ast.Node) error { func (m *fieldMap) UnmarshalYAML(node ast.Node) error {
items, err := decodeFieldMapping(node, nil)
if err != nil {
return err
}
m.items = items
return nil
}
// decodeFieldMapping compiles a fields mapping in source order. rename, when
// set, maps a raw field name (such as WinterCMS's pivot[role]) to the name
// that must be an identifier.
func decodeFieldMapping(node ast.Node, rename func(string) string) ([]FormField, error) {
node = unwrapNode(node) node = unwrapNode(node)
if _, ok := node.(*ast.NullNode); ok || node == nil { if _, ok := node.(*ast.NullNode); ok || node == nil {
m.items = []FormField{} return []FormField{}, nil
return nil
} }
mapping, ok := node.(*ast.MappingNode) mapping, ok := node.(*ast.MappingNode)
if !ok { if !ok {
return fmt.Errorf("fields must be a mapping") return nil, fmt.Errorf("fields must be a mapping")
} }
items := make([]FormField, 0, len(mapping.Values)) items := make([]FormField, 0, len(mapping.Values))
seen := map[string]struct{}{} seen := map[string]struct{}{}
for _, entry := range mapping.Values { for _, entry := range mapping.Values {
name, err := nodeString(unwrapNode(entry.Key)) name, err := nodeString(unwrapNode(entry.Key))
if err == nil && rename != nil {
name = rename(name)
}
if err != nil || !identifier(name) { if err != nil || !identifier(name) {
return fmt.Errorf("field name %q is not an identifier", nodeText(entry.Key)) return nil, fmt.Errorf("field name %q is not an identifier", nodeText(entry.Key))
} }
if _, dup := seen[name]; dup { if _, dup := seen[name]; dup {
return fmt.Errorf("duplicate field %s", name) return nil, fmt.Errorf("duplicate field %s", name)
} }
seen[name] = struct{}{} seen[name] = struct{}{}
field, err := compileFieldNode(name, unwrapNode(entry.Value)) field, err := compileFieldNode(name, unwrapNode(entry.Value))
if err != nil { if err != nil {
return fmt.Errorf("field %s: %w", name, err) return nil, fmt.Errorf("field %s: %w", name, err)
} }
items = append(items, field) items = append(items, field)
} }
m.items = items return items, nil
return nil
} }
func compileFieldNode(name string, node ast.Node) (FormField, error) { func compileFieldNode(name string, node ast.Node) (FormField, error) {

View File

@@ -275,6 +275,10 @@ func (s *service) mount(r pact.Router) {
constrainRelation(g) constrainRelation(g)
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", requireAjax(s.relationUnlink)) g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", requireAjax(s.relationUnlink))
constrainRelation(g) constrainRelation(g)
// Relation child routes (D-11, D-12): create through the relation's
// manage form.
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records", requireAjax(s.relationChildCreate))
constrainRelation(g)
// File routes of `type: fileupload` fields (D-09). {id} 0 is the // File routes of `type: fileupload` fields (D-09). {id} 0 is the
// record being created in the X-Session-Key session. // record being created in the X-Session-Key session.
g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload)) g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload))
@@ -577,7 +581,7 @@ func (s *service) relations() (RelationService, error) {
if err != nil { if err != nil {
return RelationService{}, err return RelationService{}, err
} }
return RelationService{DB: db}, nil return RelationService{DB: db, bucket: s.bucket(), tr: s.translator()}, nil
} }
func (s *service) formSchema(w http.ResponseWriter, r *http.Request) { func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {

View File

@@ -81,6 +81,24 @@ type relationMessageKeys struct {
UnlinkConfirm string `yaml:"unlinkConfirm"` UnlinkConfirm string `yaml:"unlinkConfirm"`
Unlinked string `yaml:"unlinked"` Unlinked string `yaml:"unlinked"`
Empty string `yaml:"empty"` Empty string `yaml:"empty"`
// The child create, update and delete copy and the pivot copy (12.2).
Create string `yaml:"create"`
CreateTitle string `yaml:"createTitle"`
UpdateTitle string `yaml:"updateTitle"`
PreviewTitle string `yaml:"previewTitle"`
Created string `yaml:"created"`
Updated string `yaml:"updated"`
DeleteSelected string `yaml:"deleteSelected"`
DeleteConfirm string `yaml:"deleteConfirm"`
DeleteOneConfirm string `yaml:"deleteOneConfirm"`
Deleted string `yaml:"deleted"`
PivotTitle string `yaml:"pivotTitle"`
PivotSaved string `yaml:"pivotSaved"`
EditPivot string `yaml:"editPivot"`
CreateSubmit string `yaml:"createSubmit"`
UpdateSubmit string `yaml:"updateSubmit"`
PivotSubmit string `yaml:"pivotSubmit"`
LinkSubmit string `yaml:"linkSubmit"`
} }
// RelationMessages is a relation manager's copy, every key resolved. // RelationMessages is a relation manager's copy, every key resolved.
@@ -93,6 +111,23 @@ type RelationMessages struct {
UnlinkConfirm MessageForms `json:"unlinkConfirm"` UnlinkConfirm MessageForms `json:"unlinkConfirm"`
Unlinked MessageForms `json:"unlinked"` Unlinked MessageForms `json:"unlinked"`
Empty MessageForms `json:"empty"` Empty MessageForms `json:"empty"`
Create MessageForms `json:"create"`
CreateTitle MessageForms `json:"createTitle"`
UpdateTitle MessageForms `json:"updateTitle"`
PreviewTitle MessageForms `json:"previewTitle"`
Created MessageForms `json:"created"`
Updated MessageForms `json:"updated"`
DeleteSelected MessageForms `json:"deleteSelected"`
DeleteConfirm MessageForms `json:"deleteConfirm"`
DeleteOneConfirm MessageForms `json:"deleteOneConfirm"`
Deleted MessageForms `json:"deleted"`
PivotTitle MessageForms `json:"pivotTitle"`
PivotSaved MessageForms `json:"pivotSaved"`
EditPivot MessageForms `json:"editPivot"`
CreateSubmit MessageForms `json:"createSubmit"`
UpdateSubmit MessageForms `json:"updateSubmit"`
PivotSubmit MessageForms `json:"pivotSubmit"`
LinkSubmit MessageForms `json:"linkSubmit"`
} }
// Framework defaults (D-13): every omitted key falls back to backend::lang. // Framework defaults (D-13): every omitted key falls back to backend::lang.
@@ -125,6 +160,23 @@ var (
UnlinkConfirm: "backend::lang.messages.relation.unlink_confirm", UnlinkConfirm: "backend::lang.messages.relation.unlink_confirm",
Unlinked: "backend::lang.messages.relation.unlinked", Unlinked: "backend::lang.messages.relation.unlinked",
Empty: "backend::lang.messages.relation.empty", Empty: "backend::lang.messages.relation.empty",
Create: "backend::lang.messages.relation.create",
CreateTitle: "backend::lang.messages.relation.create_title",
UpdateTitle: "backend::lang.messages.relation.update_title",
PreviewTitle: "backend::lang.messages.relation.preview_title",
Created: "backend::lang.messages.relation.created",
Updated: "backend::lang.messages.relation.updated",
DeleteSelected: "backend::lang.messages.relation.delete_selected",
DeleteConfirm: "backend::lang.messages.relation.delete_confirm",
DeleteOneConfirm: "backend::lang.messages.relation.delete_one_confirm",
Deleted: "backend::lang.messages.relation.deleted",
PivotTitle: "backend::lang.messages.relation.pivot_title",
PivotSaved: "backend::lang.messages.relation.pivot_saved",
EditPivot: "backend::lang.messages.relation.edit_pivot",
CreateSubmit: "backend::lang.messages.relation.create_submit",
UpdateSubmit: "backend::lang.messages.relation.update_submit",
PivotSubmit: "backend::lang.messages.relation.pivot_submit",
LinkSubmit: "backend::lang.messages.relation.link_submit",
} }
) )

View File

@@ -222,6 +222,9 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", 200, "cabana.Envelope-cabana_RelationMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { {"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", 200, "cabana.Envelope-cabana_RelationMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/unlink", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true) return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/unlink", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true)
}, into[cabana.Envelope[cabana.RelationMutationResult]](), nil}, }, into[cabana.Envelope[cabana.RelationMutationResult]](), nil},
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records", 201, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records", e.gadgetID), map[string]any{"label": "part-" + e.stamp}, true)
}, into[cabana.RecordEnvelope](), nil},
{"POST /{vendor}/{plugin}/{controller}/toolbar/{action}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { {"POST /{vendor}/{plugin}/{controller}/toolbar/{action}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/acme/conform/gadgets/toolbar/recount", map[string]any{}, true) return e.send(t, http.MethodPost, "/acme/conform/gadgets/toolbar/recount", map[string]any{}, true)
}, into[cabana.Envelope[cabana.AdminActionResult]](), nil}, }, into[cabana.Envelope[cabana.AdminActionResult]](), nil},
@@ -474,7 +477,7 @@ func dataID(t *testing.T, raw []byte) uint {
func newConformEnv(t *testing.T) *conformEnv { func newConformEnv(t *testing.T) *conformEnv {
t.Helper() t.Helper()
gdb := adminGorm(t) gdb := adminGorm(t)
models := []any{&conformGadget{}, &conformGroup{}, &conformMember{}, &conformGadgetMember{}, &conformSettings{}} models := []any{&conformGadget{}, &conformGroup{}, &conformMember{}, &conformGadgetMember{}, &conformPart{}, &conformSettings{}}
if err := gdb.Migrator().DropTable(models...); err != nil { if err := gdb.Migrator().DropTable(models...); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -543,6 +546,7 @@ type conformGadget struct {
ReleasedOn lagoon.Date `gorm:"column:released_on;type:date"` ReleasedOn lagoon.Date `gorm:"column:released_on;type:date"`
StartsAt *time.Time `gorm:"column:starts_at"` StartsAt *time.Time `gorm:"column:starts_at"`
Members []conformMember `gorm:"-"` Members []conformMember `gorm:"-"`
Parts []conformPart `gorm:"-"`
CreatedAt time.Time `gorm:"column:created_at"` CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"` UpdatedAt time.Time `gorm:"column:updated_at"`
} }
@@ -586,6 +590,20 @@ type conformGadgetMember struct {
func (conformGadgetMember) TableName() string { return "cabana_conform_gadget_members" } func (conformGadgetMember) TableName() string { return "cabana_conform_gadget_members" }
// conformPart is the hasMany child of a gadget; its nullable gadget_id makes
// the parts relation deferrable.
type conformPart struct {
ID uint `gorm:"column:id;primaryKey"`
GadgetID *uint `gorm:"column:gadget_id"`
Label string `gorm:"column:label"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (conformPart) TableName() string { return "cabana_conform_parts" }
func (conformPart) Fillable() []string { return []string{"label"} }
func (conformPart) Rules() map[string]string { return map[string]string{"label": "required"} }
type conformSettings struct { type conformSettings struct {
ID uint `gorm:"column:id;primaryKey"` ID uint `gorm:"column:id;primaryKey"`
Enabled bool `gorm:"column:enabled"` Enabled bool `gorm:"column:enabled"`
@@ -630,6 +648,9 @@ func (conformController) AdminRelationContracts() []cabana.RelationContract {
return []cabana.RelationContract{{ return []cabana.RelationContract{{
Name: "members", NewRelated: func() any { return &conformMember{} }, NewPivot: func() any { return &conformGadgetMember{} }, Name: "members", NewRelated: func() any { return &conformMember{} }, NewPivot: func() any { return &conformGadgetMember{} },
ParentForeignKey: "gadget_id", RelatedForeignKey: "member_id", Columns: map[string]string{"email": "email"}, ParentForeignKey: "gadget_id", RelatedForeignKey: "member_id", Columns: map[string]string{"email": "email"},
}, {
Name: "parts", Kind: cabana.RelationHasMany, NewRelated: func() any { return &conformPart{} },
ForeignKey: "gadget_id", Columns: map[string]string{"label": "label"},
}} }}
} }
func (conformController) AdminFieldRelations() []cabana.FieldRelationContract { func (conformController) AdminFieldRelations() []cabana.FieldRelationContract {
@@ -748,6 +769,20 @@ update:
email: email:
label: Email label: Email
showSearch: true showSearch: true
parts:
label: Parts
view:
list:
columns:
label:
label: Label
toolbarButtons: create
manage:
form: $/acme/conform/models/part/fields.yaml
list:
columns:
label:
label: Label
`), `),
"models/gadget/columns.yaml": file(`columns: "models/gadget/columns.yaml": file(`columns:
name: name:
@@ -779,6 +814,9 @@ update:
type: relation-manager type: relation-manager
relation: members relation: members
context: [update] context: [update]
parts:
type: relation-manager
relation: parts
lookup: lookup:
label: Lookup label: Lookup
type: widget type: widget
@@ -814,6 +852,12 @@ update:
fileTypes: [pdf, png, svg, txt] fileTypes: [pdf, png, svg, txt]
useCaption: true useCaption: true
context: update context: update
`),
"models/part/fields.yaml": file(`fields:
label:
label: Label
type: text
required: true
`), `),
"models/settings/fields.yaml": file(`fields: "models/settings/fields.yaml": file(`fields:
enabled: enabled:

View File

@@ -100,13 +100,14 @@ func TestPhase10Coverage(t *testing.T) {
"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}", "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}",
"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder", "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder",
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link",
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records",
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink",
"PUT /settings/{code}", "PUT /settings/{code}",
"PUT /{vendor}/{plugin}/{controller}/{id}", "PUT /{vendor}/{plugin}/{controller}/{id}",
"PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", "PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}",
} }
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") { if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 15 besides login):\n%s", strings.Join(unsafe, "\n")) t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 16 besides login):\n%s", strings.Join(unsafe, "\n"))
} }
// The routes added in Phase 10 are safe reads: GET /lang and the shared // The routes added in Phase 10 are safe reads: GET /lang and the shared
// nested pattern serving field options, filter options and relation lists. // nested pattern serving field options, filter options and relation lists.

View File

@@ -68,9 +68,9 @@ func TestPhase10CSRF(t *testing.T) {
} }
// refresh, logout, settings put, create, bulk-delete, widget action, // refresh, logout, settings put, create, bulk-delete, widget action,
// toolbar action, update, delete, link, unlink, file upload, file // toolbar action, update, delete, link, unlink, file upload, file
// reorder, file caption, file remove // reorder, file caption, file remove, relation child create
if unsafe != 15 { if unsafe != 16 {
t.Fatalf("walked %d state-changing routes, want 15: %v", unsafe, router.order) t.Fatalf("walked %d state-changing routes, want 16: %v", unsafe, router.order)
} }
loginHandler := router.handlers[login] loginHandler := router.handlers[login]

View File

@@ -7,12 +7,14 @@ import (
"fmt" "fmt"
"io/fs" "io/fs"
"reflect" "reflect"
"slices"
"strings" "strings"
"git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/phrasebook"
"github.com/goccy/go-yaml" "github.com/goccy/go-yaml"
"gocloud.dev/blob"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
@@ -23,18 +25,46 @@ type AdminRelationContractProvider interface {
AdminRelationContracts() []RelationContract AdminRelationContracts() []RelationContract
} }
// Relation contract kinds (RelationContract.Kind).
const (
// RelationBelongsToMany links related records through a pivot model.
// It is the kind of a contract whose Kind is empty.
RelationBelongsToMany = "belongsToMany"
// RelationHasMany owns related records through their ForeignKey column.
RelationHasMany = "hasMany"
)
// RelationContract binds one compiled relation schema to target and pivot models. // RelationContract binds one compiled relation schema to target and pivot models.
type RelationContract struct { type RelationContract struct {
Name string Name string
// Kind is RelationBelongsToMany or RelationHasMany. The empty value is
// RelationBelongsToMany, so a contract written before hasMany existed
// keeps its pivot behaviour unchanged.
Kind string
NewRelated func() any NewRelated func() any
// NewPivot, ParentForeignKey, RelatedForeignKey and HookPivotColumns
// describe the pivot of a belongsToMany relation; a hasMany contract
// leaves them empty.
NewPivot func() any NewPivot func() any
ParentForeignKey string ParentForeignKey string
RelatedForeignKey string RelatedForeignKey string
// ForeignKey is the related model's column that points at the parent
// (hasMany only). A pointer Go type (a nullable column) is needed for
// unlink and for managing the relation before the parent is saved.
ForeignKey string
Columns map[string]string Columns map[string]string
HookPivotColumns []string HookPivotColumns []string
ExcludedRelatedIDs func(parent any) ([]uint, error) ExcludedRelatedIDs func(parent any) ([]uint, error)
} }
// kind is the contract's normalized kind.
func (c RelationContract) kind() string {
if c.Kind == "" {
return RelationBelongsToMany
}
return c.Kind
}
// RelationColumn is one source-ordered relation list column. // RelationColumn is one source-ordered relation list column.
type RelationColumn struct { type RelationColumn struct {
Key string `json:"key"` Key string `json:"key"`
@@ -59,13 +89,33 @@ type RelationPanel struct {
type RelationSchema struct { type RelationSchema struct {
Name string `json:"name"` Name string `json:"name"`
Label string `json:"label"` Label string `json:"label"`
// Kind is the contract kind: belongsToMany or hasMany.
Kind string `json:"kind"`
// Deferrable is true when the relation can be managed on a record that
// is not saved yet: always for belongsToMany, and for a hasMany whose
// ForeignKey is nullable.
Deferrable bool `json:"deferrable"`
View RelationPanel `json:"view"` View RelationPanel `json:"view"`
Manage RelationPanel `json:"manage"` Manage RelationPanel `json:"manage"`
// ManageForm is the child create and update form (manage.form, or the
// top-level form); ViewForm is the read-only preview form (view.form,
// or the top-level form); PivotForm edits pivot columns of a
// belongsToMany link (pivot.form). Each is omitted when not declared.
ManageForm []FormField `json:"manageForm,omitempty"`
ViewForm []FormField `json:"viewForm,omitempty"`
PivotForm []FormField `json:"pivotForm,omitempty"`
// Messages is the relation manager's copy (D-13); the cached schema // Messages is the relation manager's copy (D-13); the cached schema
// carries each phrase key as its own form. // carries each phrase key as its own form.
Messages *RelationMessages `json:"messages"` Messages *RelationMessages `json:"messages"`
messageKeys relationMessageKeys messageKeys relationMessageKeys
// manageForm, viewForm and pivotForm are the compiled forms the field
// lists come from; relatedModel and pivotModel supply dropdown options.
manageForm *FormSchema
viewForm *FormSchema
pivotForm *FormSchema
relatedModel func() any
pivotModel func() any
} }
// CompiledRelation combines trusted YAML with model-owned metadata. // CompiledRelation combines trusted YAML with model-owned metadata.
@@ -73,6 +123,27 @@ type CompiledRelation struct {
Schema *RelationSchema Schema *RelationSchema
Contract RelationContract Contract RelationContract
RequiredPermissions []string RequiredPermissions []string
// kind is the normalized contract kind; deferrable mirrors
// Schema.Deferrable; fieldName is the relation-manager form field.
kind string
deferrable bool
fieldName string
// child is the related model's form (the manage form) compiled as a
// controller of its own: writable fields, file and date fields. view is
// the read-only form when it differs. pivot is the pivot form bound to
// the pivot model. Each is nil when not declared.
child *CompiledController
view *CompiledController
pivot *CompiledController
}
// hasMany reports whether the relation owns its children by foreign key.
func (cr *CompiledRelation) hasMany() bool { return cr != nil && cr.kind == RelationHasMany }
// allows reports whether the view panel declares the toolbar button.
func (cr *CompiledRelation) allows(button string) bool {
return cr != nil && cr.Schema != nil && slices.Contains(cr.Schema.View.ToolbarButtons, button)
} }
// RelationQuery is the finite linked/candidate query contract. // RelationQuery is the finite linked/candidate query contract.
@@ -102,7 +173,14 @@ type RelationMutationResult struct {
} }
// RelationService executes compiled relation reads and writes. // RelationService executes compiled relation reads and writes.
type RelationService struct{ DB *gorm.DB } type RelationService struct {
DB *gorm.DB
// bucket deletes the blobs of child files a save removes, after commit;
// tr localizes date bound messages. Both may be nil.
bucket *blob.Bucket
tr *phrasebook.Translator
}
func (s RelationSchema) MarshalJSON() ([]byte, error) { func (s RelationSchema) MarshalJSON() ([]byte, error) {
if s.Messages == nil { if s.Messages == nil {
@@ -136,9 +214,30 @@ func (s *RelationSchema) Localize(ctx context.Context, tr *phrasebook.Translator
out.Manage = localizeRelationPanel(ctx, tr, s.Manage) out.Manage = localizeRelationPanel(ctx, tr, s.Manage)
messages := localizeMessages[relationMessageKeys, RelationMessages](ctx, tr, s.relationMessageKeySet()) messages := localizeMessages[relationMessageKeys, RelationMessages](ctx, tr, s.relationMessageKeySet())
out.Messages = &messages out.Messages = &messages
out.ManageForm = localizeRelationForm(ctx, tr, s.manageForm, s.relatedModel, s.ManageForm)
out.ViewForm = localizeRelationForm(ctx, tr, s.viewForm, s.relatedModel, s.ViewForm)
out.PivotForm = localizeRelationForm(ctx, tr, s.pivotForm, s.pivotModel, s.PivotForm)
return &out return &out
} }
// localizeRelationForm resolves a relation form's display strings, with the
// form's model as the dropdown options provider. A form that cannot be
// localized keeps its cached fields.
func localizeRelationForm(ctx context.Context, tr *phrasebook.Translator, form *FormSchema, model func() any, cached []FormField) []FormField {
if form == nil {
return cached
}
var provider pact.DropdownOptionsProvider
if model != nil {
provider = modelDropdownProvider(model())
}
view, err := form.Localize(ctx, tr, provider)
if err != nil {
return cached
}
return view.Fields
}
func localizeRelationPanel(ctx context.Context, tr *phrasebook.Translator, src RelationPanel) RelationPanel { func localizeRelationPanel(ctx context.Context, tr *phrasebook.Translator, src RelationPanel) RelationPanel {
out := src out := src
out.List.Columns = append([]RelationColumn(nil), src.List.Columns...) out.List.Columns = append([]RelationColumn(nil), src.List.Columns...)
@@ -156,8 +255,12 @@ type relationRoot struct {
type relationDocument struct { type relationDocument struct {
Label string `yaml:"label"` Label string `yaml:"label"`
// Form is WinterCMS's top-level form: the fallback of manage.form and
// view.form.
Form string `yaml:"form"`
View relationPanelDocument `yaml:"view"` View relationPanelDocument `yaml:"view"`
Manage relationPanelDocument `yaml:"manage"` Manage relationPanelDocument `yaml:"manage"`
Pivot *relationPivotDocument `yaml:"pivot"`
Messages *relationMessageKeys `yaml:"messages"` Messages *relationMessageKeys `yaml:"messages"`
} }
@@ -165,10 +268,15 @@ type relationPanelDocument struct {
List struct { List struct {
Columns yaml.MapSlice `yaml:"columns"` Columns yaml.MapSlice `yaml:"columns"`
} `yaml:"list"` } `yaml:"list"`
Form string `yaml:"form"`
ToolbarButtons string `yaml:"toolbarButtons"` ToolbarButtons string `yaml:"toolbarButtons"`
ShowSearch bool `yaml:"showSearch"` ShowSearch bool `yaml:"showSearch"`
} }
type relationPivotDocument struct {
Form string `yaml:"form"`
}
type relationColumnDocument struct { type relationColumnDocument struct {
Label string `yaml:"label"` Label string `yaml:"label"`
Searchable *bool `yaml:"searchable"` Searchable *bool `yaml:"searchable"`
@@ -176,11 +284,12 @@ type relationColumnDocument struct {
} }
func compileRelations(pluginID string, ctl pact.AdminController, fsys fs.FS, form *FormSchema) (map[string]*CompiledRelation, error) { func compileRelations(pluginID string, ctl pact.AdminController, fsys fs.FS, form *FormSchema) (map[string]*CompiledRelation, error) {
fields := map[string]FormField{} // fields maps each relation-manager relation to its form field index.
fields := map[string]int{}
if form != nil { if form != nil {
for _, field := range form.Fields { for i, field := range form.Fields {
if field.Type == "relation-manager" { if field.Type == "relation-manager" {
fields[field.Relation] = field fields[field.Relation] = i
} }
} }
} }
@@ -246,17 +355,26 @@ func compileRelations(pluginID string, ctl pact.AdminController, fsys fs.FS, for
if err != nil { if err != nil {
return nil, bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation %s manage: %w", name, err)) return nil, bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation %s manage: %w", name, err))
} }
schema := &RelationSchema{Name: name, Label: doc.Label, View: view, Manage: manage} schema := &RelationSchema{Name: name, Label: doc.Label, Kind: contract.kind(), View: view, Manage: manage}
if doc.Messages != nil { if doc.Messages != nil {
schema.messageKeys = *doc.Messages schema.messageKeys = *doc.Messages
} }
keys := localizeMessages[relationMessageKeys, RelationMessages](context.Background(), nil, schema.relationMessageKeySet()) keys := localizeMessages[relationMessageKeys, RelationMessages](context.Background(), nil, schema.relationMessageKeySet())
schema.Messages = &keys schema.Messages = &keys
out[name] = &CompiledRelation{ cr := &CompiledRelation{
Schema: schema, Schema: schema,
Contract: contract, Contract: contract,
RequiredPermissions: append([]string(nil), requiredOf(ctl)...), RequiredPermissions: append([]string(nil), requiredOf(ctl)...),
kind: contract.kind(),
deferrable: relationDeferrable(contract),
fieldName: form.Fields[fields[name]].Name,
} }
schema.Deferrable = cr.deferrable
if err := compileRelationForms(pluginID, ctl, fsys, file, doc, cr); err != nil {
return nil, err
}
form.Fields[fields[name]].Deferrable = cr.deferrable
out[name] = cr
} }
for name := range fields { for name := range fields {
if out[name] == nil { if out[name] == nil {
@@ -311,6 +429,11 @@ func compileRelationPanel(doc relationPanelDocument, contract RelationContract,
return RelationPanel{List: RelationList{Columns: cols}, ToolbarButtons: buttons, ShowSearch: doc.ShowSearch}, nil return RelationPanel{List: RelationList{Columns: cols}, ToolbarButtons: buttons, ShowSearch: doc.ShowSearch}, nil
} }
// relationButtons are the WinterCMS RelationController toolbar buttons the
// view panel may declare (D-12). Each one is the capability of its routes:
// create, update (row edit), delete, link and unlink.
var relationButtons = []string{"create", "update", "delete", "link", "unlink"}
func compileRelationButtons(raw string, view bool) ([]string, error) { func compileRelationButtons(raw string, view bool) ([]string, error) {
if strings.TrimSpace(raw) == "" { if strings.TrimSpace(raw) == "" {
return []string{}, nil return []string{}, nil
@@ -320,11 +443,11 @@ func compileRelationButtons(raw string, view bool) ([]string, error) {
seen := map[string]struct{}{} seen := map[string]struct{}{}
for _, part := range parts { for _, part := range parts {
part = strings.TrimSpace(part) part = strings.TrimSpace(part)
if part != "link" && part != "unlink" { if !slices.Contains(relationButtons, part) {
return nil, fmt.Errorf("unsupported relation action %s", part) return nil, fmt.Errorf("unsupported relation action %s", part)
} }
if !view && part == "unlink" { if !view && part != "link" {
return nil, fmt.Errorf("manage panel cannot declare unlink") return nil, fmt.Errorf("manage panel cannot declare %s (only link)", part)
} }
if _, dup := seen[part]; dup { if _, dup := seen[part]; dup {
return nil, fmt.Errorf("duplicate relation action %s", part) return nil, fmt.Errorf("duplicate relation action %s", part)
@@ -336,18 +459,17 @@ func compileRelationButtons(raw string, view bool) ([]string, error) {
} }
func validateRelationContract(ctl pact.AdminController, contract RelationContract) error { func validateRelationContract(ctl pact.AdminController, contract RelationContract) error {
if contract.NewRelated == nil || contract.NewRelated() == nil || contract.NewPivot == nil || contract.NewPivot() == nil { switch contract.kind() {
return fmt.Errorf("relation %s requires target and pivot models", contract.Name) case RelationBelongsToMany:
if err := validatePivotContract(contract); err != nil {
return err
} }
if !identifier(contract.ParentForeignKey) || !identifier(contract.RelatedForeignKey) { case RelationHasMany:
return fmt.Errorf("relation %s has invalid pivot foreign keys", contract.Name) if err := validateHasManyContract(contract); err != nil {
return err
} }
pivotCols := modelColumns(contract.NewPivot()) default:
if _, ok := pivotCols[contract.ParentForeignKey]; !ok { return fmt.Errorf("relation %s has unknown kind %q (want %s or %s)", contract.Name, contract.Kind, RelationBelongsToMany, RelationHasMany)
return fmt.Errorf("relation %s pivot is missing %s", contract.Name, contract.ParentForeignKey)
}
if _, ok := pivotCols[contract.RelatedForeignKey]; !ok {
return fmt.Errorf("relation %s pivot is missing %s", contract.Name, contract.RelatedForeignKey)
} }
targetCols := modelColumns(contract.NewRelated()) targetCols := modelColumns(contract.NewRelated())
for logical, physical := range contract.Columns { for logical, physical := range contract.Columns {
@@ -358,14 +480,6 @@ func validateRelationContract(ctl pact.AdminController, contract RelationContrac
return fmt.Errorf("relation %s target is missing column %s", contract.Name, physical) return fmt.Errorf("relation %s target is missing column %s", contract.Name, physical)
} }
} }
for _, col := range contract.HookPivotColumns {
if !identifier(col) {
return fmt.Errorf("relation %s has invalid hook column %s", contract.Name, col)
}
if _, ok := pivotCols[col]; !ok || protectedPivotColumn(col, contract) {
return fmt.Errorf("relation %s has invalid hook column %s", contract.Name, col)
}
}
src, ok := ctl.(pact.AdminRecordSource) src, ok := ctl.(pact.AdminRecordSource)
if !ok || src == nil || src.NewRecord() == nil { if !ok || src == nil || src.NewRecord() == nil {
return fmt.Errorf("relation %s owner has no record source", contract.Name) return fmt.Errorf("relation %s owner has no record source", contract.Name)
@@ -393,6 +507,71 @@ func validateRelationContract(ctl pact.AdminController, contract RelationContrac
return nil return nil
} }
// validatePivotContract checks a belongsToMany contract: target and pivot
// models, both pivot foreign keys on the pivot model and the hook columns.
func validatePivotContract(contract RelationContract) error {
if contract.NewRelated == nil || contract.NewRelated() == nil || contract.NewPivot == nil || contract.NewPivot() == nil {
return fmt.Errorf("relation %s requires target and pivot models", contract.Name)
}
if contract.ForeignKey != "" {
return fmt.Errorf("relation %s: a belongsToMany contract cannot declare ForeignKey", contract.Name)
}
if !identifier(contract.ParentForeignKey) || !identifier(contract.RelatedForeignKey) {
return fmt.Errorf("relation %s has invalid pivot foreign keys", contract.Name)
}
pivotCols := modelColumns(contract.NewPivot())
if _, ok := pivotCols[contract.ParentForeignKey]; !ok {
return fmt.Errorf("relation %s pivot is missing %s", contract.Name, contract.ParentForeignKey)
}
if _, ok := pivotCols[contract.RelatedForeignKey]; !ok {
return fmt.Errorf("relation %s pivot is missing %s", contract.Name, contract.RelatedForeignKey)
}
for _, col := range contract.HookPivotColumns {
if !identifier(col) {
return fmt.Errorf("relation %s has invalid hook column %s", contract.Name, col)
}
if _, ok := pivotCols[col]; !ok || protectedPivotColumn(col, contract) {
return fmt.Errorf("relation %s has invalid hook column %s", contract.Name, col)
}
}
return nil
}
// validateHasManyContract checks a hasMany contract: no pivot fields, and a
// ForeignKey that is an integer column (or a pointer to one) of the related
// model.
func validateHasManyContract(contract RelationContract) error {
if contract.NewRelated == nil || contract.NewRelated() == nil {
return fmt.Errorf("relation %s requires a target model", contract.Name)
}
if contract.NewPivot != nil || contract.ParentForeignKey != "" || contract.RelatedForeignKey != "" || len(contract.HookPivotColumns) > 0 {
return fmt.Errorf("relation %s: a hasMany contract cannot declare NewPivot, ParentForeignKey, RelatedForeignKey or HookPivotColumns", contract.Name)
}
if !identifier(contract.ForeignKey) {
return fmt.Errorf("relation %s: hasMany needs a ForeignKey column", contract.Name)
}
field, ok := structFieldByColumn(contract.NewRelated(), contract.ForeignKey)
if !ok {
return fmt.Errorf("relation %s target is missing column %s", contract.Name, contract.ForeignKey)
}
if !uintLike(field.Type) {
return fmt.Errorf("relation %s: ForeignKey %s must be an integer column, found %s", contract.Name, contract.ForeignKey, field.Type)
}
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
// with a NULL key first, as in WinterCMS).
func relationDeferrable(contract RelationContract) bool {
if contract.kind() != RelationHasMany {
return true
}
field, ok := structFieldByColumn(contract.NewRelated(), contract.ForeignKey)
return ok && field.Type.Kind() == reflect.Pointer
}
func protectedPivotColumn(col string, contract RelationContract) bool { func protectedPivotColumn(col string, contract RelationContract) bool {
switch col { switch col {
case contract.ParentForeignKey, contract.RelatedForeignKey, "id", "created_at", "updated_at", "deleted_at": case contract.ParentForeignKey, contract.RelatedForeignKey, "id", "created_at", "updated_at", "deleted_at":
@@ -463,6 +642,9 @@ func (s RelationService) query(ctx context.Context, cc *CompiledController, rela
} }
func relationBaseQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent any, candidates bool) (*gorm.DB, any, error) { func relationBaseQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent any, candidates bool) (*gorm.DB, any, error) {
if cr.hasMany() {
return hasManyBaseQuery(ctx, tx, cc, cr, parent, candidates)
}
target := cr.Contract.NewRelated() target := cr.Contract.NewRelated()
targetTable := tableName(target) targetTable := tableName(target)
pivotTable := tableName(cr.Contract.NewPivot()) pivotTable := tableName(cr.Contract.NewPivot())
@@ -493,6 +675,33 @@ func relationBaseQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController,
return q, target, nil return q, target, nil
} }
// hasManyBaseQuery is relationBaseQuery for a hasMany relation: the linked
// rows are the related rows whose ForeignKey is the parent's key.
func hasManyBaseQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent any, candidates bool) (*gorm.DB, any, error) {
target := cr.Contract.NewRelated()
fk := clause.Column{Table: tableName(target), Name: cr.Contract.ForeignKey}
q := tx.WithContext(ctx).Model(target)
if !candidates {
return q.Where(clause.Eq{Column: fk, Value: pkUint(parent)}), target, nil
}
// Candidates are rows no parent owns yet: a NULL ForeignKey.
if ext, ok := cc.Controller.(pact.RelationExtendManageQuery); ok && ext != nil {
if next := ext.RelationExtendManageQuery(ctx, cr.Contract.Name, q); next != nil {
q = next
}
}
if cr.Contract.ExcludedRelatedIDs != nil {
excluded, err := cr.Contract.ExcludedRelatedIDs(parent)
if err != nil {
return nil, nil, lifecycleFailure(cc, err)
}
if len(excluded) > 0 {
q = q.Where(clause.Not(clause.IN{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}, Values: uintValues(excluded)}))
}
}
return q.Where(clause.Eq{Column: fk, Value: nil}), target, nil
}
// normalizeRelationPage applies the Phase 9 relation paging limits: page is // normalizeRelationPage applies the Phase 9 relation paging limits: page is
// a positive integer (default 1), per_page is 1..100 (default 20). // a positive integer (default 1), per_page is 1..100 (default 20).
func normalizeRelationPage(rawPage, rawPerPage string) (int, int, error) { func normalizeRelationPage(rawPage, rawPerPage string) (int, int, error) {
@@ -631,6 +840,9 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat
if err != nil { if err != nil {
return RelationMutationResult{}, err return RelationMutationResult{}, err
} }
if cr.hasMany() {
return RelationMutationResult{}, recordNotFound{}
}
var result RelationMutationResult var result RelationMutationResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
@@ -660,8 +872,25 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat
} }
for i := 0; i < holder.Elem().Len(); i++ { for i := 0; i < holder.Elem().Len(); i++ {
related := holder.Elem().Index(i).Addr().Interface() related := holder.Elem().Index(i).Addr().Interface()
pivot := cr.Contract.NewPivot() if err := insertPivot(ctx, tx, cc, cr, parent, related, nil); err != nil {
if err := setModelColumn(pivot, cr.Contract.ParentForeignKey, ownerPK); err != nil { return err
}
result.Linked++
}
return nil
})
return result, err
}
// insertPivot writes the pivot row linking related to the saved parent of a
// belongsToMany relation. A filled pivot model (from the pivot form) may be
// passed; RelationBeforeLink then stamps only its HookPivotColumns, and the
// two foreign keys are always set here.
func insertPivot(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent, related, pivot any) error {
if pivot == nil {
pivot = cr.Contract.NewPivot()
}
if err := setModelColumn(pivot, cr.Contract.ParentForeignKey, pkUint(parent)); err != nil {
return err return err
} }
if err := setModelColumn(pivot, cr.Contract.RelatedForeignKey, pkUint(related)); err != nil { if err := setModelColumn(pivot, cr.Contract.RelatedForeignKey, pkUint(related)); err != nil {
@@ -669,7 +898,7 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat
} }
values := map[string]any{} values := map[string]any{}
if hook, ok := cc.Controller.(pact.RelationBeforeLink); ok && hook != nil { if hook, ok := cc.Controller.(pact.RelationBeforeLink); ok && hook != nil {
if err := hook.RelationBeforeLink(ctx, relation, parent, related, values); err != nil { if err := hook.RelationBeforeLink(ctx, cr.Contract.Name, parent, related, values); err != nil {
return lifecycleFailure(cc, err) return lifecycleFailure(cc, err)
} }
} }
@@ -681,14 +910,7 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat
return lifecycleFailure(cc, err) return lifecycleFailure(cc, err)
} }
} }
if err := tx.WithContext(ctx).Create(pivot).Error; err != nil { return tx.WithContext(ctx).Create(pivot).Error
return err
}
result.Linked++
}
return nil
})
return result, err
} }
// Unlink deletes explicit pivot models so their lifecycle hooks run. // Unlink deletes explicit pivot models so their lifecycle hooks run.
@@ -701,6 +923,9 @@ func (s RelationService) Unlink(ctx context.Context, cc *CompiledController, rel
if err != nil { if err != nil {
return RelationMutationResult{}, err return RelationMutationResult{}, err
} }
if cr.hasMany() {
return RelationMutationResult{}, recordNotFound{}
}
var result RelationMutationResult var result RelationMutationResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)

View File

@@ -0,0 +1,223 @@
package cabana
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// relationParent is the parent record of a relation route, loaded through
// FormExtendQuery.
type relationParent struct {
model any
id uint
}
// loadParent loads the parent record of a relation route through
// loadRecord (FormExtendQuery, FOR UPDATE). A missing or hidden parent, and
// id 0, are recordNotFound.
func (s RelationService) loadParent(ctx context.Context, tx *gorm.DB, cc *CompiledController, ownerID uint) (*relationParent, error) {
parent, err := newWritableModel(cc)
if err != nil {
return nil, err
}
if ownerID == 0 {
return nil, recordNotFound{}
}
if err := loadRecord(ctx, tx, cc, parent, castPK(parent, ownerID)); err != nil {
return nil, err
}
return &relationParent{model: parent, id: ownerID}, nil
}
// fillChild fills and validates a child of a relation form like the
// controller save does: the body is projected through the form's writable
// fields for op, filled with lagoon.Fill, checked by BeforeValidate, the
// model rules merged with the form's required flags, and the datepicker
// bounds. A failure is a 422 ValidationError.
func (s RelationService) fillChild(ctx context.Context, tx *gorm.DB, form *CompiledController, model any, body map[string]any, op string) error {
projected := projectOperation(form, body, op)
if err := lagoon.Fill(model, fillAllowed(form, model, op), projected, false); err != nil {
var typed *lagoon.FillTypeError
if errors.As(err, &typed) {
return &ValidationError{Details: fillTypeDetails(typed.Key)}
}
return &CapabilityError{ControllerID: controllerID(form)}
}
if hook, ok := model.(lagoon.HasBeforeValidate); ok && hook != nil {
if err := hook.BeforeValidate(tx); err != nil {
return &CapabilityError{ControllerID: controllerID(form)}
}
}
rules := mergedRules(form, model, op)
msgs, err := lagoon.Validate(ctx, tx, model, rules, valuesForRules(model, rules), nil)
if err != nil {
return &CapabilityError{ControllerID: controllerID(form)}
}
for field, extra := range dateBoundDetails(ctx, s.tr, form, model, op) {
if msgs == nil {
msgs = map[string][]string{}
}
msgs[field] = append(msgs[field], extra...)
}
if len(msgs) > 0 {
return &ValidationError{Details: validationDetails(msgs)}
}
return nil
}
// CreateChild creates a related record through the relation's manage form
// (D-11, D-16) and attaches it to the parent: a hasMany child gets the
// parent's key in its ForeignKey (set by the server, never from the body),
// a belongsToMany record gets a pivot row (RelationBeforeLink stamps its
// hook columns). The parent is loaded through FormExtendQuery. The child is
// filled and validated like a controller save, and the controller's
// optional pact.RelationBeforeCreate and pact.RelationAfterCreate hooks run
// around the insert; any failure rolls the whole create back.
func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RecordInput) (RecordResult, error) {
if s.DB == nil {
return RecordResult{}, errors.New("cabana: database is not configured")
}
cr, err := relationOf(cc, relation)
if err != nil {
return RecordResult{}, err
}
if cr.child == nil {
return RecordResult{}, &CapabilityError{ControllerID: controllerID(cc)}
}
var result RecordResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, ownerID)
if err != nil {
return err
}
child, err := newWritableModel(cr.child)
if err != nil {
return err
}
if cr.hasMany() {
if err := setModelColumn(child, cr.Contract.ForeignKey, parent.id); err != nil {
return lifecycleFailure(cc, err)
}
}
if err := s.fillChild(ctx, tx, cr.child, child, in.Body, "create"); err != nil {
return err
}
if hook, ok := cc.Controller.(pact.RelationBeforeCreate); ok && hook != nil {
if err := hook.RelationBeforeCreate(ctx, cr.Contract.Name, parent.model, child); err != nil {
return lifecycleFailure(cc, err)
}
}
if err := tx.WithContext(ctx).Create(child).Error; err != nil {
return lifecycleFailure(cc, err)
}
if !cr.hasMany() {
if err := insertPivot(ctx, tx, cc, cr, parent.model, child, nil); err != nil {
return lifecycleFailure(cc, err)
}
}
if hook, ok := cc.Controller.(pact.RelationAfterCreate); ok && hook != nil {
if err := hook.RelationAfterCreate(ctx, cr.Contract.Name, parent.model, child); err != nil {
return lifecycleFailure(cc, err)
}
}
result, err = projectFullRecord(ctx, tx, cr.child, child)
return err
})
if err != nil {
return RecordResult{}, err
}
return result, nil
}
// relationButton resolves the route's relation and refuses (403) a route
// whose toolbar button the view panel does not declare. An unknown relation
// is 404. It writes the response and returns nil on refusal.
func (s *service) relationButton(w http.ResponseWriter, r *http.Request, cc *CompiledController, button string) *CompiledRelation {
cr, err := relationOf(cc, r.PathValue("name"))
if err != nil {
writeCRUDError(w, err)
return nil
}
if !cr.allows(button) {
if principal, _ := bouncer.User(r.Context()); principal != nil {
s.logAuth(r, "denied", principal.ID)
}
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return nil
}
return cr
}
// decodeCappedObject decodes a JSON object body capped at
// http.body_limits.default_bytes; trailing data is refused.
func (s *service) decodeCappedObject(w http.ResponseWriter, r *http.Request) (map[string]any, error) {
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, s.jsonCap()))
dec.UseNumber()
var body map[string]any
if err := dec.Decode(&body); err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
return nil, err
}
return nil, invalidBody()
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
return nil, invalidBody()
}
if body == nil {
body = map[string]any{}
}
return body, nil
}
// writeRelationError maps a relation child route failure: a body past the
// cap is 413 payload_too_large, everything else as writeCRUDError.
func writeRelationError(w http.ResponseWriter, err error) {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
WriteError(w, http.StatusRequestEntityTooLarge, "payload_too_large", msgPayloadTooLarge)
return
}
writeCRUDError(w, err)
}
// relationChildCreate serves POST .../{id}/relations/{name}/records.
func (s *service) relationChildCreate(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
cr := s.relationButton(w, r, cc, "create")
if cr == nil {
return
}
id, err := pathID(r)
if err != nil {
writeCRUDError(w, err)
return
}
body, err := s.decodeCappedObject(w, r)
if err != nil {
writeRelationError(w, err)
return
}
svc, err := s.relations()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
rec, err := svc.CreateChild(r.Context(), cc, cr.Contract.Name, id, RecordInput{Body: body})
if err != nil {
writeRelationError(w, err)
return
}
WriteData(w, http.StatusCreated, rec.Data, rec.Meta)
})
}

View File

@@ -0,0 +1,80 @@
package cabana_test
import (
"encoding/json"
"fmt"
"net/http"
"slices"
"testing"
)
// linkedIDs lists the ids of a gadget relation's linked rows.
func (e *conformEnv) linkedIDs(t *testing.T, gadget uint, relation string, headers map[string]string) []uint {
t.Helper()
rec := e.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/%s", gadget, relation), nil, "", headers)
if rec.Code != http.StatusOK {
t.Fatalf("linked %s status=%d body=%s", relation, rec.Code, rec.Body.String())
}
var body struct {
Data []struct {
ID uint `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
out := make([]uint, 0, len(body.Data))
for _, row := range body.Data {
out = append(out, row.ID)
}
return out
}
// partOwner reads a part's gadget_id straight from the table.
func (e *conformEnv) partOwner(t *testing.T, id uint) *uint {
t.Helper()
var part conformPart
if err := e.db.First(&part, id).Error; err != nil {
t.Fatal(err)
}
return part.GadgetID
}
// TestRelationChildSmokeCreate creates a hasMany child of a saved gadget
// through the relation manager: the server sets the foreign key from the
// scoped parent, the child lists under that parent only, and a body naming
// the foreign key cannot move it.
func TestRelationChildSmokeCreate(t *testing.T) {
env := newConformEnv(t)
env.loginAs(t, env.login)
a := env.createGadget(t, "a-"+env.stamp, "")
b := env.createGadget(t, "b-"+env.stamp, "")
rec := env.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records", a), map[string]any{"label": "wheel", "gadget_id": b}, true)
if rec.Code != http.StatusCreated {
t.Fatalf("create part status=%d body=%s", rec.Code, rec.Body.String())
}
part := dataID(t, rec.Body.Bytes())
if owner := env.partOwner(t, part); owner == nil || *owner != a {
t.Fatalf("part gadget_id = %v, want %d", owner, a)
}
if got := env.linkedIDs(t, a, "parts", nil); !slices.Contains(got, part) {
t.Fatalf("gadget A parts = %v, want %d", got, part)
}
if got := env.linkedIDs(t, b, "parts", nil); slices.Contains(got, part) {
t.Fatalf("gadget B lists A's part: %v", got)
}
invalid := env.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records", a), map[string]any{"label": ""}, true)
if invalid.Code != http.StatusUnprocessableEntity {
t.Fatalf("empty label status=%d body=%s", invalid.Code, invalid.Body.String())
}
undeclared := env.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/records", a), map[string]any{"email": "x@example.test"}, true)
if undeclared.Code != http.StatusForbidden {
t.Fatalf("create on a relation without the create button status=%d body=%s", undeclared.Code, undeclared.Body.String())
}
missing := env.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records", b+1000), map[string]any{"label": "x"}, true)
if missing.Code != http.StatusNotFound {
t.Fatalf("create under a missing parent status=%d body=%s", missing.Code, missing.Body.String())
}
}

View File

@@ -0,0 +1,265 @@
package cabana
import (
"bytes"
"errors"
"fmt"
"io/fs"
"regexp"
"slices"
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
)
// relationFormRefusedTypes are the field types a relation form (manage.form,
// view.form, pivot.form) may not use (D-23): pickers, nested managers and
// plugin extensions need a parent record scope a child modal does not have.
var relationFormRefusedTypes = map[string]bool{
"relation": true, "relation-manager": true, "widget": true, "partial": true,
}
// pivotFieldPattern is WinterCMS's pivot form field name, pivot[column].
var pivotFieldPattern = regexp.MustCompile(`^pivot\[([A-Za-z_][A-Za-z0-9_]*)\]$`)
// pivotFieldName normalizes pivot[x] to x; any other name is unchanged.
func pivotFieldName(name string) string {
if m := pivotFieldPattern.FindStringSubmatch(name); m != nil {
return m[1]
}
return name
}
// relationModelController stands in for an admin controller around a
// relation form's model (the related model, or the pivot model), so the
// controller form pipeline (writable fields, file and date fields, fill,
// validate, projection) runs against that model. Its ID is the owning
// controller's, so boot errors and lifecycle failures name it.
type relationModelController struct {
id string
newRecord func() any
}
func (c relationModelController) ID() string { return c.id }
func (c relationModelController) ModelName() string { return "" }
func (c relationModelController) ConfigDir() string { return "" }
func (c relationModelController) NewRecord() any { return c.newRecord() }
// compileRelationForms compiles a relation's forms (D-11, D-14, D-23): the
// manage form (manage.form, else the top-level form) used to create and
// update children, the view form (view.form, else the top-level form) used
// to preview them, and the pivot form (pivot.form, belongsToMany only). It
// also enforces the toolbar rules that depend on them: create and update
// need a manage form, and unlink on a hasMany needs a nullable ForeignKey.
func compileRelationForms(pluginID string, ctl pact.AdminController, fsys fs.FS, file string, doc relationDocument, cr *CompiledRelation) error {
name := cr.Contract.Name
fail := func(err error) error {
return bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation %s: %w", name, err))
}
buttons := cr.Schema.View.ToolbarButtons
writes := slices.Contains(buttons, "create") || slices.Contains(buttons, "update")
manageRef := strings.TrimSpace(doc.Manage.Form)
if manageRef == "" {
manageRef = strings.TrimSpace(doc.Form)
}
viewRef := strings.TrimSpace(doc.View.Form)
if viewRef == "" {
viewRef = strings.TrimSpace(doc.Form)
}
if writes && manageRef == "" {
return fail(errors.New("toolbar buttons create and update need manage.form (or a top-level form)"))
}
if cr.hasMany() && slices.Contains(buttons, "unlink") && !cr.deferrable {
return fail(fmt.Errorf("unlink needs a nullable ForeignKey %s (a pointer field)", cr.Contract.ForeignKey))
}
cr.Schema.relatedModel = cr.Contract.NewRelated
if manageRef != "" {
child, err := compileRelationForm(pluginID, ctl, fsys, file, manageRef, cr, "manage", writes)
if err != nil {
return err
}
cr.child = child
cr.Schema.manageForm = child.Form
cr.Schema.ManageForm = child.Form.Fields
}
if viewRef != "" {
view := cr.child
if viewRef != manageRef || view == nil {
compiled, err := compileRelationForm(pluginID, ctl, fsys, file, viewRef, cr, "view", false)
if err != nil {
return err
}
view = compiled
}
cr.view = view
cr.Schema.viewForm = view.Form
cr.Schema.ViewForm = view.Form.Fields
}
if doc.Pivot != nil {
ref := strings.TrimSpace(doc.Pivot.Form)
if ref == "" {
return fail(errors.New("pivot needs a form"))
}
if cr.hasMany() {
return fail(errors.New("pivot.form is only valid on a belongsToMany relation"))
}
pivot, err := compileRelationForm(pluginID, ctl, fsys, file, ref, cr, "pivot", false)
if err != nil {
return err
}
cr.pivot = pivot
cr.Schema.pivotForm = pivot.Form
cr.Schema.PivotForm = pivot.Form.Fields
cr.Schema.pivotModel = cr.Contract.NewPivot
}
return nil
}
// compileRelationForm reads one relation form file and binds it to its
// model: the related model for the manage and view forms, the pivot model
// for the pivot form. writable additionally requires the model to implement
// lagoon.HasFillable and Rules (create and update fill and validate it).
func compileRelationForm(pluginID string, ctl pact.AdminController, fsys fs.FS, cfgFile, ref string, cr *CompiledRelation, purpose string, writable bool) (*CompiledController, error) {
relation := cr.Contract.Name
path, err := assetPath(pluginID, ref)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), cfgFile, fmt.Errorf("relation %s %s form: %w", relation, purpose, err))
}
fail := func(err error) error {
return bootErr(pluginID, ctl.ID(), path, fmt.Errorf("relation %s %s form: %w", relation, purpose, err))
}
raw, err := readAsset(fsys, path)
if err != nil {
return nil, fail(err)
}
var fields []FormField
if purpose == "pivot" {
fields, err = decodePivotFields(raw)
} else {
fields, err = decodeFields(raw)
}
if err != nil {
return nil, fail(err)
}
newModel := cr.Contract.NewRelated
if purpose == "pivot" {
newModel = cr.Contract.NewPivot
}
model := newModel()
for _, field := range fields {
if relationFormRefusedTypes[field.Type] {
return nil, fail(fmt.Errorf("field %s: type %s is not supported in a relation form (%s)", field.Name, field.Type, purpose))
}
if cr.hasMany() && field.Name == cr.Contract.ForeignKey {
return nil, fail(fmt.Errorf("field %s is the relation's ForeignKey; the server sets it", field.Name))
}
if purpose == "pivot" {
if !scalarFormField(field.Type) {
return nil, fail(fmt.Errorf("field %s: type %s is not supported in a pivot form", field.Name, field.Type))
}
if protectedPivotColumn(field.Name, cr.Contract) || slices.Contains(cr.Contract.HookPivotColumns, field.Name) {
return nil, fail(fmt.Errorf("field %s is a server-owned pivot column", field.Name))
}
}
if field.optionsMethod != "" && modelDropdownProvider(model) == nil {
return nil, fail(fmt.Errorf("dropdown method %s requires DropdownOptions on the %s model", field.optionsMethod, purpose))
}
}
cc := &CompiledController{
PluginID: pluginID,
Controller: relationModelController{id: ctl.ID(), newRecord: newModel},
Form: &FormSchema{Fields: fields, fieldsPath: path},
}
if err := BindWritableFields(cc); err != nil {
return nil, fail(errors.New(strings.TrimPrefix(err.Error(), "cabana: controller "+ctl.ID()+": ")))
}
if writable {
if _, err := newWritableModel(cc); err != nil {
return nil, fail(errors.New("the related model must implement lagoon.HasFillable and Rules"))
}
}
if purpose == "pivot" {
if err := checkFormDates(pluginID, cc); err != nil {
return nil, err
}
return cc, nil
}
if err := compileFileFields(pluginID, cc); err != nil {
return nil, err
}
if writable {
if err := compileDateFields(pluginID, cc); err != nil {
return nil, err
}
return cc, nil
}
if err := checkFormDates(pluginID, cc); err != nil {
return nil, err
}
return cc, nil
}
// checkFormDates runs the D-19 Go type check of every datepicker field and
// records them, without requiring a fillable model (read-only and pivot
// forms).
func checkFormDates(pluginID string, cc *CompiledController) error {
model := cc.Controller.(pact.AdminRecordSource).NewRecord()
for _, field := range cc.Form.Fields {
if field.Type != "datepicker" {
continue
}
if err := checkDateType(model, field); err != nil {
return bootErr(pluginID, controllerID(cc), cc.Form.fieldsPath, err)
}
if cc.dates == nil {
cc.dates = map[string]*compiledDate{}
}
cc.dates[field.Name] = &compiledDate{
name: field.Name, mode: field.Mode,
min: field.MinDate, max: field.MaxDate,
ignoreTimezone: field.IgnoreTimezone,
}
}
return nil
}
// pivotFieldsFile is a pivot form's fields.yaml: WinterCMS names its fields
// pivot[column], which compile to the bare column name.
type pivotFieldsFile struct {
Fields pivotFieldMap `yaml:"fields"`
}
type pivotFieldMap struct {
items []FormField
}
func (m *pivotFieldMap) UnmarshalYAML(node ast.Node) error {
items, err := decodeFieldMapping(node, pivotFieldName)
if err != nil {
return err
}
m.items = items
return nil
}
func decodePivotFields(raw []byte) ([]FormField, error) {
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
var doc pivotFieldsFile
if err := dec.Decode(&doc); err != nil {
return nil, normalizeYAMLError(err)
}
if doc.Fields.items == nil {
return []FormField{}, nil
}
return doc.Fields.items, nil
}
// modelDropdownProvider is model as a pact.DropdownOptionsProvider, or nil.
func modelDropdownProvider(model any) pact.DropdownOptionsProvider {
if p, ok := model.(pact.DropdownOptionsProvider); ok && p != nil {
return p
}
return nil
}

View File

@@ -34,10 +34,22 @@ func readAsset(fsys fs.FS, name string) ([]byte, error) {
return fs.ReadFile(fsys, name) return fs.ReadFile(fsys, name)
} }
// assetPath resolves a YAML file reference to a path inside the plugin's
// AdminFS: a plugin-relative path, ~/plugins/<vendor>/<plugin>/rest, or
// WinterCMS's $/<vendor>/<plugin>/rest ($/ is the plugins directory). A $/
// path into another plugin cannot be read from this plugin's tree and is an
// error.
func assetPath(pluginID, ref string) (string, error) { func assetPath(pluginID, ref string) (string, error) {
ref = strings.TrimSpace(ref) ref = strings.TrimSpace(ref)
own := strings.ReplaceAll(pluginID, ".", "/") + "/"
if rest, ok := strings.CutPrefix(ref, "$/"); ok {
if !strings.HasPrefix(rest, own) {
return "", fmt.Errorf("$/ path %s names another plugin", ref)
}
ref = strings.TrimPrefix(rest, own)
}
ref = strings.TrimPrefix(ref, "~/") ref = strings.TrimPrefix(ref, "~/")
prefix := "plugins/" + strings.ReplaceAll(pluginID, ".", "/") + "/" prefix := "plugins/" + own
ref = strings.TrimPrefix(ref, prefix) ref = strings.TrimPrefix(ref, prefix)
ref = path.Clean(ref) ref = path.Clean(ref)
if ref == "." || strings.HasPrefix(ref, "..") || strings.Contains(ref, "..") { if ref == "." || strings.HasPrefix(ref, "..") || strings.Contains(ref, "..") {

View File

@@ -264,6 +264,10 @@ type FormField struct {
// Protected is true for a fileupload field whose relation is not // Protected is true for a fileupload field whose relation is not
// public: its files are served only through the admin file routes. // public: its files are served only through the admin file routes.
Protected bool `json:"protected,omitempty"` Protected bool `json:"protected,omitempty"`
// Deferrable is true on a relation-manager field whose relation can be
// managed before the record is first saved (RelationSchema.Deferrable):
// the SPA shows it on the create screen.
Deferrable bool `json:"deferrable,omitempty"`
optionsMethod string optionsMethod string
} }

View File

@@ -62,6 +62,7 @@ var phase09Routes = []adminRoute{
{key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates"}, {key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates"},
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link"}, {key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link"},
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink"}, {key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink"},
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records"},
{key: "GET /{vendor}/{plugin}/{controller}/{id}/files/{field}", mounted: nestedGetRoute}, {key: "GET /{vendor}/{plugin}/{controller}/{id}/files/{field}", mounted: nestedGetRoute},
{key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}"}, {key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}"},
{key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder"}, {key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder"},
@@ -293,6 +294,7 @@ func phase09ProtectedCalls() []phase09Call {
{"relation-candidates", (*service).relationCandidates}, {"relation-candidates", (*service).relationCandidates},
{"relation-link", (*service).relationLink}, {"relation-link", (*service).relationLink},
{"relation-unlink", (*service).relationUnlink}, {"relation-unlink", (*service).relationUnlink},
{"relation-child-create", (*service).relationChildCreate},
{"file-list", (*service).fileList}, {"file-list", (*service).fileList},
{"file-upload", (*service).fileUpload}, {"file-upload", (*service).fileUpload},
{"file-reorder", (*service).fileReorder}, {"file-reorder", (*service).fileReorder},

View File

@@ -136,3 +136,22 @@ messages:
one: "Unlinked :count record" one: "Unlinked :count record"
other: "Unlinked :count records" other: "Unlinked :count records"
empty: No linked records. empty: No linked records.
create: New record
create_title: New record
update_title: Edit record
preview_title: Record preview
created: Record created
updated: Record saved
delete_selected: Delete selected
delete_confirm: "Delete the selected (:count)? This cannot be undone."
delete_one_confirm: Delete this record? This cannot be undone.
deleted:
one: "Deleted :count record"
other: "Deleted :count records"
pivot_title: Link details
pivot_saved: Link details saved
edit_pivot: "Edit link details: :name"
create_submit: Create record
update_submit: Save record
pivot_submit: Save link details
link_submit: Add link

View File

@@ -152,3 +152,24 @@ messages:
many: "Odłączono :count rekordów" many: "Odłączono :count rekordów"
other: "Odłączono :count rekordu" other: "Odłączono :count rekordu"
empty: Brak powiązanych rekordów. empty: Brak powiązanych rekordów.
create: Nowy rekord
create_title: Nowy rekord
update_title: Edycja rekordu
preview_title: Podgląd rekordu
created: Utworzono rekord
updated: Zapisano rekord
delete_selected: Usuń zaznaczone
delete_confirm: "Usunąć zaznaczone (:count)? Tej operacji nie można cofnąć."
delete_one_confirm: Usunąć ten rekord? Tej operacji nie można cofnąć.
deleted:
one: "Usunięto :count rekord"
few: "Usunięto :count rekordy"
many: "Usunięto :count rekordów"
other: "Usunięto :count rekordu"
pivot_title: Szczegóły powiązania
pivot_saved: Zapisano szczegóły powiązania
edit_pivot: "Edytuj szczegóły powiązania: :name"
create_submit: Utwórz rekord
update_submit: Zapisz rekord
pivot_submit: Zapisz szczegóły powiązania
link_submit: Dodaj powiązanie