feat(12.2-03): add parent-scoped child show, update, delete and pivot routes

- loadChild finds a child with one query carrying the parent predicate; a foreign child is 404
- GET/PUT .../records/{child} and POST .../delete (all or nothing) per relation kind
- hasMany link adopts NULL-key rows and unlink clears the key; pending created children are never candidates
- link accepts pivot values for one id through the pivot.form whitelist; GET/PUT .../pivot/{child}
- Link and Unlink share linkRelated/unlinkRelated for the deferred commit
This commit is contained in:
Jakub Zych
2026-10-02 18:44:34 +02:00
parent 48a5b8045a
commit afb05b6ee4
13 changed files with 2327 additions and 90 deletions

View File

@@ -137,6 +137,24 @@
"additionalProperties": {},
"type": "object"
},
"cabana.AdminRelationLinkRequest": {
"properties": {
"ids": {
"items": {
"type": "integer"
},
"type": "array"
},
"pivot": {
"additionalProperties": {},
"type": "object"
}
},
"required": [
"ids"
],
"type": "object"
},
"cabana.AdminRoleSummary": {
"properties": {
"code": {
@@ -334,6 +352,21 @@
],
"type": "object"
},
"cabana.Envelope-cabana_AdminRecord": {
"properties": {
"data": {
"$ref": "#/components/schemas/cabana.AdminRecord"
},
"meta": {
"$ref": "#/components/schemas/cabana.SuccessMeta"
}
},
"required": [
"data",
"meta"
],
"type": "object"
},
"cabana.Envelope-cabana_BulkResult": {
"properties": {
"data": {
@@ -5082,8 +5115,9 @@
]
}
},
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link": {
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete": {
"post": {
"description": "Deletes children of the owner through their model: a hasMany child is deleted (hooks and soft delete run); a belongsToMany record loses this owner's pivot row and is then deleted. Every id must be a child of this owner, otherwise the whole request is 404 and nothing is deleted. The view panel must declare the delete toolbar button, otherwise 403.",
"parameters": [
{
"description": "Vendor",
@@ -5142,6 +5176,139 @@
"description": "Related record ids",
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-cabana_BulkResult"
}
}
},
"description": "OK"
},
"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": "Delete related records",
"tags": [
"admin"
]
}
},
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link": {
"post": {
"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.AdminRelationLinkRequest"
}
}
},
"description": "Related record ids and optional pivot form values",
"required": true
},
"responses": {
"200": {
"content": {
@@ -5205,6 +5372,269 @@
]
}
},
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}": {
"get": {
"description": "The pivot form (pivot.form) values of the pivot row linking the owner and one related record, keyed by field name; id is the related record's id. Needs a pivot form and the link or update toolbar button (403 otherwise); a record not linked to this owner is 404.",
"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"
}
},
{
"description": "Related record id",
"in": "path",
"name": "child",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminRecord"
}
}
},
"description": "OK"
},
"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"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unprocessable Entity"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "Show the pivot values of a link",
"tags": [
"admin"
]
},
"put": {
"description": "Saves pivot form values on the pivot row linking the owner and one related record. Only pivot form fields are accepted (422 per unknown key); the pivot foreign keys, timestamps and hook columns can never be set. Gated and scoped like the pivot 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"
}
},
{
"description": "Related record id",
"in": "path",
"name": "child",
"required": true,
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.AdminRecord"
}
}
},
"description": "Pivot form values keyed by field name",
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminRecord"
}
}
},
"description": "OK"
},
"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": "Update the pivot values of a link",
"tags": [
"admin"
]
}
},
"/{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.",
@@ -5339,6 +5769,269 @@
]
}
},
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}": {
"get": {
"description": "One child of the owner, projected through the manage form when the relation declares the update button, else through the view form (view.form, or the top-level form). A record that is not a child of this owner (hasMany: its foreign key; belongsToMany: a pivot row) is 404. Without either form the route answers 403.",
"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"
}
},
{
"description": "Related record id",
"in": "path",
"name": "child",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.RecordEnvelope"
}
}
},
"description": "OK"
},
"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"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unprocessable Entity"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "Show a related record",
"tags": [
"admin"
]
},
"put": {
"description": "Saves one child of the owner through the manage form, with the related model's rules and hooks. The view panel must declare the update toolbar button, otherwise 403. A record that is not a child of this owner is 404.",
"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"
}
},
{
"description": "Related record id",
"in": "path",
"name": "child",
"required": true,
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.AdminRecord"
}
}
},
"description": "Field values of the manage form keyed by field name",
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.RecordEnvelope"
}
}
},
"description": "OK"
},
"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": "Update a related record",
"tags": [
"admin"
]
}
},
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink": {
"post": {
"parameters": [

View File

@@ -2540,6 +2540,106 @@ export interface paths {
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Delete related records
* @description Deletes children of the owner through their model: a hasMany child is deleted (hooks and soft delete run); a belongsToMany record loses this owner's pivot row and is then deleted. Every id must be a child of this owner, otherwise the whole request is 404 and nothing is deleted. The view panel must declare the delete toolbar button, otherwise 403.
*/
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 Related record ids */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminIDsRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_BulkResult"];
};
};
/** @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}/link": {
parameters: {
query?: never;
@@ -2568,10 +2668,10 @@ export interface paths {
};
cookie?: never;
};
/** @description Related record ids */
/** @description Related record ids and optional pivot form values */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminIDsRequest"];
"application/json": components["schemas"]["cabana.AdminRelationLinkRequest"];
};
};
responses: {
@@ -2628,6 +2728,180 @@ export interface paths {
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Show the pivot values of a link
* @description The pivot form (pivot.form) values of the pivot row linking the owner and one related record, keyed by field name; id is the related record's id. Needs a pivot form and the link or update toolbar button (403 otherwise); a record not linked to this owner is 404.
*/
get: {
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;
/** @description Related record id */
child: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminRecord"];
};
};
/** @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 Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
/**
* Update the pivot values of a link
* @description Saves pivot form values on the pivot row linking the owner and one related record. Only pivot form fields are accepted (422 per unknown key); the pivot foreign keys, timestamps and hook columns can never be set. Gated and scoped like the pivot show route.
*/
put: {
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;
/** @description Related record id */
child: number;
};
cookie?: never;
};
/** @description Pivot form values keyed by field name */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminRecord"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminRecord"];
};
};
/** @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"];
};
};
};
};
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records": {
parameters: {
query?: never;
@@ -2728,6 +3002,180 @@ export interface paths {
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Show a related record
* @description One child of the owner, projected through the manage form when the relation declares the update button, else through the view form (view.form, or the top-level form). A record that is not a child of this owner (hasMany: its foreign key; belongsToMany: a pivot row) is 404. Without either form the route answers 403.
*/
get: {
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;
/** @description Related record id */
child: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
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 Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
/**
* Update a related record
* @description Saves one child of the owner through the manage form, with the related model's rules and hooks. The view panel must declare the update toolbar button, otherwise 403. A record that is not a child of this owner is 404.
*/
put: {
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;
/** @description Related record id */
child: number;
};
cookie?: never;
};
/** @description Field values of the manage form keyed by field name */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminRecord"];
};
};
responses: {
/** @description OK */
200: {
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"];
};
};
};
};
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink": {
parameters: {
query?: never;
@@ -2864,6 +3312,12 @@ export interface components {
"cabana.AdminRecord": {
[key: string]: unknown;
};
"cabana.AdminRelationLinkRequest": {
ids: number[];
pivot?: {
[key: string]: unknown;
};
};
"cabana.AdminRoleSummary": {
code: string;
id: number;
@@ -2912,6 +3366,10 @@ export interface components {
data: components["schemas"]["cabana.AdminProfile"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_AdminRecord": {
data: components["schemas"]["cabana.AdminRecord"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_BulkResult": {
data: components["schemas"]["cabana.BulkResult"];
meta: components["schemas"]["cabana.SuccessMeta"];

View File

@@ -117,7 +117,39 @@ The `manage` panel may declare only `link`. An unknown or duplicate button stops
`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.
### Editing, deleting, linking and unlinking
| Route | Button | Effect |
|-------|--------|--------|
| GET `.../{id}/relations/{name}/records/{child}` | `update`, or a view form | One child, through the manage form when `update` is listed, else through the view form. |
| PUT `.../{id}/relations/{name}/records/{child}` | `update` | Saves the child through the manage form, with the related model's rules and hooks. |
| POST `.../{id}/relations/{name}/delete` | `delete` | `{ids}`: deletes the children. |
| POST `.../{id}/relations/{name}/link` | `link` | `{ids}`, optionally with `pivot`: links existing records. |
| POST `.../{id}/relations/{name}/unlink` | `unlink` | `{ids}`: unlinks records. |
What delete, link and unlink do depends on the kind:
- On a hasMany, `delete` deletes the child through its model, so its hooks and soft delete run. `link` adopts records whose `ForeignKey` is NULL, setting it to the parent's key through the child model; `unlink` sets it back to NULL.
- On a belongsToMany, `delete` removes this parent's pivot row and then deletes the related record through its model. `link` writes pivot rows and `unlink` deletes them, as before.
Every child route is scoped to its parent. The parent is loaded through `pact.FormExtendQuery`, and the child must belong to it: on a hasMany by its `ForeignKey`, on a belongsToMany by a pivot row. A child of another parent, or any child of a parent the administrator cannot open, answers 404 `not_found`, never 403, so a request cannot tell a foreign record from a missing one. A delete is all or nothing: when one of the ids is not a child of the parent, nothing is deleted.
A controller can hook into the child writes with the optional `pact.RelationBeforeCreate` and `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate` and `pact.RelationAfterUpdate`, and `pact.RelationBeforeDelete` and `pact.RelationAfterDelete`, 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.
### Pivot forms
A belongsToMany relation can edit columns of its pivot rows with `pivot.form` (a pivot form on a hasMany stops the start-up):
```yaml
editors:
label: acme.blog::lang.posts.editors
pivot:
form: $/acme/blog/models/posteditor/pivot_fields.yaml
```
WinterCMS names pivot form fields `pivot[role]`; both `pivot[role]` and the bare `role` are accepted and compile to the pivot column `role`. Each field must be a scalar or `datepicker` column of the pivot model, and never one of the pivot's foreign keys, `id`, `created_at`, `updated_at`, `deleted_at` or a `HookPivotColumns` entry: those stay server-owned.
The SPA sends pivot values when it links one record: `{"ids": [7], "pivot": {"role": "reviewer"}}`. A `pivot` object with more than one id answers 422 on `ids`, and a key that is not a pivot form field answers 422 on that key. The values are filled into the pivot model through the pivot form's fields only, validated, and then `pact.RelationBeforeLink` stamps its hook columns as before. Later, GET and PUT `.../{id}/relations/{name}/pivot/{child}` read and save the same values on an existing link. Both need a pivot form and the `link` or `update` button, and a record not linked to the parent answers 404.
### Messages

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.
- 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.
- 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.
- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates, linking and unlinking, and creating related records. A contract is a belongsToMany (`cabana.RelationBelongsToMany`, the kind of a contract that leaves `Kind` empty) with a pivot model, or a hasMany (`cabana.RelationHasMany`) whose `ForeignKey` column on the related model points at the parent. A relation's child form comes from `manage.form` in `config_relation.yaml` (or a top-level `form`, WinterCMS's fallback), its read-only preview from `view.form` (same fallback), and a belongsToMany's pivot columns are edited through `pivot.form` (WinterCMS `pivot[x]` field names compile to `x`; pivot keys, timestamps and hook columns are refused); WinterCMS `$/<vendor>/<plugin>/...` paths resolve inside the same plugin only. A relation form accepts the scalar field types plus `datepicker` and `fileupload`; `relation`, `relation-manager`, `widget` and `partial` fail boot. The view panel's `toolbarButtons` (`create`, `update`, `delete`, `link`, `unlink`) are the capability of their routes. A relation's `messages` block takes the link keys (`link`, `linkHint`, `candidateSearch`, `linked`, `unlinkSelected`, `unlinkConfirm`, `unlinked`, `empty`) and the child and pivot modal keys (`create`, `createTitle`, `updateTitle`, `previewTitle`, `created`, `updated`, `deleteSelected`, `deleteConfirm`, `deleteOneConfirm`, `deleted`, `pivotTitle`, `pivotSaved`, `editPivot`, `createSubmit`, `updateSubmit`, `pivotSubmit`, `linkSubmit`), each defaulting to `backend::lang.messages.relation.*`. Framework code never guesses table, pivot or foreign-key names: the controller supplies them.
- 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.
- 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.
@@ -47,8 +47,11 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
| GET `/{vendor}/{plugin}/{controller}/partials/{name}` | Render a declared header or form partial as a node tree; `?id=` (form partials only) passes the scoped record to the view model. |
| 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. |
| 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. A link body may carry `pivot` values for one id on a relation with a pivot form. On a hasMany, link sets and unlink clears the related record's foreign key. |
| POST `.../{id}/relations/{name}/records` | Create a related record through the relation's manage form and attach it to the record (a hasMany foreign key is set by the server; a belongsToMany gets its pivot row). Needs `create` in the view panel's `toolbarButtons` (403 otherwise). |
| GET and PUT `.../{id}/relations/{name}/records/{child}` | Show a child (needs `update` or a view form) and save it through the manage form (needs `update`). |
| POST `.../{id}/relations/{name}/delete` | `{ids}`: delete children through their model (needs `delete`). A belongsToMany record loses this record's pivot row first. All or nothing. |
| GET and PUT `.../{id}/relations/{name}/pivot/{child}` | Read and save the pivot form values of one link (needs a pivot form and `link` or `update`). |
| 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`. |
| PUT `.../{id}/files/{field}/{file}` | Save a file's `title` and `description` at once; the field must declare `useCaption` (403 otherwise). |
@@ -56,6 +59,8 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
| POST `.../{id}/files/{field}/reorder` | `{ids}` in the new order, exactly the field's visible files; they take the existing `sort_order` values at once. attachMany only (403 otherwise). |
| GET `.../{id}/files/{field}/{file}/download`, GET `.../{id}/files/{field}/{file}/thumb` | Stream a protected file or its preview thumbnail; see the protected files note below. |
Every relation child and pivot route loads the record through `pact.FormExtendQuery` and finds the child with one query that carries the record: a hasMany child by its foreign key, a belongsToMany record by a pivot row. A child of another record is `not_found`, never `forbidden`.
Every file route resolves its file with one query scoped to the record (loaded through `pact.FormExtendQuery`) or to the administrator's own pending uploads: a file of another record is `not_found`, never `forbidden`. The save applies the session's file work in its transaction: a pending upload on an attachOne field replaces the file attached before, a deferred removal deletes the attached file, and the blobs of deleted files are removed after commit. After that the save checks `maxFiles` and a `required` fileupload field (at least one file) and answers 422 on the field when either fails; the pending work stays for the next attempt.
Protected files (a relation with `Public` false) never get a public URL. The download and thumb routes serve only `is_public` false files, with `X-Content-Type-Options: nosniff`, `Cache-Control: private, no-store` and `Content-Security-Policy: default-src 'none'; sandbox`; JPEG, PNG, GIF and WebP are served inline with their type, every other type as an `application/octet-stream` attachment. The thumb route answers 404 for a file that is not one of those images.
@@ -165,7 +170,9 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. |
| `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.AdminRelationChildCreate` / `cabana.AdminRelationChildShow` / `cabana.AdminRelationChildUpdate` / `cabana.AdminRelationChildDelete` / `cabana.AdminRelationPivotShow` / `cabana.AdminRelationPivotUpdate` | Swag annotations of the relation child and pivot routes. |
| `cabana.RelationMutationInput` | Body of the link and unlink routes: `IDs` and, for a link of one id, `Pivot` form values. |
| `cabana.AdminRelationLinkRequest` | Documented body of the link route: `ids` and the optional `pivot` object. |
| `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.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |

View File

@@ -67,11 +67,19 @@ type ListEnvelope[T any] struct {
type AdminRecord map[string]any
// AdminIDsRequest is the body of the id-list writes: bulk delete, relation
// link and relation unlink.
// unlink and relation child delete.
type AdminIDsRequest struct {
IDs []uint64 `json:"ids"`
}
// AdminRelationLinkRequest is the body of the relation link route: the
// related ids and, on a belongsToMany relation with a pivot form, the pivot
// form values of exactly one linked id. Unknown keys are refused.
type AdminRelationLinkRequest struct {
IDs []uint64 `json:"ids"`
Pivot map[string]any `json:"pivot,omitempty"`
}
// AdminLoginRequest is the admin login body. Either login or email
// identifies the backend user.
type AdminLoginRequest struct {
@@ -582,7 +590,7 @@ func AdminRelationCandidates() {}
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param name path string true "Relation name"
// @Param body body AdminIDsRequest true "Related record ids"
// @Param body body AdminRelationLinkRequest true "Related record ids and optional pivot form values"
// @Success 200 {object} Envelope[RelationMutationResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
@@ -635,6 +643,119 @@ func AdminRelationUnlink() {}
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records [post]
func AdminRelationChildCreate() {}
// AdminRelationChildShow documents the relation child show route.
//
// @Summary Show a related record
// @Description One child of the owner, projected through the manage form when the relation declares the update button, else through the view form (view.form, or the top-level form). A record that is not a child of this owner (hasMany: its foreign key; belongsToMany: a pivot row) is 404. Without either form the route answers 403.
// @Tags admin
// @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 child path integer true "Related record id"
// @Success 200 {object} RecordEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child} [get]
func AdminRelationChildShow() {}
// AdminRelationChildUpdate documents the relation child update route.
//
// @Summary Update a related record
// @Description Saves one child of the owner through the manage form, with the related model's rules and hooks. The view panel must declare the update toolbar button, otherwise 403. A record that is not a child of this owner is 404.
// @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 child path integer true "Related record id"
// @Param body body AdminRecord true "Field values of the manage form keyed by field name"
// @Success 200 {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/{child} [put]
func AdminRelationChildUpdate() {}
// AdminRelationChildDelete documents the relation child delete route.
//
// @Summary Delete related records
// @Description Deletes children of the owner through their model: a hasMany child is deleted (hooks and soft delete run); a belongsToMany record loses this owner's pivot row and is then deleted. Every id must be a child of this owner, otherwise the whole request is 404 and nothing is deleted. The view panel must declare the delete toolbar button, otherwise 403.
// @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 AdminIDsRequest true "Related record ids"
// @Success 200 {object} Envelope[BulkResult]
// @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}/delete [post]
func AdminRelationChildDelete() {}
// AdminRelationPivotShow documents the pivot show route.
//
// @Summary Show the pivot values of a link
// @Description The pivot form (pivot.form) values of the pivot row linking the owner and one related record, keyed by field name; id is the related record's id. Needs a pivot form and the link or update toolbar button (403 otherwise); a record not linked to this owner is 404.
// @Tags admin
// @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 child path integer true "Related record id"
// @Success 200 {object} Envelope[AdminRecord]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child} [get]
func AdminRelationPivotShow() {}
// AdminRelationPivotUpdate documents the pivot update route.
//
// @Summary Update the pivot values of a link
// @Description Saves pivot form values on the pivot row linking the owner and one related record. Only pivot form fields are accepted (422 per unknown key); the pivot foreign keys, timestamps and hook columns can never be set. Gated and scoped like the pivot 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 child path integer true "Related record id"
// @Param body body AdminRecord true "Pivot form values keyed by field name"
// @Success 200 {object} Envelope[AdminRecord]
// @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}/pivot/{child} [put]
func AdminRelationPivotUpdate() {}
// FileMutationResult is the payload of a file removal: the number of files
// removed (always 1 on success).
type FileMutationResult struct {

View File

@@ -275,10 +275,22 @@ func (s *service) mount(r pact.Router) {
constrainRelation(g)
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", requireAjax(s.relationUnlink))
constrainRelation(g)
// Relation child routes (D-11, D-12): create through the relation's
// manage form.
// Relation child routes (D-11, D-12, D-15): create, show, update and
// delete children through the relation's forms, every child scoped
// to the parent record.
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records", requireAjax(s.relationChildCreate))
constrainRelation(g)
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}", s.relationChildShow)
constrainChild(g)
g.Put("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}", requireAjax(s.relationChildUpdate))
constrainChild(g)
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete", requireAjax(s.relationChildDelete))
constrainRelation(g)
// Pivot form values of one belongsToMany link (D-14).
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}", s.relationPivotShow)
constrainChild(g)
g.Put("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}", requireAjax(s.relationPivotUpdate))
constrainChild(g)
// File routes of `type: fileupload` fields (D-09). {id} 0 is the
// record being created in the X-Session-Key session.
g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload))
@@ -318,6 +330,11 @@ func constrainRelation(g pact.Router) {
g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")
}
func constrainChild(g pact.Router) {
constrainRelation(g)
g.Where("child", "[0-9]+")
}
func constrainFile(g pact.Router) {
constrainController(g)
g.Where("field", "[A-Za-z_][A-Za-z0-9_]*")

View File

@@ -216,6 +216,12 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", 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/link", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true)
}, into[cabana.Envelope[cabana.RelationMutationResult]](), nil},
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}", 200, "cabana.Envelope-cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/pivot/%d", e.gadgetID, e.memberID), nil, true)
}, into[cabana.Envelope[cabana.AdminRecord]](), nil},
{"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}", 200, "cabana.Envelope-cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/pivot/%d", e.gadgetID, e.memberID), map[string]any{"note": "note-" + e.stamp}, true)
}, into[cabana.Envelope[cabana.AdminRecord]](), nil},
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members", e.gadgetID), nil, true)
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]](), nil},
@@ -223,8 +229,19 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
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},
{"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)
rec := e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records", e.gadgetID), map[string]any{"label": "part-" + e.stamp}, true)
e.partID = dataID(t, rec.Body.Bytes())
return rec
}, into[cabana.RecordEnvelope](), nil},
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/%d", e.gadgetID, e.partID), nil, true)
}, into[cabana.RecordEnvelope](), nil},
{"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/%d", e.gadgetID, e.partID), map[string]any{"label": "part-" + e.stamp + "-renamed"}, true)
}, into[cabana.RecordEnvelope](), nil},
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/delete", e.gadgetID), map[string]any{"ids": []uint{e.partID}}, true)
}, into[cabana.Envelope[cabana.BulkResult]](), nil},
{"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)
}, into[cabana.Envelope[cabana.AdminActionResult]](), nil},
@@ -344,6 +361,7 @@ type conformEnv struct {
groupID uint
memberID uint
gadgetID uint
partID uint
}
// sendWith sends a raw body with extra headers through the assembled router.
@@ -586,6 +604,9 @@ type conformGadgetMember struct {
ID uint `gorm:"column:id;primaryKey"`
GadgetID uint `gorm:"column:gadget_id"`
MemberID uint `gorm:"column:member_id"`
Note string `gorm:"column:note"`
// Stamp is a hook column RelationBeforeLink writes.
Stamp string `gorm:"column:stamp"`
}
func (conformGadgetMember) TableName() string { return "cabana_conform_gadget_members" }
@@ -648,11 +669,27 @@ func (conformController) AdminRelationContracts() []cabana.RelationContract {
return []cabana.RelationContract{{
Name: "members", NewRelated: func() any { return &conformMember{} }, NewPivot: func() any { return &conformGadgetMember{} },
ParentForeignKey: "gadget_id", RelatedForeignKey: "member_id", Columns: map[string]string{"email": "email"},
HookPivotColumns: []string{"stamp"},
}, {
Name: "parts", Kind: cabana.RelationHasMany, NewRelated: func() any { return &conformPart{} },
ForeignKey: "gadget_id", Columns: map[string]string{"label": "label"},
}}
}
// RelationBeforeLink stamps the server-owned pivot column of a members link.
func (conformController) RelationBeforeLink(_ context.Context, relation string, _, _ any, pivot map[string]any) error {
if relation == "members" {
pivot["stamp"] = "linked"
}
return nil
}
// FormExtendQuery hides gadgets whose name starts with hidden-, so the
// smoke tests can check that a hidden parent scopes its children away.
func (conformController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
return db.Where("name NOT LIKE ?", "hidden-%")
}
func (conformController) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{{Field: "group", Kind: "belongsTo", NewRelated: func() any { return &conformGroup{} }, ForeignKey: "group_id"}}
}
@@ -769,6 +806,8 @@ update:
email:
label: Email
showSearch: true
pivot:
form: models/member/pivot_fields.yaml
parts:
label: Parts
view:
@@ -776,7 +815,7 @@ parts:
columns:
label:
label: Label
toolbarButtons: create
toolbarButtons: create|update|delete|link|unlink
manage:
form: $/acme/conform/models/part/fields.yaml
list:
@@ -852,6 +891,11 @@ parts:
fileTypes: [pdf, png, svg, txt]
useCaption: true
context: update
`),
"models/member/pivot_fields.yaml": file(`fields:
pivot[note]:
label: Note
type: text
`),
"models/part/fields.yaml": file(`fields:
label:

View File

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

View File

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

View File

@@ -164,6 +164,10 @@ type RelationResult struct {
// RelationMutationInput is the only accepted relation write payload.
type RelationMutationInput struct {
IDs []any `json:"ids"`
// Pivot holds pivot form values for a belongsToMany link of exactly one
// id (D-14). Only the relation's pivot.form fields are accepted; the
// pivot foreign keys, timestamps and hook columns never are.
Pivot map[string]any `json:"pivot,omitempty"`
}
// RelationMutationResult reports inserted/deleted pivot rows.
@@ -668,6 +672,10 @@ func relationBaseQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController,
}
notExists := "NOT EXISTS (SELECT 1 FROM " + quotedIdent(tx, pivotTable) + " p WHERE p." + quotedIdent(tx, cr.Contract.ParentForeignKey) + " = ? AND p." + quotedIdent(tx, cr.Contract.RelatedForeignKey) + " = " + quotedIdent(tx, targetTable) + "." + quotedIdent(tx, pk) + ")"
q = q.Where(notExists, ownerPK)
var err error
if q, err = excludePendingCreated(tx, q, target); err != nil {
return nil, nil, lifecycleFailure(cc, err)
}
} else {
join := "JOIN " + quotedIdent(tx, pivotTable) + " p ON p." + quotedIdent(tx, cr.Contract.RelatedForeignKey) + " = " + quotedIdent(tx, targetTable) + "." + quotedIdent(tx, pk)
q = q.Joins(join).Where("p."+quotedIdent(tx, cr.Contract.ParentForeignKey)+" = ?", ownerPK)
@@ -699,9 +707,27 @@ func hasManyBaseQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController,
q = q.Where(clause.Not(clause.IN{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}, Values: uintValues(excluded)}))
}
}
q, err := excludePendingCreated(tx, q, target)
if err != nil {
return nil, nil, lifecycleFailure(cc, err)
}
return q.Where(clause.Eq{Column: fk, Value: nil}), target, nil
}
// excludePendingCreated drops from a candidate query every row that a
// pending form session created (a live bind whose envelope says created,
// D-22): another parent must not adopt a child before the session that
// created it saves or the purge removes it (Pitfall 8).
func excludePendingCreated(tx *gorm.DB, q *gorm.DB, target any) (*gorm.DB, error) {
morph, err := lagoon.MorphType(tx, target)
if err != nil {
return nil, err
}
column := quotedIdent(tx, tableName(target)) + "." + quotedIdent(tx, primaryColumn(target))
pending := "NOT EXISTS (SELECT 1 FROM " + quotedIdent(tx, "deferred_bindings") + " b WHERE b.slave_type = ? AND b.is_bind AND b.slave_id = CAST(" + column + " AS TEXT) AND b.pivot_data LIKE ?)"
return q.Where(pending, morph, `{"created":true%`), nil
}
// normalizeRelationPage applies the Phase 9 relation paging limits: page is
// a positive integer (default 1), per_page is 1..100 (default 20).
func normalizeRelationPage(rawPage, rawPerPage string) (int, int, error) {
@@ -830,7 +856,12 @@ func relationSelects(db *gorm.DB, cr *CompiledRelation, target any, cols []Relat
return out
}
// Link inserts only currently eligible targets and never restamps existing rows.
// Link links eligible related records to the parent and never restamps
// existing links. On a belongsToMany it writes pivot rows (with the pivot
// form's values when the body carries a pivot object for one id); on a
// hasMany it sets each child's ForeignKey to the parent's key through the
// child model. Records already linked to this parent are skipped; any other
// id that is not a candidate fails the whole link with 422 on ids.
func (s RelationService) Link(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RelationMutationInput) (RelationMutationResult, error) {
ids, err := normalizeIDs(in.IDs)
if err != nil {
@@ -840,46 +871,170 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat
if err != nil {
return RelationMutationResult{}, err
}
if cr.hasMany() {
return RelationMutationResult{}, recordNotFound{}
if err := checkPivotInput(cr, ids, in.Pivot); err != nil {
return RelationMutationResult{}, err
}
var result RelationMutationResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
parent, err := newWritableModel(cc)
parent, err := s.loadParent(ctx, tx, cc, ownerID)
if err != nil {
return err
}
if err := loadRecord(ctx, tx, cc, parent, ownerID); err != nil {
result.Linked, err = s.linkRelated(ctx, tx, cc, cr, parent.model, ids, in.Pivot, "ids")
return err
}
})
return result, err
}
// linkRelated links ids to the saved parent inside tx (the shared link
// path of the link route and of the deferred commit). Eligibility is the
// candidate query: RelationExtendManageQuery, ExcludedRelatedIDs, not linked
// yet, and not a child created in a pending session. An ineligible id is a
// 422 on errField.
func (s RelationService) linkRelated(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent any, ids []uint, pivot map[string]any, errField string) (int, error) {
ownerPK := pkUint(parent)
pending, err := pendingRelationIDs(tx, cr, ownerPK, ids)
var pending []uint
var err error
if cr.hasMany() {
pending, err = pendingChildIDs(ctx, tx, cr, ownerPK, ids)
} else {
pending, err = pendingRelationIDs(tx, cr, ownerPK, ids)
}
if err != nil || len(pending) == 0 {
return err
return 0, err
}
q, target, err := relationBaseQuery(ctx, tx, cc, cr, parent, true)
if err != nil {
return err
return 0, err
}
q = q.Clauses(clause.Locking{Strength: "UPDATE"}).Where(clause.IN{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}, Values: uintValues(pending)})
holder := reflect.New(reflect.SliceOf(reflect.TypeOf(target).Elem()))
if err := q.Order(clause.OrderByColumn{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}}).Find(holder.Interface()).Error; err != nil {
return err
return 0, err
}
if holder.Elem().Len() != len(pending) {
return relationInvalid("ids", "contains an ineligible target")
return 0, relationInvalid(errField, "contains an ineligible target")
}
linked := 0
for i := 0; i < holder.Elem().Len(); i++ {
related := holder.Elem().Index(i).Addr().Interface()
if err := insertPivot(ctx, tx, cc, cr, parent, related, nil); err != nil {
return err
if cr.hasMany() {
if err := setModelColumn(related, cr.Contract.ForeignKey, ownerPK); err != nil {
return 0, lifecycleFailure(cc, err)
}
result.Linked++
if err := tx.WithContext(ctx).Save(related).Error; err != nil {
return 0, lifecycleFailure(cc, err)
}
linked++
continue
}
var row any
if pivot != nil {
row = cr.Contract.NewPivot()
if err := s.fillPivot(ctx, tx, cr, row, pivot); err != nil {
return 0, err
}
}
if err := insertPivot(ctx, tx, cc, cr, parent, related, row); err != nil {
return 0, err
}
linked++
}
return linked, nil
}
// pendingChildIDs drops the ids a hasMany parent already owns.
func pendingChildIDs(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, ownerID uint, ids []uint) ([]uint, error) {
target := cr.Contract.NewRelated()
var owned []uint
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(target).
Where(clause.Eq{Column: clause.Column{Name: cr.Contract.ForeignKey}, Value: ownerID}).
Where(clause.IN{Column: clause.Column{Name: primaryColumn(target)}, Values: uintValues(ids)}).
Pluck(primaryColumn(target), &owned).Error
if err != nil {
return nil, err
}
pending := make([]uint, 0, len(ids))
for _, id := range ids {
if !slices.Contains(owned, id) {
pending = append(pending, id)
}
}
return pending, nil
}
// checkPivotInput validates a link body's pivot object before any query
// (D-14): it needs a pivot form, exactly one id, and only pivot form keys.
func checkPivotInput(cr *CompiledRelation, ids []uint, pivot map[string]any) error {
if pivot == nil {
return nil
}
if cr.pivot == nil {
return relationInvalid("pivot", "This relation has no pivot form.")
}
if len(ids) != 1 {
return relationInvalid("ids", "A link with pivot values takes exactly one id.")
}
return checkPivotKeys(cr, pivot)
}
// checkPivotKeys refuses every key that is not a writable pivot form field.
func checkPivotKeys(cr *CompiledRelation, values map[string]any) error {
details := map[string]any{}
for key := range values {
if !slices.ContainsFunc(cr.pivot.Writable, func(f WritableField) bool { return f.Name == key }) {
details[key] = []string{"The " + key + " field is not a pivot field."}
}
}
if len(details) > 0 {
return &ValidationError{Details: details}
}
return nil
}
// fillPivot fills a pivot model from pivot form values (D-14): only the
// pivot form's writable fields are filled (the form is the whitelist, the
// pivot model needs no Fillable), then the form's required flags, the pivot
// model's rules for those fields and the datepicker bounds are checked.
// The pivot foreign keys, timestamps and hook columns are never in the form.
func (s RelationService) fillPivot(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, pivot any, values map[string]any) error {
if err := checkPivotKeys(cr, values); err != nil {
return err
}
form := cr.pivot
allowed := make([]string, 0, len(form.Writable))
for _, field := range form.Writable {
allowed = append(allowed, field.FillKey)
}
projected := ProjectWritableFields(form, values)
if err := lagoon.Fill(pivot, allowed, projected, false); err != nil {
var typed *lagoon.FillTypeError
if errors.As(err, &typed) {
return &ValidationError{Details: fillTypeDetails(typed.Key)}
}
return &CapabilityError{ControllerID: controllerID(form)}
}
rules := map[string]string{}
for key, rule := range mergedRules(form, pivot, "") {
if slices.Contains(allowed, key) {
rules[key] = rule
}
}
msgs, err := lagoon.Validate(ctx, tx, pivot, rules, valuesForRules(pivot, rules), nil)
if err != nil {
return &CapabilityError{ControllerID: controllerID(form)}
}
for field, extra := range dateBoundDetails(ctx, s.tr, form, pivot, "") {
if msgs == nil {
msgs = map[string][]string{}
}
msgs[field] = append(msgs[field], extra...)
}
if len(msgs) > 0 {
return &ValidationError{Details: validationDetails(msgs)}
}
return nil
})
return result, err
}
// insertPivot writes the pivot row linking related to the saved parent of a
@@ -913,50 +1068,92 @@ func insertPivot(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *C
return tx.WithContext(ctx).Create(pivot).Error
}
// Unlink deletes explicit pivot models so their lifecycle hooks run.
// Unlink detaches related records from the parent: on a belongsToMany it
// deletes this parent's pivot rows through the pivot model, on a hasMany it
// sets each owned child's ForeignKey to NULL through the child model. Ids
// that are not linked to this parent are ignored.
func (s RelationService) Unlink(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RelationMutationInput) (RelationMutationResult, error) {
ids, err := normalizeIDs(in.IDs)
if err != nil {
return RelationMutationResult{}, err
}
if in.Pivot != nil {
return RelationMutationResult{}, relationInvalid("pivot", "Unlink takes no pivot values.")
}
cr, err := relationOf(cc, relation)
if err != nil {
return RelationMutationResult{}, err
}
if cr.hasMany() {
return RelationMutationResult{}, recordNotFound{}
}
var result RelationMutationResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
parent, err := newWritableModel(cc)
parent, err := s.loadParent(ctx, tx, cc, ownerID)
if err != nil {
return err
}
if err := loadRecord(ctx, tx, cc, parent, ownerID); err != nil {
result.Removed, err = s.unlinkRelated(ctx, tx, cc, cr, parent.model, ids)
return err
}
proto := cr.Contract.NewPivot()
t := reflect.TypeOf(proto)
holder := reflect.New(reflect.SliceOf(t.Elem()))
q := tx.WithContext(ctx).Model(proto).Clauses(clause.Locking{Strength: "UPDATE"}).
Where(clause.Eq{Column: clause.Column{Name: cr.Contract.ParentForeignKey}, Value: pkUint(parent)}).
Where(clause.IN{Column: clause.Column{Name: cr.Contract.RelatedForeignKey}, Values: uintValues(ids)}).
Order(clause.OrderByColumn{Column: clause.Column{Name: cr.Contract.RelatedForeignKey}})
if err := q.Find(holder.Interface()).Error; err != nil {
return err
}
for i := 0; i < holder.Elem().Len(); i++ {
if err := tx.WithContext(ctx).Delete(holder.Elem().Index(i).Addr().Interface()).Error; err != nil {
return err
}
result.Removed++
}
return nil
})
return result, err
}
// unlinkRelated detaches ids from the saved parent inside tx (the shared
// unlink path of the unlink route and of the deferred commit).
func (s RelationService) unlinkRelated(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent any, ids []uint) (int, error) {
if cr.hasMany() {
target := cr.Contract.NewRelated()
holder := reflect.New(reflect.SliceOf(reflect.TypeOf(target).Elem()))
err := tx.WithContext(ctx).Model(target).Clauses(clause.Locking{Strength: "UPDATE"}).
Where(clause.Eq{Column: clause.Column{Name: cr.Contract.ForeignKey}, Value: pkUint(parent)}).
Where(clause.IN{Column: clause.Column{Name: primaryColumn(target)}, Values: uintValues(ids)}).
Order(clause.OrderByColumn{Column: clause.Column{Name: primaryColumn(target)}}).
Find(holder.Interface()).Error
if err != nil {
return 0, err
}
for i := 0; i < holder.Elem().Len(); i++ {
child := holder.Elem().Index(i).Addr().Interface()
if err := setModelColumn(child, cr.Contract.ForeignKey, nil); err != nil {
return 0, lifecycleFailure(cc, err)
}
if err := tx.WithContext(ctx).Save(child).Error; err != nil {
return 0, lifecycleFailure(cc, err)
}
}
return holder.Elem().Len(), nil
}
rows, err := lockPivotRows(ctx, tx, cr, pkUint(parent), ids)
if err != nil {
return 0, err
}
for _, row := range rows {
if err := tx.WithContext(ctx).Delete(row).Error; err != nil {
return 0, err
}
}
return len(rows), nil
}
// lockPivotRows loads this parent's pivot rows for the related ids FOR
// UPDATE, in related id order.
func lockPivotRows(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, ownerPK uint, ids []uint) ([]any, error) {
proto := cr.Contract.NewPivot()
holder := reflect.New(reflect.SliceOf(reflect.TypeOf(proto).Elem()))
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(proto).Clauses(clause.Locking{Strength: "UPDATE"}).
Where(clause.Eq{Column: clause.Column{Name: cr.Contract.ParentForeignKey}, Value: ownerPK}).
Where(clause.IN{Column: clause.Column{Name: cr.Contract.RelatedForeignKey}, Values: uintValues(ids)}).
Order(clause.OrderByColumn{Column: clause.Column{Name: cr.Contract.RelatedForeignKey}}).
Find(holder.Interface()).Error
if err != nil {
return nil, err
}
out := make([]any, holder.Elem().Len())
for i := range out {
out[i] = holder.Elem().Index(i).Addr().Interface()
}
return out, nil
}
func relationOf(cc *CompiledController, name string) (*CompiledRelation, error) {
if cc == nil || !identifier(name) || cc.Relations == nil || cc.Relations[name] == nil {
return nil, recordNotFound{}

View File

@@ -6,11 +6,14 @@ import (
"errors"
"io"
"net/http"
"strconv"
"strings"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// relationParent is the parent record of a relation route, loaded through
@@ -138,23 +141,306 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController
return result, nil
}
// loadChild finds one child of the parent with a single query that carries
// the parent predicate (D-15): on a hasMany the child's ForeignKey must be
// the parent's key, on a belongsToMany a pivot row must link the child to
// the parent. A child of another parent is recordNotFound (404, never 403).
// lock adds FOR UPDATE.
func loadChild(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, parent *relationParent, childID uint, lock bool) (any, error) {
if parent == nil || childID == 0 {
return nil, recordNotFound{}
}
child := cr.Contract.NewRelated()
table := tableName(child)
pk := clause.Column{Table: table, Name: primaryColumn(child)}
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(child).
Where(clause.Eq{Column: pk, Value: castPK(child, childID)})
if cr.hasMany() {
q = q.Where(clause.Eq{Column: clause.Column{Table: table, Name: cr.Contract.ForeignKey}, Value: parent.id})
} else {
linked := "EXISTS (SELECT 1 FROM " + quotedIdent(tx, tableName(cr.Contract.NewPivot())) + " p WHERE p." +
quotedIdent(tx, cr.Contract.ParentForeignKey) + " = ? AND p." + quotedIdent(tx, cr.Contract.RelatedForeignKey) +
" = " + quotedIdent(tx, table) + "." + quotedIdent(tx, primaryColumn(child)) + ")"
q = q.Where(linked, parent.id)
}
if lock {
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
}
if err := q.Take(child).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, recordNotFound{}
}
return nil, err
}
return child, nil
}
// childForm is the form a child is shown with: the manage form when the
// relation declares update, else the view form.
func (cr *CompiledRelation) childForm() *CompiledController {
if cr.allows("update") && cr.child != nil {
return cr.child
}
return cr.view
}
// ShowChild loads one child of the parent (D-15) and projects it through
// the manage form when the relation declares update, else the view form.
func (s RelationService) ShowChild(ctx context.Context, cc *CompiledController, relation string, ownerID, childID uint) (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
}
form := cr.childForm()
if form == nil {
return RecordResult{}, recordNotFound{}
}
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 := loadChild(ctx, tx, cr, parent, childID, false)
if err != nil {
return err
}
result, err = projectFullRecord(ctx, tx, form, child)
return err
})
if err != nil {
return RecordResult{}, err
}
return result, nil
}
// UpdateChild saves one child of the parent through the manage form (D-16):
// the child is loaded under the parent scope and locked, filled and
// validated like a controller update, and saved through its model between
// the optional pact.RelationBeforeUpdate and pact.RelationAfterUpdate hooks.
func (s RelationService) UpdateChild(ctx context.Context, cc *CompiledController, relation string, ownerID, childID 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 := loadChild(ctx, tx, cr, parent, childID, true)
if err != nil {
return err
}
if err := s.fillChild(ctx, tx, cr.child, child, in.Body, "update"); err != nil {
return err
}
if hook, ok := cc.Controller.(pact.RelationBeforeUpdate); ok && hook != nil {
if err := hook.RelationBeforeUpdate(ctx, cr.Contract.Name, parent.model, child); err != nil {
return lifecycleFailure(cc, err)
}
}
if err := tx.WithContext(ctx).Save(child).Error; err != nil {
return lifecycleFailure(cc, err)
}
if hook, ok := cc.Controller.(pact.RelationAfterUpdate); ok && hook != nil {
if err := hook.RelationAfterUpdate(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
}
// DeleteChildren deletes children of the parent (D-12). Every id must be a
// child of this parent, or the whole request is recordNotFound and nothing
// is deleted. Per child the optional pact.RelationBeforeDelete runs, then a
// hasMany child is deleted through its model (hooks and soft delete run),
// a belongsToMany record first loses this parent's pivot row and is then
// deleted through its model, then pact.RelationAfterDelete runs.
func (s RelationService) DeleteChildren(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in BulkDeleteInput) (BulkResult, error) {
if s.DB == nil {
return BulkResult{}, errors.New("cabana: database is not configured")
}
ids, err := normalizeIDs(in.IDs)
if err != nil {
return BulkResult{}, err
}
cr, err := relationOf(cc, relation)
if err != nil {
return BulkResult{}, err
}
var result BulkResult
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
}
children := make([]any, 0, len(ids))
for _, id := range ids {
child, err := loadChild(ctx, tx, cr, parent, id, true)
if err != nil {
return err
}
children = append(children, child)
}
for _, child := range children {
if err := s.deleteChild(ctx, tx, cc, cr, parent, child); err != nil {
return err
}
}
result.Deleted = len(children)
return nil
})
if err != nil {
return BulkResult{}, err
}
return result, nil
}
// deleteChild deletes one loaded child of the parent through its model.
func (s RelationService) deleteChild(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent *relationParent, child any) error {
if hook, ok := cc.Controller.(pact.RelationBeforeDelete); ok && hook != nil {
if err := hook.RelationBeforeDelete(ctx, cr.Contract.Name, parent.model, child); err != nil {
return lifecycleFailure(cc, err)
}
}
if !cr.hasMany() && parent.id > 0 {
rows, err := lockPivotRows(ctx, tx, cr, parent.id, []uint{pkUint(child)})
if err != nil {
return lifecycleFailure(cc, err)
}
for _, row := range rows {
if err := tx.WithContext(ctx).Delete(row).Error; err != nil {
return lifecycleFailure(cc, err)
}
}
}
if err := tx.WithContext(ctx).Delete(child).Error; err != nil {
return lifecycleFailure(cc, err)
}
if hook, ok := cc.Controller.(pact.RelationAfterDelete); ok && hook != nil {
if err := hook.RelationAfterDelete(ctx, cr.Contract.Name, parent.model, child); err != nil {
return lifecycleFailure(cc, err)
}
}
return nil
}
// loadPivotRow loads this parent's pivot row for the related id, locked; a
// missing row is recordNotFound.
func loadPivotRow(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, parent *relationParent, childID uint) (any, error) {
if childID == 0 {
return nil, recordNotFound{}
}
rows, err := lockPivotRows(ctx, tx, cr, parent.id, []uint{childID})
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, recordNotFound{}
}
return rows[0], nil
}
// ShowPivot returns the pivot form values of the link between the parent
// and one related record (D-14).
func (s RelationService) ShowPivot(ctx context.Context, cc *CompiledController, relation string, ownerID, childID uint) (map[string]any, error) {
if s.DB == nil {
return nil, errors.New("cabana: database is not configured")
}
cr, err := relationOf(cc, relation)
if err != nil {
return nil, err
}
if cr.pivot == nil {
return nil, recordNotFound{}
}
var data map[string]any
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
}
row, err := loadPivotRow(ctx, tx, cr, parent, childID)
if err != nil {
return err
}
data = pivotRecord(cr, row, childID)
return nil
})
return data, err
}
// UpdatePivot saves pivot form values on the link between the parent and
// one related record (D-14): only pivot form fields are accepted (an
// unknown key is a 422), and the pivot model is saved through GORM.
func (s RelationService) UpdatePivot(ctx context.Context, cc *CompiledController, relation string, ownerID, childID uint, values map[string]any) (map[string]any, error) {
if s.DB == nil {
return nil, errors.New("cabana: database is not configured")
}
cr, err := relationOf(cc, relation)
if err != nil {
return nil, err
}
if cr.pivot == nil {
return nil, recordNotFound{}
}
var data map[string]any
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
}
row, err := loadPivotRow(ctx, tx, cr, parent, childID)
if err != nil {
return err
}
if err := s.fillPivot(ctx, tx, cr, row, values); err != nil {
return err
}
if err := tx.WithContext(ctx).Save(row).Error; err != nil {
return lifecycleFailure(cc, err)
}
data = pivotRecord(cr, row, childID)
return nil
})
return data, err
}
// pivotRecord projects a pivot row through the pivot form, keyed by the
// related record's id.
func pivotRecord(cr *CompiledRelation, row any, childID uint) map[string]any {
data := projectRecord(cr.pivot, row)
data["id"] = childID
return data
}
// 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
return s.relationAllowed(w, r, cc, func(cr *CompiledRelation) bool { return cr.allows(button) })
}
// decodeCappedObject decodes a JSON object body capped at
@@ -221,3 +507,218 @@ func (s *service) relationChildCreate(w http.ResponseWriter, r *http.Request) {
WriteData(w, http.StatusCreated, rec.Data, rec.Meta)
})
}
// pathChildID parses {child}; anything but a positive integer is not found.
func pathChildID(r *http.Request) (uint, error) {
n, err := strconv.ParseUint(strings.TrimSpace(r.PathValue("child")), 10, 64)
if err != nil || n == 0 {
return 0, recordNotFound{}
}
return uint(n), nil
}
// relationAllowed resolves the route's relation and refuses (403) unless
// allowed reports the relation declares what the route needs. An unknown
// relation is 404. It writes the response and returns nil on refusal.
func (s *service) relationAllowed(w http.ResponseWriter, r *http.Request, cc *CompiledController, allowed func(*CompiledRelation) bool) *CompiledRelation {
cr, err := relationOf(cc, r.PathValue("name"))
if err != nil {
writeCRUDError(w, err)
return nil
}
if !allowed(cr) {
if principal, _ := bouncer.User(r.Context()); principal != nil {
s.logAuth(r, "denied", principal.ID)
}
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return nil
}
return cr
}
// relationIDs parses {id} and {child}.
func relationIDs(r *http.Request) (uint, uint, error) {
id, err := pathID(r)
if err != nil {
return 0, 0, err
}
child, err := pathChildID(r)
if err != nil {
return 0, 0, err
}
return id, child, nil
}
// relationChildShow serves GET .../{id}/relations/{name}/records/{child}:
// allowed when the relation declares update (with a manage form) or has a
// view form.
func (s *service) relationChildShow(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
cr := s.relationAllowed(w, r, cc, func(cr *CompiledRelation) bool { return cr.childForm() != nil })
if cr == nil {
return
}
id, child, err := relationIDs(r)
if err != nil {
writeCRUDError(w, err)
return
}
svc, err := s.relations()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
rec, err := svc.ShowChild(r.Context(), cc, cr.Contract.Name, id, child)
if err != nil {
writeRelationError(w, err)
return
}
WriteData(w, http.StatusOK, rec.Data, rec.Meta)
})
}
// relationChildUpdate serves PUT .../{id}/relations/{name}/records/{child}.
func (s *service) relationChildUpdate(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
cr := s.relationButton(w, r, cc, "update")
if cr == nil {
return
}
id, child, err := relationIDs(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.UpdateChild(r.Context(), cc, cr.Contract.Name, id, child, RecordInput{Body: body})
if err != nil {
writeRelationError(w, err)
return
}
WriteData(w, http.StatusOK, rec.Data, rec.Meta)
})
}
// relationChildDelete serves POST .../{id}/relations/{name}/delete with
// {ids}.
func (s *service) relationChildDelete(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
cr := s.relationButton(w, r, cc, "delete")
if cr == nil {
return
}
id, err := pathID(r)
if err != nil {
writeCRUDError(w, err)
return
}
var in BulkDeleteInput
if err := s.decodeStrictNumbers(w, r, &in); err != nil {
writeRelationError(w, err)
return
}
svc, err := s.relations()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
result, err := svc.DeleteChildren(r.Context(), cc, cr.Contract.Name, id, in)
if err != nil {
writeRelationError(w, err)
return
}
WriteData(w, http.StatusOK, result, nil)
})
}
// decodeStrictNumbers decodes a capped JSON body into dest with numbers
// kept as json.Number, unknown keys and trailing data refused.
func (s *service) decodeStrictNumbers(w http.ResponseWriter, r *http.Request, dest any) error {
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, s.jsonCap()))
dec.UseNumber()
dec.DisallowUnknownFields()
if err := dec.Decode(dest); err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
return err
}
return invalidBody()
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
return invalidBody()
}
return nil
}
// pivotAllowed: the pivot routes need a pivot form and the link or update
// button.
func pivotAllowed(cr *CompiledRelation) bool {
return cr.pivot != nil && (cr.allows("link") || cr.allows("update"))
}
// relationPivotShow serves GET .../{id}/relations/{name}/pivot/{child}.
func (s *service) relationPivotShow(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
cr := s.relationAllowed(w, r, cc, pivotAllowed)
if cr == nil {
return
}
id, child, err := relationIDs(r)
if err != nil {
writeCRUDError(w, err)
return
}
svc, err := s.relations()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
data, err := svc.ShowPivot(r.Context(), cc, cr.Contract.Name, id, child)
if err != nil {
writeRelationError(w, err)
return
}
WriteData(w, http.StatusOK, data, nil)
})
}
// relationPivotUpdate serves PUT .../{id}/relations/{name}/pivot/{child}.
func (s *service) relationPivotUpdate(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
cr := s.relationAllowed(w, r, cc, pivotAllowed)
if cr == nil {
return
}
id, child, err := relationIDs(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
}
data, err := svc.UpdatePivot(r.Context(), cc, cr.Contract.Name, id, child, body)
if err != nil {
writeRelationError(w, err)
return
}
WriteData(w, http.StatusOK, data, nil)
})
}

View File

@@ -4,7 +4,9 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
)
@@ -78,3 +80,154 @@ func TestRelationChildSmokeCreate(t *testing.T) {
t.Fatalf("create under a missing parent status=%d body=%s", missing.Code, missing.Body.String())
}
}
// relationPath is the admin API path of a gadget relation route.
func relationPath(gadget uint, relation, rest string) string {
return fmt.Sprintf("/acme/conform/gadgets/%d/relations/%s%s", gadget, relation, rest)
}
// expectStatus fails the test unless rec has the status.
func expectStatus(t *testing.T, what string, rec *httptest.ResponseRecorder, status int) {
t.Helper()
if rec.Code != status {
t.Fatalf("%s status=%d want %d body=%s", what, rec.Code, status, rec.Body.String())
}
}
// createPart creates a part under a gadget through the relation manager.
func (e *conformEnv) createPart(t *testing.T, gadget uint, label string) uint {
t.Helper()
rec := e.send(t, http.MethodPost, relationPath(gadget, "parts", "/records"), map[string]any{"label": label}, true)
expectStatus(t, "create part", rec, http.StatusCreated)
return dataID(t, rec.Body.Bytes())
}
// TestRelationChildSmokeScope checks D-15 on every child route: a child of
// another parent, a member linked to another parent and a parent hidden by
// FormExtendQuery all answer 404, and a delete naming one foreign child
// deletes nothing. It also walks hasMany link, unlink and delete.
func TestRelationChildSmokeScope(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, "")
pa := env.createPart(t, a, "pa")
pb := env.createPart(t, b, "pb")
expectStatus(t, "show foreign child", env.send(t, http.MethodGet, relationPath(a, "parts", fmt.Sprintf("/records/%d", pb)), nil, true), http.StatusNotFound)
expectStatus(t, "update foreign child", env.send(t, http.MethodPut, relationPath(a, "parts", fmt.Sprintf("/records/%d", pb)), map[string]any{"label": "stolen"}, true), http.StatusNotFound)
expectStatus(t, "delete foreign child", env.send(t, http.MethodPost, relationPath(a, "parts", "/delete"), map[string]any{"ids": []uint{pa, pb}}, true), http.StatusNotFound)
for _, id := range []uint{pa, pb} {
var n int64
if err := env.db.Model(&conformPart{}).Where("id = ?", id).Count(&n).Error; err != nil || n != 1 {
t.Fatalf("part %d count=%d err=%v after a refused delete", id, n, err)
}
}
shown := env.send(t, http.MethodGet, relationPath(a, "parts", fmt.Sprintf("/records/%d", pa)), nil, true)
expectStatus(t, "show own child", shown, http.StatusOK)
updated := env.send(t, http.MethodPut, relationPath(a, "parts", fmt.Sprintf("/records/%d", pa)), map[string]any{"label": "pa-renamed", "gadget_id": b}, true)
expectStatus(t, "update own child", updated, http.StatusOK)
if owner := env.partOwner(t, pa); owner == nil || *owner != a {
t.Fatalf("update moved the part to %v", owner)
}
// A member linked to B has no pivot row under A.
expectStatus(t, "link member to B", env.send(t, http.MethodPost, relationPath(b, "members", "/link"), map[string]any{"ids": []uint{env.memberID}}, true), http.StatusOK)
expectStatus(t, "pivot of B's member through A", env.send(t, http.MethodGet, relationPath(a, "members", fmt.Sprintf("/pivot/%d", env.memberID)), nil, true), http.StatusNotFound)
expectStatus(t, "pivot update of B's member through A", env.send(t, http.MethodPut, relationPath(a, "members", fmt.Sprintf("/pivot/%d", env.memberID)), map[string]any{"note": "x"}, true), http.StatusNotFound)
// A parent FormExtendQuery hides is 404 on every child route.
hidden := conformGadget{Name: "hidden-" + env.stamp}
if err := env.db.Create(&hidden).Error; err != nil {
t.Fatal(err)
}
ph := conformPart{GadgetID: &hidden.ID, Label: "ph"}
if err := env.db.Create(&ph).Error; err != nil {
t.Fatal(err)
}
expectStatus(t, "hidden parent linked list", env.send(t, http.MethodGet, relationPath(hidden.ID, "parts", ""), nil, true), http.StatusNotFound)
expectStatus(t, "hidden parent child", env.send(t, http.MethodGet, relationPath(hidden.ID, "parts", fmt.Sprintf("/records/%d", ph.ID)), nil, true), http.StatusNotFound)
expectStatus(t, "hidden parent create", env.send(t, http.MethodPost, relationPath(hidden.ID, "parts", "/records"), map[string]any{"label": "x"}, true), http.StatusNotFound)
// hasMany link adopts a free part, unlink frees it, delete removes it.
free := conformPart{Label: "free"}
if err := env.db.Create(&free).Error; err != nil {
t.Fatal(err)
}
candidates := env.send(t, http.MethodGet, relationPath(a, "parts", "/candidates"), nil, true)
expectStatus(t, "candidates", candidates, http.StatusOK)
if !strings.Contains(candidates.Body.String(), `"label":"free"`) || strings.Contains(candidates.Body.String(), `"label":"pb"`) {
t.Fatalf("hasMany candidates = %s", candidates.Body.String())
}
expectStatus(t, "link owned part of B", env.send(t, http.MethodPost, relationPath(a, "parts", "/link"), map[string]any{"ids": []uint{pb}}, true), http.StatusUnprocessableEntity)
expectStatus(t, "link free part", env.send(t, http.MethodPost, relationPath(a, "parts", "/link"), map[string]any{"ids": []uint{free.ID}}, true), http.StatusOK)
if owner := env.partOwner(t, free.ID); owner == nil || *owner != a {
t.Fatalf("linked part owner = %v, want %d", owner, a)
}
expectStatus(t, "unlink part", env.send(t, http.MethodPost, relationPath(a, "parts", "/unlink"), map[string]any{"ids": []uint{free.ID, pb}}, true), http.StatusOK)
if owner := env.partOwner(t, free.ID); owner != nil {
t.Fatalf("unlinked part owner = %d", *owner)
}
if owner := env.partOwner(t, pb); owner == nil || *owner != b {
t.Fatalf("unlink through A freed B's part: %v", owner)
}
expectStatus(t, "delete own child", env.send(t, http.MethodPost, relationPath(a, "parts", "/delete"), map[string]any{"ids": []uint{pa}}, true), http.StatusOK)
var n int64
if err := env.db.Model(&conformPart{}).Where("id = ?", pa).Count(&n).Error; err != nil || n != 0 {
t.Fatalf("deleted part count=%d err=%v", n, err)
}
}
// TestRelationChildSmokePivot links a member with a pivot note (D-14): the
// note is stored, RelationBeforeLink still stamps its hook column, and pivot
// keys outside the pivot form are refused on link and on the pivot route.
func TestRelationChildSmokePivot(t *testing.T) {
env := newConformEnv(t)
env.loginAs(t, env.login)
g := env.createGadget(t, "g-"+env.stamp, "")
second := conformMember{Email: "second-" + env.stamp + "@example.test"}
if err := env.db.Create(&second).Error; err != nil {
t.Fatal(err)
}
for name, body := range map[string]map[string]any{
"foreign key": {"ids": []uint{env.memberID}, "pivot": map[string]any{"gadget_id": 99}},
"hook column": {"ids": []uint{env.memberID}, "pivot": map[string]any{"stamp": "forged"}},
"two ids": {"ids": []uint{env.memberID, second.ID}, "pivot": map[string]any{"note": "x"}},
"hasMany link": nil,
} {
if body == nil {
rec := env.send(t, http.MethodPost, relationPath(g, "parts", "/link"), map[string]any{"ids": []uint{1}, "pivot": map[string]any{"note": "x"}}, true)
expectStatus(t, name, rec, http.StatusUnprocessableEntity)
continue
}
expectStatus(t, name, env.send(t, http.MethodPost, relationPath(g, "members", "/link"), body, true), http.StatusUnprocessableEntity)
}
var count int64
if err := env.db.Model(&conformGadgetMember{}).Where("gadget_id = ?", g).Count(&count).Error; err != nil || count != 0 {
t.Fatalf("refused links wrote %d pivot rows (%v)", count, err)
}
expectStatus(t, "link with note", env.send(t, http.MethodPost, relationPath(g, "members", "/link"), map[string]any{"ids": []uint{env.memberID}, "pivot": map[string]any{"note": "hello"}}, true), http.StatusOK)
var row conformGadgetMember
if err := env.db.Where("gadget_id = ? AND member_id = ?", g, env.memberID).Take(&row).Error; err != nil {
t.Fatal(err)
}
if row.Note != "hello" || row.Stamp != "linked" {
t.Fatalf("pivot row = %+v, want note hello and stamp linked", row)
}
shown := env.send(t, http.MethodGet, relationPath(g, "members", fmt.Sprintf("/pivot/%d", env.memberID)), nil, true)
expectStatus(t, "pivot show", shown, http.StatusOK)
if !strings.Contains(shown.Body.String(), `"note":"hello"`) || strings.Contains(shown.Body.String(), "stamp") {
t.Fatalf("pivot show = %s", shown.Body.String())
}
expectStatus(t, "pivot update with a foreign key", env.send(t, http.MethodPut, relationPath(g, "members", fmt.Sprintf("/pivot/%d", env.memberID)), map[string]any{"member_id": second.ID}, true), http.StatusUnprocessableEntity)
expectStatus(t, "pivot update", env.send(t, http.MethodPut, relationPath(g, "members", fmt.Sprintf("/pivot/%d", env.memberID)), map[string]any{"note": "changed"}, true), http.StatusOK)
if err := env.db.Where("gadget_id = ? AND member_id = ?", g, env.memberID).Take(&row).Error; err != nil {
t.Fatal(err)
}
if row.Note != "changed" || row.Stamp != "linked" || row.MemberID != env.memberID {
t.Fatalf("pivot row after update = %+v", row)
}
}

View File

@@ -63,6 +63,11 @@ var phase09Routes = []adminRoute{
{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}/records"},
{key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}"},
{key: "PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}"},
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete"},
{key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}"},
{key: "PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}"},
{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}/reorder"},
@@ -295,6 +300,11 @@ func phase09ProtectedCalls() []phase09Call {
{"relation-link", (*service).relationLink},
{"relation-unlink", (*service).relationUnlink},
{"relation-child-create", (*service).relationChildCreate},
{"relation-child-show", (*service).relationChildShow},
{"relation-child-update", (*service).relationChildUpdate},
{"relation-child-delete", (*service).relationChildDelete},
{"relation-pivot-show", (*service).relationPivotShow},
{"relation-pivot-update", (*service).relationPivotUpdate},
{"file-list", (*service).fileList},
{"file-upload", (*service).fileUpload},
{"file-reorder", (*service).fileReorder},