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:
@@ -137,6 +137,24 @@
|
|||||||
"additionalProperties": {},
|
"additionalProperties": {},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"cabana.AdminRelationLinkRequest": {
|
||||||
|
"properties": {
|
||||||
|
"ids": {
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"pivot": {
|
||||||
|
"additionalProperties": {},
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"ids"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.AdminRoleSummary": {
|
"cabana.AdminRoleSummary": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"code": {
|
"code": {
|
||||||
@@ -334,6 +352,21 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
"cabana.Envelope-cabana_BulkResult": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -5082,8 +5115,9 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link": {
|
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete": {
|
||||||
"post": {
|
"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": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"description": "Vendor",
|
"description": "Vendor",
|
||||||
@@ -5142,6 +5176,139 @@
|
|||||||
"description": "Related record ids",
|
"description": "Related record ids",
|
||||||
"required": true
|
"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": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"content": {
|
"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": {
|
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records": {
|
||||||
"post": {
|
"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.",
|
"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": {
|
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink": {
|
||||||
"post": {
|
"post": {
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
|||||||
462
admin/src/api/schema.d.ts
vendored
462
admin/src/api/schema.d.ts
vendored
@@ -2540,6 +2540,106 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2568,10 +2668,10 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
/** @description Related record ids */
|
/** @description Related record ids and optional pivot form values */
|
||||||
requestBody: {
|
requestBody: {
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["cabana.AdminIDsRequest"];
|
"application/json": components["schemas"]["cabana.AdminRelationLinkRequest"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
@@ -2628,6 +2728,180 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2728,6 +3002,180 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2864,6 +3312,12 @@ export interface components {
|
|||||||
"cabana.AdminRecord": {
|
"cabana.AdminRecord": {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
"cabana.AdminRelationLinkRequest": {
|
||||||
|
ids: number[];
|
||||||
|
pivot?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
"cabana.AdminRoleSummary": {
|
"cabana.AdminRoleSummary": {
|
||||||
code: string;
|
code: string;
|
||||||
id: number;
|
id: number;
|
||||||
@@ -2912,6 +3366,10 @@ export interface components {
|
|||||||
data: components["schemas"]["cabana.AdminProfile"];
|
data: components["schemas"]["cabana.AdminProfile"];
|
||||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
};
|
};
|
||||||
|
"cabana.Envelope-cabana_AdminRecord": {
|
||||||
|
data: components["schemas"]["cabana.AdminRecord"];
|
||||||
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
|
};
|
||||||
"cabana.Envelope-cabana_BulkResult": {
|
"cabana.Envelope-cabana_BulkResult": {
|
||||||
data: components["schemas"]["cabana.BulkResult"];
|
data: components["schemas"]["cabana.BulkResult"];
|
||||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
|
|||||||
@@ -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.
|
`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
|
### Messages
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
|
|||||||
- Boot-time schema compilation: `cabana.CompileList` and `cabana.CompileForm` read a controller's YAML from the plugin's embedded tree, check that `modelClass` matches the controller's model name, and cache a locale-neutral schema. Each request gets a translated copy (`cabana.ListSchema.Localize`, `cabana.FormSchema.Localize`, `cabana.RelationSchema.Localize`) through [phrasebook](../phrasebook/README.md), with CLDR plural forms for the SPA's messages.
|
- Boot-time schema compilation: `cabana.CompileList` and `cabana.CompileForm` read a controller's YAML from the plugin's embedded tree, check that `modelClass` matches the controller's model name, and cache a locale-neutral schema. Each request gets a translated copy (`cabana.ListSchema.Localize`, `cabana.FormSchema.Localize`, `cabana.RelationSchema.Localize`) through [phrasebook](../phrasebook/README.md), with CLDR plural forms for the SPA's messages.
|
||||||
- Generic CRUD with `cabana.CRUDService`: list, show, create, update, delete and bulk delete. Writes run in transactions, and reads and writes are scoped by the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` hooks. `cabana.ExecuteList` applies search, sort, filters and pagination only on columns declared in the schema, so request parameters never reach SQL directly.
|
- Generic CRUD with `cabana.CRUDService`: list, show, create, update, delete and bulk delete. Writes run in transactions, and reads and writes are scoped by the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` hooks. `cabana.ExecuteList` applies search, sort, filters and pagination only on columns declared in the schema, so request parameters never reach SQL directly.
|
||||||
- Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies. Values are filled and validated through [lagoon](../lagoon/README.md); a value that does not fit its column (a `lagoon.FillTypeError`, such as a fraction for an integer field) is a 422 `validation_failed` on that field, and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write.
|
- Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies. Values are filled and validated through [lagoon](../lagoon/README.md); a value that does not fit its column (a `lagoon.FillTypeError`, such as a fraction for an integer field) is a 422 `validation_failed` on that field, and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write.
|
||||||
- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates, 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.
|
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Like the list schema's `toolbarActions`, the form schema carries a widget field only when the requesting administrator may run its action. The field's `context` applies to the action route as it does on save: a request without `record_id` is the create form's and one with it the update form's, and a widget its context hides on that form answers 404. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot.
|
||||||
- Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file.
|
- Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file.
|
||||||
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
|
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
|
||||||
@@ -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 `/{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 `.../fields/{field}/options`, GET `.../filters/{scope}/options` | Choices for a relation field and for a model-backed list filter. |
|
||||||
| GET `.../{id}/relations/{name}`, GET `.../{id}/relations/{name}/candidates` | Linked records and link candidates of a relation manager. |
|
| GET `.../{id}/relations/{name}`, GET `.../{id}/relations/{name}/candidates` | Linked records and link candidates of a relation manager. |
|
||||||
| POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. |
|
| POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. 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). |
|
| 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`. |
|
| GET `.../{id}/files/{field}` | The files of a `type: fileupload` field: attached files minus the session's pending removals, plus its pending uploads, in `sort_order`. `{id}` 0 is the record being created and needs `X-Session-Key`. |
|
||||||
| POST `.../{id}/files/{field}` | Upload one multipart `file_data` part; it is bound to the `X-Session-Key` session (required) and attached by the record's next save. Answers 201 with the pending `cabana.FileItem`. |
|
| POST `.../{id}/files/{field}` | Upload one multipart `file_data` part; it is bound to the `X-Session-Key` session (required) and attached by the record's next save. Answers 201 with the pending `cabana.FileItem`. |
|
||||||
| PUT `.../{id}/files/{field}/{file}` | Save a file's `title` and `description` at once; the field must declare `useCaption` (403 otherwise). |
|
| PUT `.../{id}/files/{field}/{file}` | Save a file's `title` and `description` at once; the field must declare `useCaption` (403 otherwise). |
|
||||||
@@ -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). |
|
| 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. |
|
| 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.
|
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.
|
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.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. |
|
||||||
| `cabana.AdminRelationContractProvider` / `cabana.RelationContract` | Controller-supplied bindings for relation managers. |
|
| `cabana.AdminRelationContractProvider` / `cabana.RelationContract` | Controller-supplied bindings for relation managers. |
|
||||||
| `cabana.RelationBelongsToMany` / `cabana.RelationHasMany` | The two `RelationContract.Kind` values; an empty `Kind` is a belongsToMany. |
|
| `cabana.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.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. |
|
||||||
| `cabana.Allows` | Checks a principal against required permission codes. |
|
| `cabana.Allows` | Checks a principal against required permission codes. |
|
||||||
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
|
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
|
||||||
|
|||||||
@@ -67,11 +67,19 @@ type ListEnvelope[T any] struct {
|
|||||||
type AdminRecord map[string]any
|
type AdminRecord map[string]any
|
||||||
|
|
||||||
// AdminIDsRequest is the body of the id-list writes: bulk delete, relation
|
// AdminIDsRequest is the body of the id-list writes: bulk delete, relation
|
||||||
// link and relation unlink.
|
// unlink and relation child delete.
|
||||||
type AdminIDsRequest struct {
|
type AdminIDsRequest struct {
|
||||||
IDs []uint64 `json:"ids"`
|
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
|
// AdminLoginRequest is the admin login body. Either login or email
|
||||||
// identifies the backend user.
|
// identifies the backend user.
|
||||||
type AdminLoginRequest struct {
|
type AdminLoginRequest struct {
|
||||||
@@ -582,7 +590,7 @@ func AdminRelationCandidates() {}
|
|||||||
// @Param controller path string true "Controller"
|
// @Param controller path string true "Controller"
|
||||||
// @Param id path integer true "Owner id"
|
// @Param id path integer true "Owner id"
|
||||||
// @Param name path string true "Relation name"
|
// @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]
|
// @Success 200 {object} Envelope[RelationMutationResult]
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
@@ -635,6 +643,119 @@ func AdminRelationUnlink() {}
|
|||||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records [post]
|
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records [post]
|
||||||
func AdminRelationChildCreate() {}
|
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
|
// FileMutationResult is the payload of a file removal: the number of files
|
||||||
// removed (always 1 on success).
|
// removed (always 1 on success).
|
||||||
type FileMutationResult struct {
|
type FileMutationResult struct {
|
||||||
|
|||||||
@@ -275,10 +275,22 @@ func (s *service) mount(r pact.Router) {
|
|||||||
constrainRelation(g)
|
constrainRelation(g)
|
||||||
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", requireAjax(s.relationUnlink))
|
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", requireAjax(s.relationUnlink))
|
||||||
constrainRelation(g)
|
constrainRelation(g)
|
||||||
// Relation child routes (D-11, D-12): create through the relation's
|
// Relation child routes (D-11, D-12, D-15): create, show, update and
|
||||||
// manage form.
|
// 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))
|
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records", requireAjax(s.relationChildCreate))
|
||||||
constrainRelation(g)
|
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
|
// File routes of `type: fileupload` fields (D-09). {id} 0 is the
|
||||||
// record being created in the X-Session-Key session.
|
// record being created in the X-Session-Key session.
|
||||||
g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload))
|
g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload))
|
||||||
@@ -318,6 +330,11 @@ func constrainRelation(g pact.Router) {
|
|||||||
g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")
|
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) {
|
func constrainFile(g pact.Router) {
|
||||||
constrainController(g)
|
constrainController(g)
|
||||||
g.Where("field", "[A-Za-z_][A-Za-z0-9_]*")
|
g.Where("field", "[A-Za-z_][A-Za-z0-9_]*")
|
||||||
|
|||||||
@@ -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 {
|
{"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)
|
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},
|
}, 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 {
|
{"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)
|
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members", e.gadgetID), nil, true)
|
||||||
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]](), nil},
|
}, 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)
|
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/unlink", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true)
|
||||||
}, into[cabana.Envelope[cabana.RelationMutationResult]](), nil},
|
}, into[cabana.Envelope[cabana.RelationMutationResult]](), nil},
|
||||||
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records", 201, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
{"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},
|
}, 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 {
|
{"POST /{vendor}/{plugin}/{controller}/toolbar/{action}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||||
return e.send(t, http.MethodPost, "/acme/conform/gadgets/toolbar/recount", map[string]any{}, true)
|
return e.send(t, http.MethodPost, "/acme/conform/gadgets/toolbar/recount", map[string]any{}, true)
|
||||||
}, into[cabana.Envelope[cabana.AdminActionResult]](), nil},
|
}, into[cabana.Envelope[cabana.AdminActionResult]](), nil},
|
||||||
@@ -344,6 +361,7 @@ type conformEnv struct {
|
|||||||
groupID uint
|
groupID uint
|
||||||
memberID uint
|
memberID uint
|
||||||
gadgetID uint
|
gadgetID uint
|
||||||
|
partID uint
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendWith sends a raw body with extra headers through the assembled router.
|
// sendWith sends a raw body with extra headers through the assembled router.
|
||||||
@@ -583,9 +601,12 @@ type conformMember struct {
|
|||||||
func (conformMember) TableName() string { return "cabana_conform_members" }
|
func (conformMember) TableName() string { return "cabana_conform_members" }
|
||||||
|
|
||||||
type conformGadgetMember struct {
|
type conformGadgetMember struct {
|
||||||
ID uint `gorm:"column:id;primaryKey"`
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
GadgetID uint `gorm:"column:gadget_id"`
|
GadgetID uint `gorm:"column:gadget_id"`
|
||||||
MemberID uint `gorm:"column:member_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" }
|
func (conformGadgetMember) TableName() string { return "cabana_conform_gadget_members" }
|
||||||
@@ -648,11 +669,27 @@ func (conformController) AdminRelationContracts() []cabana.RelationContract {
|
|||||||
return []cabana.RelationContract{{
|
return []cabana.RelationContract{{
|
||||||
Name: "members", NewRelated: func() any { return &conformMember{} }, NewPivot: func() any { return &conformGadgetMember{} },
|
Name: "members", NewRelated: func() any { return &conformMember{} }, NewPivot: func() any { return &conformGadgetMember{} },
|
||||||
ParentForeignKey: "gadget_id", RelatedForeignKey: "member_id", Columns: map[string]string{"email": "email"},
|
ParentForeignKey: "gadget_id", RelatedForeignKey: "member_id", Columns: map[string]string{"email": "email"},
|
||||||
|
HookPivotColumns: []string{"stamp"},
|
||||||
}, {
|
}, {
|
||||||
Name: "parts", Kind: cabana.RelationHasMany, NewRelated: func() any { return &conformPart{} },
|
Name: "parts", Kind: cabana.RelationHasMany, NewRelated: func() any { return &conformPart{} },
|
||||||
ForeignKey: "gadget_id", Columns: map[string]string{"label": "label"},
|
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 {
|
func (conformController) AdminFieldRelations() []cabana.FieldRelationContract {
|
||||||
return []cabana.FieldRelationContract{{Field: "group", Kind: "belongsTo", NewRelated: func() any { return &conformGroup{} }, ForeignKey: "group_id"}}
|
return []cabana.FieldRelationContract{{Field: "group", Kind: "belongsTo", NewRelated: func() any { return &conformGroup{} }, ForeignKey: "group_id"}}
|
||||||
}
|
}
|
||||||
@@ -769,6 +806,8 @@ update:
|
|||||||
email:
|
email:
|
||||||
label: Email
|
label: Email
|
||||||
showSearch: true
|
showSearch: true
|
||||||
|
pivot:
|
||||||
|
form: models/member/pivot_fields.yaml
|
||||||
parts:
|
parts:
|
||||||
label: Parts
|
label: Parts
|
||||||
view:
|
view:
|
||||||
@@ -776,7 +815,7 @@ parts:
|
|||||||
columns:
|
columns:
|
||||||
label:
|
label:
|
||||||
label: Label
|
label: Label
|
||||||
toolbarButtons: create
|
toolbarButtons: create|update|delete|link|unlink
|
||||||
manage:
|
manage:
|
||||||
form: $/acme/conform/models/part/fields.yaml
|
form: $/acme/conform/models/part/fields.yaml
|
||||||
list:
|
list:
|
||||||
@@ -852,6 +891,11 @@ parts:
|
|||||||
fileTypes: [pdf, png, svg, txt]
|
fileTypes: [pdf, png, svg, txt]
|
||||||
useCaption: true
|
useCaption: true
|
||||||
context: update
|
context: update
|
||||||
|
`),
|
||||||
|
"models/member/pivot_fields.yaml": file(`fields:
|
||||||
|
pivot[note]:
|
||||||
|
label: Note
|
||||||
|
type: text
|
||||||
`),
|
`),
|
||||||
"models/part/fields.yaml": file(`fields:
|
"models/part/fields.yaml": file(`fields:
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -99,15 +99,18 @@ func TestPhase10Coverage(t *testing.T) {
|
|||||||
"POST /{vendor}/{plugin}/{controller}/widgets/{field}",
|
"POST /{vendor}/{plugin}/{controller}/widgets/{field}",
|
||||||
"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}",
|
"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}",
|
||||||
"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder",
|
"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder",
|
||||||
|
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete",
|
||||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link",
|
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link",
|
||||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records",
|
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records",
|
||||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink",
|
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink",
|
||||||
"PUT /settings/{code}",
|
"PUT /settings/{code}",
|
||||||
"PUT /{vendor}/{plugin}/{controller}/{id}",
|
"PUT /{vendor}/{plugin}/{controller}/{id}",
|
||||||
"PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}",
|
"PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}",
|
||||||
|
"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") {
|
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
|
// The routes added in Phase 10 are safe reads: GET /lang and the shared
|
||||||
// nested pattern serving field options, filter options and relation lists.
|
// nested pattern serving field options, filter options and relation lists.
|
||||||
|
|||||||
@@ -68,9 +68,10 @@ func TestPhase10CSRF(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// refresh, logout, settings put, create, bulk-delete, widget action,
|
// refresh, logout, settings put, create, bulk-delete, widget action,
|
||||||
// toolbar action, update, delete, link, unlink, file upload, file
|
// toolbar action, update, delete, link, unlink, file upload, file
|
||||||
// reorder, file caption, file remove, relation child create
|
// reorder, file caption, file remove, relation child create, update
|
||||||
if unsafe != 16 {
|
// and delete, pivot update
|
||||||
t.Fatalf("walked %d state-changing routes, want 16: %v", unsafe, router.order)
|
if unsafe != 19 {
|
||||||
|
t.Fatalf("walked %d state-changing routes, want 19: %v", unsafe, router.order)
|
||||||
}
|
}
|
||||||
|
|
||||||
loginHandler := router.handlers[login]
|
loginHandler := router.handlers[login]
|
||||||
|
|||||||
@@ -164,6 +164,10 @@ type RelationResult struct {
|
|||||||
// RelationMutationInput is the only accepted relation write payload.
|
// RelationMutationInput is the only accepted relation write payload.
|
||||||
type RelationMutationInput struct {
|
type RelationMutationInput struct {
|
||||||
IDs []any `json:"ids"`
|
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.
|
// 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) + ")"
|
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)
|
q = q.Where(notExists, ownerPK)
|
||||||
|
var err error
|
||||||
|
if q, err = excludePendingCreated(tx, q, target); err != nil {
|
||||||
|
return nil, nil, lifecycleFailure(cc, err)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
join := "JOIN " + quotedIdent(tx, pivotTable) + " p ON p." + quotedIdent(tx, cr.Contract.RelatedForeignKey) + " = " + quotedIdent(tx, targetTable) + "." + quotedIdent(tx, pk)
|
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)
|
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 = 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
|
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
|
// normalizeRelationPage applies the Phase 9 relation paging limits: page is
|
||||||
// a positive integer (default 1), per_page is 1..100 (default 20).
|
// a positive integer (default 1), per_page is 1..100 (default 20).
|
||||||
func normalizeRelationPage(rawPage, rawPerPage string) (int, int, error) {
|
func normalizeRelationPage(rawPage, rawPerPage string) (int, int, error) {
|
||||||
@@ -830,7 +856,12 @@ func relationSelects(db *gorm.DB, cr *CompiledRelation, target any, cols []Relat
|
|||||||
return out
|
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) {
|
func (s RelationService) Link(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RelationMutationInput) (RelationMutationResult, error) {
|
||||||
ids, err := normalizeIDs(in.IDs)
|
ids, err := normalizeIDs(in.IDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -840,48 +871,172 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return RelationMutationResult{}, err
|
return RelationMutationResult{}, err
|
||||||
}
|
}
|
||||||
if cr.hasMany() {
|
if err := checkPivotInput(cr, ids, in.Pivot); err != nil {
|
||||||
return RelationMutationResult{}, recordNotFound{}
|
return RelationMutationResult{}, err
|
||||||
}
|
}
|
||||||
var result RelationMutationResult
|
var result RelationMutationResult
|
||||||
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
ctx = withTx(ctx, tx)
|
ctx = withTx(ctx, tx)
|
||||||
parent, err := newWritableModel(cc)
|
parent, err := s.loadParent(ctx, tx, cc, ownerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 err
|
||||||
}
|
|
||||||
ownerPK := pkUint(parent)
|
|
||||||
pending, err := pendingRelationIDs(tx, cr, ownerPK, ids)
|
|
||||||
if err != nil || len(pending) == 0 {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
q, target, err := relationBaseQuery(ctx, tx, cc, cr, parent, true)
|
|
||||||
if err != nil {
|
|
||||||
return 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
|
|
||||||
}
|
|
||||||
if holder.Elem().Len() != len(pending) {
|
|
||||||
return relationInvalid("ids", "contains an ineligible target")
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
result.Linked++
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
return result, 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)
|
||||||
|
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 0, err
|
||||||
|
}
|
||||||
|
q, target, err := relationBaseQuery(ctx, tx, cc, cr, parent, true)
|
||||||
|
if err != nil {
|
||||||
|
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 0, err
|
||||||
|
}
|
||||||
|
if holder.Elem().Len() != len(pending) {
|
||||||
|
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 cr.hasMany() {
|
||||||
|
if err := setModelColumn(related, cr.Contract.ForeignKey, ownerPK); err != nil {
|
||||||
|
return 0, lifecycleFailure(cc, err)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// insertPivot writes the pivot row linking related to the saved parent of a
|
// insertPivot writes the pivot row linking related to the saved parent of a
|
||||||
// belongsToMany relation. A filled pivot model (from the pivot form) may be
|
// belongsToMany relation. A filled pivot model (from the pivot form) may be
|
||||||
// passed; RelationBeforeLink then stamps only its HookPivotColumns, and the
|
// passed; RelationBeforeLink then stamps only its HookPivotColumns, and the
|
||||||
@@ -913,50 +1068,92 @@ func insertPivot(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *C
|
|||||||
return tx.WithContext(ctx).Create(pivot).Error
|
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) {
|
func (s RelationService) Unlink(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RelationMutationInput) (RelationMutationResult, error) {
|
||||||
ids, err := normalizeIDs(in.IDs)
|
ids, err := normalizeIDs(in.IDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RelationMutationResult{}, err
|
return RelationMutationResult{}, err
|
||||||
}
|
}
|
||||||
|
if in.Pivot != nil {
|
||||||
|
return RelationMutationResult{}, relationInvalid("pivot", "Unlink takes no pivot values.")
|
||||||
|
}
|
||||||
cr, err := relationOf(cc, relation)
|
cr, err := relationOf(cc, relation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RelationMutationResult{}, err
|
return RelationMutationResult{}, err
|
||||||
}
|
}
|
||||||
if cr.hasMany() {
|
|
||||||
return RelationMutationResult{}, recordNotFound{}
|
|
||||||
}
|
|
||||||
var result RelationMutationResult
|
var result RelationMutationResult
|
||||||
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
ctx = withTx(ctx, tx)
|
ctx = withTx(ctx, tx)
|
||||||
parent, err := newWritableModel(cc)
|
parent, err := s.loadParent(ctx, tx, cc, ownerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
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
|
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) {
|
func relationOf(cc *CompiledController, name string) (*CompiledRelation, error) {
|
||||||
if cc == nil || !identifier(name) || cc.Relations == nil || cc.Relations[name] == nil {
|
if cc == nil || !identifier(name) || cc.Relations == nil || cc.Relations[name] == nil {
|
||||||
return nil, recordNotFound{}
|
return nil, recordNotFound{}
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
"git.golem15.com/golem15/summercms/modules/pact"
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
// relationParent is the parent record of a relation route, loaded through
|
// 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
|
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
|
// relationButton resolves the route's relation and refuses (403) a route
|
||||||
// whose toolbar button the view panel does not declare. An unknown relation
|
// whose toolbar button the view panel does not declare. An unknown relation
|
||||||
// is 404. It writes the response and returns nil on refusal.
|
// 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 {
|
func (s *service) relationButton(w http.ResponseWriter, r *http.Request, cc *CompiledController, button string) *CompiledRelation {
|
||||||
cr, err := relationOf(cc, r.PathValue("name"))
|
return s.relationAllowed(w, r, cc, func(cr *CompiledRelation) bool { return cr.allows(button) })
|
||||||
if err != nil {
|
|
||||||
writeCRUDError(w, err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !cr.allows(button) {
|
|
||||||
if principal, _ := bouncer.User(r.Context()); principal != nil {
|
|
||||||
s.logAuth(r, "denied", principal.ID)
|
|
||||||
}
|
|
||||||
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return cr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodeCappedObject decodes a JSON object body capped at
|
// 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)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"testing"
|
"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())
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ var phase09Routes = []adminRoute{
|
|||||||
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link"},
|
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link"},
|
||||||
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink"},
|
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink"},
|
||||||
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records"},
|
{key: "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: "GET /{vendor}/{plugin}/{controller}/{id}/files/{field}", mounted: nestedGetRoute},
|
||||||
{key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}"},
|
{key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}"},
|
||||||
{key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder"},
|
{key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder"},
|
||||||
@@ -295,6 +300,11 @@ func phase09ProtectedCalls() []phase09Call {
|
|||||||
{"relation-link", (*service).relationLink},
|
{"relation-link", (*service).relationLink},
|
||||||
{"relation-unlink", (*service).relationUnlink},
|
{"relation-unlink", (*service).relationUnlink},
|
||||||
{"relation-child-create", (*service).relationChildCreate},
|
{"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-list", (*service).fileList},
|
||||||
{"file-upload", (*service).fileUpload},
|
{"file-upload", (*service).fileUpload},
|
||||||
{"file-reorder", (*service).fileReorder},
|
{"file-reorder", (*service).fileReorder},
|
||||||
|
|||||||
Reference in New Issue
Block a user