feat(10-02): relation field options and relation saves with labels
- FieldRelationContract/FieldRelationProvider bind every type: relation
field to a belongsTo foreign key or a belongsToMany pivot; activation
fails naming plugin, controller and field on a missing or broken contract
- GET /{vendor}/{plugin}/{controller}/fields/{field}/options serves
{value, label} pages scoped by pact.RelationExtendOptionsQuery, behind
the controller permission; read-only and non-relation fields are 404
- Saves apply present relation keys after the Before hook: ids are
revalidated through the same scoped query (422 and full rollback
otherwise), belongsTo sets the foreign key, belongsToMany replaces pivot
rows in submitted order with the order column set to the index
- Show, create and update return relation values in data and meta.labels
- A belongsTo on a protected fill key is read-only (D-26)
- One six-segment GET pattern dispatches relation lists and field options,
which ServeMux cannot register side by side
- Admin OpenAPI documents the options route and RecordEnvelope
This commit is contained in:
@@ -69,6 +69,10 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminRecord": {
|
||||
"additionalProperties": {},
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminRoleSummary": {
|
||||
"properties": {
|
||||
"code": {
|
||||
@@ -273,6 +277,24 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.ListEnvelope-array_cabana_RelationOption": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/cabana.RelationOption"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/cabana.ListMeta"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"meta"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.ListFilter": {
|
||||
"properties": {
|
||||
"column": {
|
||||
@@ -481,6 +503,53 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.RecordEnvelope": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/cabana.AdminRecord"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/cabana.RecordMeta"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"meta"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.RecordMeta": {
|
||||
"properties": {
|
||||
"labels": {
|
||||
"additionalProperties": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/cabana.RelationOption"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"labels"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.RelationOption": {
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"label",
|
||||
"value"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.RowAction": {
|
||||
"properties": {
|
||||
"label": {
|
||||
@@ -1144,6 +1213,7 @@
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"description": "Relation fields are sent by field name with ids ({\"genre\": 3, \"artists\": [4, 9]}); the response carries the same shape plus meta.labels.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Vendor",
|
||||
@@ -1174,15 +1244,15 @@
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.SuccessEnvelope"
|
||||
"$ref": "#/components/schemas/cabana.RecordEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
"description": "Created"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
@@ -1310,6 +1380,134 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/{vendor}/{plugin}/{controller}/fields/{field}/options": {
|
||||
"get": {
|
||||
"description": "Choices for a writable `type: relation` field: value is the related id, label its nameFrom column. Read-only and non-relation fields answer 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": "Relation field name",
|
||||
"in": "path",
|
||||
"name": "field",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Case-insensitive label search",
|
||||
"in": "query",
|
||||
"name": "search",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Page",
|
||||
"in": "query",
|
||||
"name": "page",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Options per page (1-100, default 20)",
|
||||
"in": "query",
|
||||
"name": "per_page",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ListEnvelope-array_cabana_RelationOption"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "Relation field options",
|
||||
"tags": [
|
||||
"admin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/{vendor}/{plugin}/{controller}/schema/form": {
|
||||
"get": {
|
||||
"parameters": [
|
||||
@@ -1707,7 +1905,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.SuccessEnvelope"
|
||||
"$ref": "#/components/schemas/cabana.RecordEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1798,7 +1996,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.SuccessEnvelope"
|
||||
"$ref": "#/components/schemas/cabana.RecordEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1824,6 +2022,16 @@
|
||||
},
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
|
||||
135
admin/src/api/schema.d.ts
vendored
135
admin/src/api/schema.d.ts
vendored
@@ -568,7 +568,10 @@ export interface paths {
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
/** Create an admin record */
|
||||
/**
|
||||
* Create an admin record
|
||||
* @description Relation fields are sent by field name with ids ({"genre": 3, "artists": [4, 9]}); the response carries the same shape plus meta.labels.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -585,13 +588,13 @@ export interface paths {
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
/** @description Created */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.SuccessEnvelope"];
|
||||
"application/json": components["schemas"]["cabana.RecordEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
@@ -699,6 +702,97 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/{vendor}/{plugin}/{controller}/fields/{field}/options": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Relation field options
|
||||
* @description Choices for a writable `type: relation` field: value is the related id, label its nameFrom column. Read-only and non-relation fields answer 404.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Case-insensitive label search */
|
||||
search?: string;
|
||||
/** @description Page */
|
||||
page?: number;
|
||||
/** @description Options per page (1-100, default 20) */
|
||||
per_page?: number;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Vendor */
|
||||
vendor: string;
|
||||
/** @description Plugin */
|
||||
plugin: string;
|
||||
/** @description Controller */
|
||||
controller: string;
|
||||
/** @description Relation field name */
|
||||
field: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ListEnvelope-array_cabana_RelationOption"];
|
||||
};
|
||||
};
|
||||
/** @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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/{vendor}/{plugin}/{controller}/schema/form": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -943,7 +1037,7 @@ export interface paths {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.SuccessEnvelope"];
|
||||
"application/json": components["schemas"]["cabana.RecordEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
@@ -1000,7 +1094,7 @@ export interface paths {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.SuccessEnvelope"];
|
||||
"application/json": components["schemas"]["cabana.RecordEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
@@ -1021,6 +1115,15 @@ export interface paths {
|
||||
"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: {
|
||||
@@ -1396,6 +1499,9 @@ export interface components {
|
||||
login: string;
|
||||
role?: components["schemas"]["cabana.AdminRoleSummary"];
|
||||
};
|
||||
"cabana.AdminRecord": {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
"cabana.AdminRoleSummary": {
|
||||
code: string;
|
||||
id: number;
|
||||
@@ -1453,6 +1559,10 @@ export interface components {
|
||||
}[];
|
||||
meta: components["schemas"]["cabana.ListMeta"];
|
||||
};
|
||||
"cabana.ListEnvelope-array_cabana_RelationOption": {
|
||||
data: components["schemas"]["cabana.RelationOption"][];
|
||||
meta: components["schemas"]["cabana.ListMeta"];
|
||||
};
|
||||
"cabana.ListFilter": {
|
||||
column?: string;
|
||||
falseValue?: components["schemas"]["cabana.jsonScalar"];
|
||||
@@ -1504,6 +1614,19 @@ export interface components {
|
||||
order: number;
|
||||
sideMenu: components["schemas"]["cabana.NavigationEntry"][];
|
||||
};
|
||||
"cabana.RecordEnvelope": {
|
||||
data: components["schemas"]["cabana.AdminRecord"];
|
||||
meta: components["schemas"]["cabana.RecordMeta"];
|
||||
};
|
||||
"cabana.RecordMeta": {
|
||||
labels: {
|
||||
[key: string]: components["schemas"]["cabana.RelationOption"][];
|
||||
};
|
||||
};
|
||||
"cabana.RelationOption": {
|
||||
label: string;
|
||||
value: number;
|
||||
};
|
||||
"cabana.RowAction": {
|
||||
label?: string;
|
||||
name: string;
|
||||
|
||||
@@ -257,6 +257,28 @@ func AdminFormSchema() {}
|
||||
// @Router /{vendor}/{plugin}/{controller}/schema/relation/{name} [get]
|
||||
func AdminRelationSchema() {}
|
||||
|
||||
// AdminFieldOptions documents the relation field options route (D-17).
|
||||
//
|
||||
// @Summary Relation field options
|
||||
// @Description Choices for a writable `type: relation` field: value is the related id, label its nameFrom column. Read-only and non-relation fields answer 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 field path string true "Relation field name"
|
||||
// @Param search query string false "Case-insensitive label search"
|
||||
// @Param page query integer false "Page"
|
||||
// @Param per_page query integer false "Options per page (1-100, default 20)"
|
||||
// @Success 200 {object} ListEnvelope[[]RelationOption]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller}/fields/{field}/options [get]
|
||||
func AdminFieldOptions() {}
|
||||
|
||||
// AdminList documents the record list route.
|
||||
//
|
||||
// @Summary List admin records
|
||||
@@ -281,6 +303,7 @@ func AdminList() {}
|
||||
// AdminCreate documents the record create route.
|
||||
//
|
||||
// @Summary Create an admin record
|
||||
// @Description Relation fields are sent by field name with ids ({"genre": 3, "artists": [4, 9]}); the response carries the same shape plus meta.labels.
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -288,7 +311,7 @@ func AdminList() {}
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Success 201 {object} RecordEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
@@ -322,7 +345,7 @@ func AdminBulkDelete() {}
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Record id"
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Success 200 {object} RecordEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
@@ -340,9 +363,10 @@ func AdminShow() {}
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Record id"
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @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} [put]
|
||||
func AdminUpdate() {}
|
||||
|
||||
@@ -71,6 +71,8 @@ type CompiledController struct {
|
||||
Form *FormSchema
|
||||
Relations map[string]*CompiledRelation
|
||||
Writable []WritableField
|
||||
// FieldRelations are the form's `type: relation` fields keyed by field name.
|
||||
FieldRelations map[string]*CompiledFieldRelation
|
||||
}
|
||||
|
||||
// Registry is the immutable controller map keyed by controller ID.
|
||||
|
||||
@@ -119,11 +119,23 @@ func BindWritableFields(cc *CompiledController) error {
|
||||
|
||||
// Create persists a projected record after Fill and Validate.
|
||||
func (s CRUDService) Create(ctx context.Context, cc *CompiledController, in RecordInput) (map[string]any, error) {
|
||||
return s.save(ctx, cc, nil, in, false)
|
||||
res, err := s.save(ctx, cc, nil, in, false)
|
||||
return res.Data, err
|
||||
}
|
||||
|
||||
// Update persists a projected change after Fill and Validate.
|
||||
func (s CRUDService) Update(ctx context.Context, cc *CompiledController, id any, in RecordInput) (map[string]any, error) {
|
||||
res, err := s.save(ctx, cc, id, in, true)
|
||||
return res.Data, err
|
||||
}
|
||||
|
||||
// CreateRecord is Create plus the relation labels of the saved record (D-18).
|
||||
func (s CRUDService) CreateRecord(ctx context.Context, cc *CompiledController, in RecordInput) (RecordResult, error) {
|
||||
return s.save(ctx, cc, nil, in, false)
|
||||
}
|
||||
|
||||
// UpdateRecord is Update plus the relation labels of the saved record (D-18).
|
||||
func (s CRUDService) UpdateRecord(ctx context.Context, cc *CompiledController, id any, in RecordInput) (RecordResult, error) {
|
||||
return s.save(ctx, cc, id, in, true)
|
||||
}
|
||||
|
||||
@@ -226,13 +238,22 @@ func (s CRUDService) BulkDelete(ctx context.Context, cc *CompiledController, in
|
||||
|
||||
// Show loads one scoped record. Missing and out-of-scope ids are identical.
|
||||
func (s CRUDService) Show(ctx context.Context, cc *CompiledController, id any) (map[string]any, error) {
|
||||
res, err := s.ShowRecord(ctx, cc, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res.Data, nil
|
||||
}
|
||||
|
||||
// ShowRecord is Show plus the relation labels of the record (D-18).
|
||||
func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id any) (RecordResult, error) {
|
||||
if s.DB == nil {
|
||||
return nil, errors.New("cabana: database is not configured")
|
||||
return RecordResult{}, errors.New("cabana: database is not configured")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
var result map[string]any
|
||||
var result RecordResult
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
tx = tx.WithContext(ctx)
|
||||
target, err := newWritableModel(cc)
|
||||
@@ -246,35 +267,54 @@ func (s CRUDService) Show(ctx context.Context, cc *CompiledController, id any) (
|
||||
if err := loadRecord(ctx, tx, cc, target, pk); err != nil {
|
||||
return err
|
||||
}
|
||||
result = projectRecord(cc, target)
|
||||
return nil
|
||||
result, err = projectFullRecord(ctx, tx, cc, target)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return RecordResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, in RecordInput, update bool) (map[string]any, error) {
|
||||
// projectFullRecord is the D-18 record shape: scalar writable fields, relation
|
||||
// values keyed by field name, and their labels.
|
||||
func projectFullRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any) (RecordResult, error) {
|
||||
data := projectRecord(cc, model)
|
||||
meta, err := projectRelationFields(ctx, tx, cc, model, data)
|
||||
if err != nil {
|
||||
return RecordResult{}, err
|
||||
}
|
||||
return RecordResult{Data: data, Meta: meta}, nil
|
||||
}
|
||||
|
||||
func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, in RecordInput, update bool) (RecordResult, error) {
|
||||
if s.DB == nil {
|
||||
return nil, errors.New("cabana: database is not configured")
|
||||
return RecordResult{}, errors.New("cabana: database is not configured")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if _, err := newWritableModel(cc); err != nil {
|
||||
return nil, err
|
||||
return RecordResult{}, err
|
||||
}
|
||||
var result map[string]any
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
op := "create"
|
||||
if update {
|
||||
op = "update"
|
||||
}
|
||||
// Writable relation keys are lifted before scalar projection (which drops
|
||||
// every nested value); only keys present in the body are applied.
|
||||
relations, err := liftRelationValues(cc, in.Body, op)
|
||||
if err != nil {
|
||||
return RecordResult{}, err
|
||||
}
|
||||
var result RecordResult
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
tx = tx.WithContext(ctx)
|
||||
target, err := newWritableModel(cc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
op := "create"
|
||||
if update {
|
||||
op = "update"
|
||||
pk, err := coercePK(target, id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -301,19 +341,32 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
return &ValidationError{Details: validationDetails(msgs)}
|
||||
}
|
||||
if update {
|
||||
if err := formBeforeUpdate(ctx, cc, target); err != nil {
|
||||
return err
|
||||
}
|
||||
err = formBeforeUpdate(ctx, cc, target)
|
||||
} else {
|
||||
err = formBeforeCreate(ctx, cc, target)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// D-18: submitted ids pass the same scoped query as the options
|
||||
// endpoint; belongsTo keys land before the row write, pivot rows after.
|
||||
if err := checkRelationScope(ctx, tx, cc, relations); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := assignBelongsTo(cc, target, relations); err != nil {
|
||||
return err
|
||||
}
|
||||
if update {
|
||||
err = tx.Save(target).Error
|
||||
} else {
|
||||
if err := formBeforeCreate(ctx, cc, target); err != nil {
|
||||
return err
|
||||
}
|
||||
err = tx.Create(target).Error
|
||||
}
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
if err := syncBelongsToMany(ctx, tx, cc, target, relations); err != nil {
|
||||
return err
|
||||
}
|
||||
if update {
|
||||
err = formAfterUpdate(ctx, cc, target)
|
||||
} else {
|
||||
@@ -322,11 +375,11 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result = projectRecord(cc, target)
|
||||
return nil
|
||||
result, err = projectFullRecord(ctx, tx, cc, target)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return RecordResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -212,8 +212,11 @@ func (s *service) mount(r pact.Router) {
|
||||
constrainController(g)
|
||||
g.Delete("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.deleteRecord))
|
||||
constrainController(g)
|
||||
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}", s.relationLinked)
|
||||
constrainRelation(g)
|
||||
// Six-segment GET routes share one pattern: ServeMux rejects the
|
||||
// relation list next to the field options route (neither is more
|
||||
// specific), so nestedGet dispatches on the literal segments.
|
||||
g.Get("/{vendor}/{plugin}/{controller}/{id}/{segment}/{name}", s.nestedGet)
|
||||
constrainNested(g)
|
||||
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", s.relationCandidates)
|
||||
constrainRelation(g)
|
||||
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", requireAjax(s.relationLink))
|
||||
@@ -240,6 +243,32 @@ func constrainRelation(g pact.Router) {
|
||||
g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")
|
||||
}
|
||||
|
||||
func constrainNested(g pact.Router) {
|
||||
constrainController(g)
|
||||
g.Where("segment", "[A-Za-z_][A-Za-z0-9_]*")
|
||||
g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")
|
||||
}
|
||||
|
||||
// nestedGet serves the logical routes
|
||||
//
|
||||
// GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}
|
||||
// GET /{vendor}/{plugin}/{controller}/fields/{field}/options
|
||||
//
|
||||
// A numeric id never equals a literal segment, so the dispatch is unambiguous.
|
||||
// Anything else is the D-10 not_found envelope, as an unmatched API path.
|
||||
func (s *service) nestedGet(w http.ResponseWriter, r *http.Request) {
|
||||
id, segment, name := r.PathValue("id"), r.PathValue("segment"), r.PathValue("name")
|
||||
switch {
|
||||
case id == "fields" && name == "options":
|
||||
r.SetPathValue("field", segment)
|
||||
s.fieldOptions(w, r)
|
||||
case segment == "relations":
|
||||
s.relationLinked(w, r)
|
||||
default:
|
||||
writeNotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func constrainSetting(g pact.Router) {
|
||||
g.Where("code", "[A-Za-z_][A-Za-z0-9_-]*")
|
||||
}
|
||||
@@ -509,12 +538,12 @@ func (s *service) show(w http.ResponseWriter, r *http.Request) {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
rec, err := svc.Show(r.Context(), cc, id)
|
||||
rec, err := svc.ShowRecord(r.Context(), cc, id)
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, rec, nil)
|
||||
WriteData(w, http.StatusOK, rec.Data, rec.Meta)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -530,12 +559,12 @@ func (s *service) create(w http.ResponseWriter, r *http.Request) {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
rec, err := svc.Create(r.Context(), cc, RecordInput{Body: body})
|
||||
rec, err := svc.CreateRecord(r.Context(), cc, RecordInput{Body: body})
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusCreated, rec, nil)
|
||||
WriteData(w, http.StatusCreated, rec.Data, rec.Meta)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -556,12 +585,12 @@ func (s *service) update(w http.ResponseWriter, r *http.Request) {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
rec, err := svc.Update(r.Context(), cc, id, RecordInput{Body: body})
|
||||
rec, err := svc.UpdateRecord(r.Context(), cc, id, RecordInput{Body: body})
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, rec, nil)
|
||||
WriteData(w, http.StatusOK, rec.Data, rec.Meta)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -66,8 +66,10 @@ func TestPhase09ContractInventory(t *testing.T) {
|
||||
t.Fatalf("duplicate contract route %s", key)
|
||||
}
|
||||
seen[key] = true
|
||||
if _, ok := op.Responses["200"]; !ok {
|
||||
t.Fatalf("%s has no 200 response", key)
|
||||
_, ok200 := op.Responses["200"]
|
||||
_, ok201 := op.Responses["201"]
|
||||
if !ok200 && !ok201 {
|
||||
t.Fatalf("%s has no 200 or 201 response", key)
|
||||
}
|
||||
if public[key] {
|
||||
if _, ok := op.Responses["401"]; !ok {
|
||||
@@ -99,11 +101,7 @@ func splitRoute(key string) (method, path string, ok bool) {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func pathsOf(routes []struct {
|
||||
key string
|
||||
public bool
|
||||
spa bool
|
||||
}) map[string]bool {
|
||||
func pathsOf(routes []adminRoute) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, route := range routes {
|
||||
if route.spa {
|
||||
|
||||
@@ -73,12 +73,17 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fieldRelations, err := compileFieldRelations(item.plugin.ID(), item.ctl, form)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
compiled := &CompiledController{
|
||||
PluginID: item.plugin.ID(),
|
||||
Controller: item.ctl,
|
||||
List: list,
|
||||
Form: form,
|
||||
Relations: relations,
|
||||
PluginID: item.plugin.ID(),
|
||||
Controller: item.ctl,
|
||||
List: list,
|
||||
Form: form,
|
||||
Relations: relations,
|
||||
FieldRelations: fieldRelations,
|
||||
}
|
||||
if err := BindWritableFields(compiled); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -495,21 +495,31 @@ func relationBaseQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController,
|
||||
return q, target, nil
|
||||
}
|
||||
|
||||
func normalizeRelationQuery(schema *RelationSchema, candidates bool, in RelationQuery) (int, int, string, bool, error) {
|
||||
// normalizeRelationPage applies the Phase 9 relation paging limits: page is
|
||||
// a positive integer (default 1), per_page is 1..100 (default 20).
|
||||
func normalizeRelationPage(rawPage, rawPerPage string) (int, int, error) {
|
||||
page, per := 1, 20
|
||||
var err error
|
||||
if in.Page != "" {
|
||||
page, err = parsePositive(in.Page)
|
||||
if rawPage != "" {
|
||||
page, err = parsePositive(rawPage)
|
||||
if err != nil {
|
||||
return 0, 0, "", false, relationInvalid("page", "must be a positive integer")
|
||||
return 0, 0, relationInvalid("page", "must be a positive integer")
|
||||
}
|
||||
}
|
||||
if in.PerPage != "" {
|
||||
per, err = parsePositive(in.PerPage)
|
||||
if rawPerPage != "" {
|
||||
per, err = parsePositive(rawPerPage)
|
||||
if err != nil || per > 100 {
|
||||
return 0, 0, "", false, relationInvalid("per_page", "must be between 1 and 100")
|
||||
return 0, 0, relationInvalid("per_page", "must be between 1 and 100")
|
||||
}
|
||||
}
|
||||
return page, per, nil
|
||||
}
|
||||
|
||||
func normalizeRelationQuery(schema *RelationSchema, candidates bool, in RelationQuery) (int, int, string, bool, error) {
|
||||
page, per, err := normalizeRelationPage(in.Page, in.PerPage)
|
||||
if err != nil {
|
||||
return 0, 0, "", false, err
|
||||
}
|
||||
panel := schema.View
|
||||
if candidates {
|
||||
panel = schema.Manage
|
||||
|
||||
652
cabana/relation_field.go
Normal file
652
cabana/relation_field.go
Normal file
@@ -0,0 +1,652 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// FieldRelationContract binds one fields.yaml `type: relation` field to its
|
||||
// related model (D-17, D-18). Framework code never guesses a plugin table,
|
||||
// pivot or foreign key: every name below comes from the controller.
|
||||
type FieldRelationContract struct {
|
||||
// Field is the fields.yaml key ("genre", "artists").
|
||||
Field string
|
||||
// Kind is "belongsTo" or "belongsToMany".
|
||||
Kind string
|
||||
// NewRelated returns a pointer to the related model.
|
||||
NewRelated func() any
|
||||
// ForeignKey is the belongsTo column on the parent model.
|
||||
ForeignKey string
|
||||
// NewPivot returns a pointer to the belongsToMany join model.
|
||||
NewPivot func() any
|
||||
// ParentForeignKey and RelatedForeignKey are the pivot columns.
|
||||
ParentForeignKey string
|
||||
RelatedForeignKey string
|
||||
// OrderColumn is an optional pivot column set to the submitted array index.
|
||||
OrderColumn string
|
||||
// LabelColumn is the physical label column on the related model. Empty
|
||||
// means the field's nameFrom, mapped through pact.ListRelationColumnMapper
|
||||
// when the controller implements it.
|
||||
LabelColumn string
|
||||
}
|
||||
|
||||
// FieldRelationProvider is implemented by admin controllers whose form
|
||||
// declares `type: relation` fields.
|
||||
type FieldRelationProvider interface {
|
||||
AdminFieldRelations() []FieldRelationContract
|
||||
}
|
||||
|
||||
// RelationOption is one relation choice or label: the related primary key and
|
||||
// its label column value.
|
||||
type RelationOption struct {
|
||||
Value uint `json:"value"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// RecordMeta is the record envelope meta: display labels per relation field,
|
||||
// in the same order as the ids in data.
|
||||
type RecordMeta struct {
|
||||
Labels map[string][]RelationOption `json:"labels"`
|
||||
}
|
||||
|
||||
// RecordEnvelope is the show, create and update response.
|
||||
type RecordEnvelope struct {
|
||||
Data AdminRecord `json:"data"`
|
||||
Meta RecordMeta `json:"meta"`
|
||||
}
|
||||
|
||||
// RecordResult is one projected record plus its relation labels.
|
||||
type RecordResult struct {
|
||||
Data map[string]any
|
||||
Meta RecordMeta
|
||||
}
|
||||
|
||||
// CompiledFieldRelation is one relation field after activation checks.
|
||||
type CompiledFieldRelation struct {
|
||||
Contract FieldRelationContract
|
||||
// LabelColumn is the resolved physical label column.
|
||||
LabelColumn string
|
||||
// Multiple is true for belongsToMany.
|
||||
Multiple bool
|
||||
// ReadOnly is true for a belongsTo whose foreign key is a protected fill
|
||||
// key (D-26): it is shown with its label but never written and has no
|
||||
// options endpoint.
|
||||
ReadOnly bool
|
||||
// Nullable is true when a belongsTo foreign key accepts null.
|
||||
Nullable bool
|
||||
}
|
||||
|
||||
const (
|
||||
relationKindBelongsTo = "belongsTo"
|
||||
relationKindBelongsToMany = "belongsToMany"
|
||||
)
|
||||
|
||||
// compileFieldRelations binds every `type: relation` field to exactly one
|
||||
// controller contract and marks the compiled form fields multiple or
|
||||
// read-only. Any mismatch is a boot error naming plugin, controller and field.
|
||||
func compileFieldRelations(pluginID string, ctl pact.AdminController, form *FormSchema) (map[string]*CompiledFieldRelation, error) {
|
||||
out := map[string]*CompiledFieldRelation{}
|
||||
fail := func(field string, err error) error {
|
||||
return bootErr(pluginID, ctl.ID(), "config_form.yaml", fmt.Errorf("field %s: %w", field, err))
|
||||
}
|
||||
indexes := map[string]int{}
|
||||
var order []string
|
||||
if form != nil {
|
||||
for i, field := range form.Fields {
|
||||
if field.Type == "relation" {
|
||||
indexes[field.Name] = i
|
||||
order = append(order, field.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
provider, hasProvider := ctl.(FieldRelationProvider)
|
||||
var contracts []FieldRelationContract
|
||||
if hasProvider && provider != nil {
|
||||
contracts = provider.AdminFieldRelations()
|
||||
}
|
||||
if len(order) == 0 && len(contracts) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
byField := map[string]FieldRelationContract{}
|
||||
for _, contract := range contracts {
|
||||
if _, dup := byField[contract.Field]; dup {
|
||||
return nil, fail(contract.Field, errors.New("duplicate relation contract"))
|
||||
}
|
||||
byField[contract.Field] = contract
|
||||
if _, ok := indexes[contract.Field]; !ok {
|
||||
return nil, fail(contract.Field, errors.New("relation contract names a field that is not a relation field in fields.yaml"))
|
||||
}
|
||||
}
|
||||
src, ok := ctl.(pact.AdminRecordSource)
|
||||
if !ok || src == nil || src.NewRecord() == nil {
|
||||
return nil, fail(order[0], errors.New("relation fields require an AdminRecordSource"))
|
||||
}
|
||||
parent := src.NewRecord()
|
||||
for _, name := range order {
|
||||
contract, declared := byField[name]
|
||||
if !declared {
|
||||
if !hasProvider {
|
||||
return nil, fail(name, errors.New("type relation requires AdminFieldRelations on the controller"))
|
||||
}
|
||||
return nil, fail(name, errors.New("has no relation contract"))
|
||||
}
|
||||
field := &form.Fields[indexes[name]]
|
||||
compiled, err := compileFieldRelation(ctl, *field, contract, parent)
|
||||
if err != nil {
|
||||
return nil, fail(name, err)
|
||||
}
|
||||
field.Multiple = compiled.Multiple
|
||||
field.ReadOnly = compiled.ReadOnly
|
||||
out[name] = compiled
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func compileFieldRelation(ctl pact.AdminController, field FormField, contract FieldRelationContract, parent any) (*CompiledFieldRelation, error) {
|
||||
if contract.NewRelated == nil || contract.NewRelated() == nil {
|
||||
return nil, errors.New("relation contract requires a related model")
|
||||
}
|
||||
related := contract.NewRelated()
|
||||
relatedCols := modelColumns(related)
|
||||
if _, ok := relatedCols[primaryColumn(related)]; !ok {
|
||||
return nil, fmt.Errorf("related model has no primary key column %s", primaryColumn(related))
|
||||
}
|
||||
label := strings.TrimSpace(contract.LabelColumn)
|
||||
if label == "" {
|
||||
label = field.NameFrom
|
||||
if mapper, ok := ctl.(pact.ListRelationColumnMapper); ok && mapper != nil && label != "" {
|
||||
if mapped, ok := mapper.ListRelationColumn(field.Name, label); ok && mapped != "" {
|
||||
label = mapped
|
||||
}
|
||||
}
|
||||
}
|
||||
if !identifier(label) {
|
||||
return nil, errors.New("relation needs nameFrom or a LabelColumn")
|
||||
}
|
||||
if _, ok := relatedCols[label]; !ok {
|
||||
return nil, fmt.Errorf("related model is missing label column %s", label)
|
||||
}
|
||||
out := &CompiledFieldRelation{Contract: contract, LabelColumn: label}
|
||||
parentCols := modelColumns(parent)
|
||||
switch contract.Kind {
|
||||
case relationKindBelongsTo:
|
||||
if contract.NewPivot != nil || contract.ParentForeignKey != "" || contract.RelatedForeignKey != "" || contract.OrderColumn != "" {
|
||||
return nil, errors.New("belongsTo cannot declare pivot columns")
|
||||
}
|
||||
if !identifier(contract.ForeignKey) {
|
||||
return nil, errors.New("belongsTo requires a ForeignKey")
|
||||
}
|
||||
if _, ok := parentCols[contract.ForeignKey]; !ok {
|
||||
return nil, fmt.Errorf("parent model is missing foreign key column %s", contract.ForeignKey)
|
||||
}
|
||||
fk, ok := structFieldByColumn(parent, contract.ForeignKey)
|
||||
if !ok || !uintLike(fk.Type) {
|
||||
return nil, fmt.Errorf("foreign key %s must be an unsigned integer column", contract.ForeignKey)
|
||||
}
|
||||
out.Nullable = fk.Type.Kind() == reflect.Pointer
|
||||
out.ReadOnly = protectedFillKey(contract.ForeignKey)
|
||||
case relationKindBelongsToMany:
|
||||
if contract.ForeignKey != "" {
|
||||
return nil, errors.New("belongsToMany cannot declare a ForeignKey")
|
||||
}
|
||||
if contract.NewPivot == nil || contract.NewPivot() == nil {
|
||||
return nil, errors.New("belongsToMany requires a pivot model")
|
||||
}
|
||||
pivot := contract.NewPivot()
|
||||
if reflect.TypeOf(pivot).Kind() != reflect.Pointer || reflect.TypeOf(pivot).Elem().Kind() != reflect.Struct {
|
||||
return nil, errors.New("pivot model must be a struct pointer")
|
||||
}
|
||||
pivotCols := modelColumns(pivot)
|
||||
for _, col := range []string{contract.ParentForeignKey, contract.RelatedForeignKey} {
|
||||
if !identifier(col) {
|
||||
return nil, errors.New("belongsToMany requires ParentForeignKey and RelatedForeignKey")
|
||||
}
|
||||
if _, ok := pivotCols[col]; !ok {
|
||||
return nil, fmt.Errorf("pivot model is missing column %s", col)
|
||||
}
|
||||
}
|
||||
if contract.ParentForeignKey == contract.RelatedForeignKey {
|
||||
return nil, errors.New("pivot foreign keys must differ")
|
||||
}
|
||||
if contract.OrderColumn != "" {
|
||||
if !identifier(contract.OrderColumn) {
|
||||
return nil, fmt.Errorf("order column %q is not an identifier", contract.OrderColumn)
|
||||
}
|
||||
if _, ok := pivotCols[contract.OrderColumn]; !ok {
|
||||
return nil, fmt.Errorf("pivot model is missing order column %s", contract.OrderColumn)
|
||||
}
|
||||
if contract.OrderColumn == contract.ParentForeignKey || contract.OrderColumn == contract.RelatedForeignKey {
|
||||
return nil, errors.New("order column must not be a pivot foreign key")
|
||||
}
|
||||
}
|
||||
out.Multiple = true
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown relation kind %s (want belongsTo or belongsToMany)", contract.Kind)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func structFieldByColumn(model any, column string) (reflect.StructField, bool) {
|
||||
t := reflect.TypeOf(model)
|
||||
for t != nil && t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t == nil || t.Kind() != reflect.Struct {
|
||||
return reflect.StructField{}, false
|
||||
}
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
if field.PkgPath == "" && gormColumn(field) == column {
|
||||
return field, true
|
||||
}
|
||||
}
|
||||
return reflect.StructField{}, false
|
||||
}
|
||||
|
||||
func uintLike(t reflect.Type) bool {
|
||||
if t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
switch t.Kind() {
|
||||
case reflect.Uint, reflect.Uint32, reflect.Uint64, reflect.Int, reflect.Int32, reflect.Int64:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// writableFieldRelation returns a relation field that offers options and
|
||||
// accepts values. Read-only and unknown fields are not found.
|
||||
func writableFieldRelation(cc *CompiledController, name string) (*CompiledFieldRelation, bool) {
|
||||
if cc == nil || !identifier(name) {
|
||||
return nil, false
|
||||
}
|
||||
fr, ok := cc.FieldRelations[name]
|
||||
if !ok || fr == nil || fr.ReadOnly {
|
||||
return nil, false
|
||||
}
|
||||
return fr, true
|
||||
}
|
||||
|
||||
// scopedRelationQuery is the one query that both serves options and
|
||||
// revalidates submitted ids, so the options hook is never only cosmetic.
|
||||
func scopedRelationQuery(ctx context.Context, db *gorm.DB, cc *CompiledController, field string, fr *CompiledFieldRelation) *gorm.DB {
|
||||
q := db.WithContext(ctx).Model(fr.Contract.NewRelated())
|
||||
if ext, ok := cc.Controller.(pact.RelationExtendOptionsQuery); ok && ext != nil {
|
||||
if next := ext.RelationExtendOptionsQuery(ctx, field, q); next != nil {
|
||||
q = next
|
||||
}
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func labelExpression(db *gorm.DB, table, column string) string {
|
||||
return "COALESCE(CAST(" + quotedIdent(db, table) + "." + quotedIdent(db, column) + " AS TEXT), '')"
|
||||
}
|
||||
|
||||
// RelationOptions serves one page of a relation field's choices (D-17):
|
||||
// scoped by RelationExtendOptionsQuery, searched case-insensitively on the
|
||||
// label column, ordered by label then primary key.
|
||||
func (s CRUDService) RelationOptions(ctx context.Context, cc *CompiledController, field string, in RelationQuery) ([]RelationOption, ListMeta, error) {
|
||||
if s.DB == nil {
|
||||
return nil, ListMeta{}, errors.New("cabana: database is not configured")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
fr, ok := writableFieldRelation(cc, field)
|
||||
if !ok {
|
||||
return nil, ListMeta{}, recordNotFound{}
|
||||
}
|
||||
page, per, err := normalizeRelationPage(in.Page, in.PerPage)
|
||||
if err != nil {
|
||||
return nil, ListMeta{}, err
|
||||
}
|
||||
related := fr.Contract.NewRelated()
|
||||
table := tableName(related)
|
||||
pk := primaryColumn(related)
|
||||
q := scopedRelationQuery(ctx, s.DB, cc, field, fr)
|
||||
label := labelExpression(q, table, fr.LabelColumn)
|
||||
if term := strings.TrimSpace(in.Search); term != "" {
|
||||
q = q.Where(label+" ILIKE ? ESCAPE '\\'", "%"+escapeLike(term)+"%")
|
||||
}
|
||||
var total int64
|
||||
if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil {
|
||||
return nil, ListMeta{}, lifecycleFailure(cc, err)
|
||||
}
|
||||
rows := make([]RelationOption, 0)
|
||||
err = q.Select(quotedIdent(q, table) + "." + quotedIdent(q, pk) + " AS value, " + label + " AS label").
|
||||
Order(label).
|
||||
Order(clause.OrderByColumn{Column: clause.Column{Table: table, Name: pk}}).
|
||||
Offset((page - 1) * per).Limit(per).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, ListMeta{}, lifecycleFailure(cc, err)
|
||||
}
|
||||
last := 1
|
||||
if total > 0 {
|
||||
last = int((total + int64(per) - 1) / int64(per))
|
||||
}
|
||||
return rows, ListMeta{Page: page, PerPage: per, Total: total, LastPage: last}, nil
|
||||
}
|
||||
|
||||
// relationValue is one present, writable relation key lifted from a body.
|
||||
type relationValue struct {
|
||||
field string
|
||||
fr *CompiledFieldRelation
|
||||
ids []uint
|
||||
null bool
|
||||
}
|
||||
|
||||
// liftRelationValues takes the writable relation keys present in body. Read
|
||||
// only fields, fields outside the operation's context and absent keys are
|
||||
// skipped. Shape errors are one validation_failed with every bad field.
|
||||
func liftRelationValues(cc *CompiledController, body map[string]any, op string) ([]relationValue, error) {
|
||||
if cc == nil || len(cc.FieldRelations) == 0 || body == nil {
|
||||
return nil, nil
|
||||
}
|
||||
names := make([]string, 0, len(cc.FieldRelations))
|
||||
for name := range cc.FieldRelations {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
details := map[string]any{}
|
||||
var out []relationValue
|
||||
for _, name := range names {
|
||||
fr := cc.FieldRelations[name]
|
||||
if fr == nil || fr.ReadOnly || !contextAllows(cc, name, op) {
|
||||
continue
|
||||
}
|
||||
raw, present := body[name]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
value := relationValue{field: name, fr: fr}
|
||||
if fr.Multiple {
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
details[name] = []string{"The " + name + " field must be a list of ids."}
|
||||
continue
|
||||
}
|
||||
seen := map[uint]struct{}{}
|
||||
ids := make([]uint, 0, len(items))
|
||||
bad := ""
|
||||
for _, item := range items {
|
||||
id, err := relationID(item)
|
||||
if err != nil {
|
||||
bad = "The " + name + " field must be a list of integer ids."
|
||||
break
|
||||
}
|
||||
if _, dup := seen[id]; dup {
|
||||
bad = "The " + name + " field contains a duplicate id."
|
||||
break
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if bad != "" {
|
||||
details[name] = []string{bad}
|
||||
continue
|
||||
}
|
||||
value.ids = ids
|
||||
} else if raw == nil {
|
||||
if !fr.Nullable {
|
||||
details[name] = []string{"The " + name + " field cannot be empty."}
|
||||
continue
|
||||
}
|
||||
value.null = true
|
||||
} else {
|
||||
id, err := relationID(raw)
|
||||
if err != nil {
|
||||
details[name] = []string{"The " + name + " field must be an integer id."}
|
||||
continue
|
||||
}
|
||||
value.ids = []uint{id}
|
||||
}
|
||||
out = append(out, value)
|
||||
}
|
||||
if len(details) > 0 {
|
||||
return nil, &ValidationError{Details: details}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// relationID accepts a JSON integer only: no strings, booleans or fractions.
|
||||
func relationID(v any) (uint, error) {
|
||||
switch n := v.(type) {
|
||||
case json.Number:
|
||||
return asUint(n)
|
||||
case float64:
|
||||
if n != math.Trunc(n) {
|
||||
return 0, errBadID
|
||||
}
|
||||
return asUint(n)
|
||||
case int, int64, uint, uint32, uint64:
|
||||
return asUint(n)
|
||||
default:
|
||||
return 0, errBadID
|
||||
}
|
||||
}
|
||||
|
||||
// checkRelationScope re-runs the scoped options query for every submitted id
|
||||
// inside the save transaction. An id the query does not return (unknown or
|
||||
// out of scope) is validation_failed on that field and rolls the save back.
|
||||
func checkRelationScope(ctx context.Context, tx *gorm.DB, cc *CompiledController, values []relationValue) error {
|
||||
details := map[string]any{}
|
||||
for _, value := range values {
|
||||
if len(value.ids) == 0 {
|
||||
continue
|
||||
}
|
||||
related := value.fr.Contract.NewRelated()
|
||||
table := tableName(related)
|
||||
pk := primaryColumn(related)
|
||||
var found []uint
|
||||
err := scopedRelationQuery(ctx, tx, cc, value.field, value.fr).
|
||||
Where(clause.IN{Column: clause.Column{Table: table, Name: pk}, Values: uintValues(value.ids)}).
|
||||
Pluck(quotedIdent(tx, table)+"."+quotedIdent(tx, pk), &found).Error
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
have := map[uint]struct{}{}
|
||||
for _, id := range found {
|
||||
have[id] = struct{}{}
|
||||
}
|
||||
for _, id := range value.ids {
|
||||
if _, ok := have[id]; !ok {
|
||||
details[value.field] = []string{"The selected " + value.field + " is invalid."}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(details) > 0 {
|
||||
return &ValidationError{Details: details}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// assignBelongsTo writes validated belongsTo ids (or null) onto the parent's
|
||||
// foreign key before the row write. Read-only keys never reach this point.
|
||||
func assignBelongsTo(cc *CompiledController, model any, values []relationValue) error {
|
||||
for _, value := range values {
|
||||
if value.fr.Multiple || value.fr.ReadOnly || protectedFillKey(value.fr.Contract.ForeignKey) {
|
||||
continue
|
||||
}
|
||||
var id any
|
||||
if !value.null {
|
||||
id = value.ids[0]
|
||||
}
|
||||
if err := setModelColumn(model, value.fr.Contract.ForeignKey, id); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncBelongsToMany replaces the parent's pivot rows in submitted order: an
|
||||
// explicit delete, then one bulk insert (Phase 5 join-table contract).
|
||||
func syncBelongsToMany(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any, values []relationValue) error {
|
||||
parentPK := pkUint(model)
|
||||
for _, value := range values {
|
||||
if !value.fr.Multiple {
|
||||
continue
|
||||
}
|
||||
c := value.fr.Contract
|
||||
proto := c.NewPivot()
|
||||
err := tx.WithContext(ctx).Unscoped().
|
||||
Where(clause.Eq{Column: clause.Column{Name: c.ParentForeignKey}, Value: parentPK}).
|
||||
Delete(proto).Error
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
if len(value.ids) == 0 {
|
||||
continue
|
||||
}
|
||||
rows := reflect.MakeSlice(reflect.SliceOf(reflect.TypeOf(proto).Elem()), 0, len(value.ids))
|
||||
for i, id := range value.ids {
|
||||
pivot := c.NewPivot()
|
||||
if err := setModelColumn(pivot, c.ParentForeignKey, parentPK); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
if err := setModelColumn(pivot, c.RelatedForeignKey, id); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
if c.OrderColumn != "" {
|
||||
if err := setModelColumn(pivot, c.OrderColumn, i); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
}
|
||||
rows = reflect.Append(rows, reflect.ValueOf(pivot).Elem())
|
||||
}
|
||||
holder := reflect.New(rows.Type())
|
||||
holder.Elem().Set(rows)
|
||||
if err := tx.WithContext(ctx).Create(holder.Interface()).Error; err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// projectRelationFields adds every relation field's value to data (belongsTo
|
||||
// id or null; belongsToMany ids in pivot order, then related id) and returns
|
||||
// the labels in the same order. Read-only fields are included.
|
||||
func projectRelationFields(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any, data map[string]any) (RecordMeta, error) {
|
||||
meta := RecordMeta{Labels: map[string][]RelationOption{}}
|
||||
if cc == nil || len(cc.FieldRelations) == 0 {
|
||||
return meta, nil
|
||||
}
|
||||
v := reflect.ValueOf(model)
|
||||
for v.Kind() == reflect.Pointer {
|
||||
v = v.Elem()
|
||||
}
|
||||
for name, fr := range cc.FieldRelations {
|
||||
c := fr.Contract
|
||||
var ids []uint
|
||||
if fr.Multiple {
|
||||
ids = []uint{}
|
||||
q := tx.WithContext(ctx).Model(c.NewPivot()).
|
||||
Where(clause.Eq{Column: clause.Column{Name: c.ParentForeignKey}, Value: pkUint(model)})
|
||||
if c.OrderColumn != "" {
|
||||
q = q.Order(clause.OrderByColumn{Column: clause.Column{Name: c.OrderColumn}})
|
||||
}
|
||||
if err := q.Order(clause.OrderByColumn{Column: clause.Column{Name: c.RelatedForeignKey}}).Pluck(c.RelatedForeignKey, &ids).Error; err != nil {
|
||||
return RecordMeta{}, lifecycleFailure(cc, err)
|
||||
}
|
||||
data[name] = ids
|
||||
} else {
|
||||
data[name] = nil
|
||||
if id, ok := foreignKeyValue(v, c.ForeignKey); ok {
|
||||
data[name] = id
|
||||
ids = []uint{id}
|
||||
}
|
||||
}
|
||||
labels, err := relationLabels(ctx, tx, fr, ids)
|
||||
if err != nil {
|
||||
return RecordMeta{}, lifecycleFailure(cc, err)
|
||||
}
|
||||
meta.Labels[name] = labels
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func foreignKeyValue(v reflect.Value, column string) (uint, bool) {
|
||||
field := fieldByColumn(v, column)
|
||||
if !field.IsValid() {
|
||||
return 0, false
|
||||
}
|
||||
for field.Kind() == reflect.Pointer {
|
||||
if field.IsNil() {
|
||||
return 0, false
|
||||
}
|
||||
field = field.Elem()
|
||||
}
|
||||
if !field.CanInterface() {
|
||||
return 0, false
|
||||
}
|
||||
id, err := asUint(field.Interface())
|
||||
if err != nil || id == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func relationLabels(ctx context.Context, tx *gorm.DB, fr *CompiledFieldRelation, ids []uint) ([]RelationOption, error) {
|
||||
out := make([]RelationOption, 0, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
related := fr.Contract.NewRelated()
|
||||
table := tableName(related)
|
||||
pk := primaryColumn(related)
|
||||
var rows []RelationOption
|
||||
err := tx.WithContext(ctx).Model(related).
|
||||
Select(quotedIdent(tx, table) + "." + quotedIdent(tx, pk) + " AS value, " + labelExpression(tx, table, fr.LabelColumn) + " AS label").
|
||||
Where(clause.IN{Column: clause.Column{Table: table, Name: pk}, Values: uintValues(ids)}).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID := make(map[uint]string, len(rows))
|
||||
for _, row := range rows {
|
||||
byID[row.Value] = row.Label
|
||||
}
|
||||
for _, id := range ids {
|
||||
if label, ok := byID[id]; ok {
|
||||
out = append(out, RelationOption{Value: id, Label: label})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fieldOptions serves GET /{vendor}/{plugin}/{controller}/fields/{field}/options.
|
||||
func (s *service) fieldOptions(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
field := r.PathValue("field")
|
||||
if _, ok := writableFieldRelation(cc, field); !ok {
|
||||
writeNotFound(w, r)
|
||||
return
|
||||
}
|
||||
svc, err := s.crud()
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
rows, meta, err := svc.RelationOptions(r.Context(), cc, field, RelationQuery{Search: q.Get("search"), Page: q.Get("page"), PerPage: q.Get("per_page")})
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, rows, meta)
|
||||
})
|
||||
}
|
||||
732
cabana/relation_field_test.go
Normal file
732
cabana/relation_field_test.go
Normal file
@@ -0,0 +1,732 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"git.golem15.com/golem15/summercms/bouncer"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const p10FormConfig = `name: records
|
||||
form: ~/plugins/acme/demo/models/record/fields.yaml
|
||||
modelClass: Record
|
||||
`
|
||||
|
||||
const p10ListConfig = `modelClass: Record
|
||||
list: ~/plugins/acme/demo/models/record/columns.yaml
|
||||
recordsPerPage: 20
|
||||
`
|
||||
|
||||
const p10Columns = `columns:
|
||||
name:
|
||||
label: Name
|
||||
searchable: true
|
||||
`
|
||||
|
||||
const p10Fields = `fields:
|
||||
name:
|
||||
label: Name
|
||||
type: text
|
||||
required: true
|
||||
group:
|
||||
label: Group
|
||||
type: relation
|
||||
nameFrom: title
|
||||
emptyOption: None
|
||||
tags:
|
||||
label: Tags
|
||||
type: relation
|
||||
nameFrom: label
|
||||
person:
|
||||
label: Person
|
||||
type: relation
|
||||
nameFrom: username
|
||||
`
|
||||
|
||||
type p10Record struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
GroupID *uint `gorm:"column:group_id"`
|
||||
UserID uint `gorm:"column:user_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (p10Record) TableName() string { return "cabana_p10_records" }
|
||||
func (p10Record) Fillable() []string { return []string{"name"} }
|
||||
func (p10Record) Rules() map[string]string { return map[string]string{"name": "required"} }
|
||||
func (p10Group) TableName() string { return "cabana_p10_groups" }
|
||||
func (p10Tag) TableName() string { return "cabana_p10_tags" }
|
||||
func (p10RecordTag) TableName() string { return "cabana_p10_record_tags" }
|
||||
func (p10Person) TableName() string { return "cabana_p10_people" }
|
||||
func (p10Controller) ID() string { return "acme.demo.records" }
|
||||
func (p10Controller) ModelName() string { return "Record" }
|
||||
func (p10Controller) ConfigDir() string { return "controllers/records" }
|
||||
func (p10Controller) NewRecord() any { return &p10Record{} }
|
||||
func (c p10Controller) RequiredPermissions() []string { return c.perms }
|
||||
|
||||
type p10Group struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Title string `gorm:"column:title"`
|
||||
Scope string `gorm:"column:scope"`
|
||||
}
|
||||
|
||||
type p10Tag struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Label *string `gorm:"column:label"`
|
||||
Scope string `gorm:"column:scope"`
|
||||
}
|
||||
|
||||
type p10RecordTag struct {
|
||||
RecordID uint `gorm:"column:record_id;primaryKey"`
|
||||
TagID uint `gorm:"column:tag_id;primaryKey"`
|
||||
Position int `gorm:"column:position"`
|
||||
}
|
||||
|
||||
type p10Person struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Email string `gorm:"column:email"`
|
||||
}
|
||||
|
||||
// p10Controller serves the acme fixtures. Options are scoped to rows whose
|
||||
// scope is "visible"; the person relation writes the protected user_id and is
|
||||
// therefore read-only (D-26).
|
||||
type p10Controller struct {
|
||||
perms []string
|
||||
mutate func([]FieldRelationContract) []FieldRelationContract
|
||||
}
|
||||
|
||||
func (c p10Controller) AdminFieldRelations() []FieldRelationContract {
|
||||
out := []FieldRelationContract{
|
||||
{Field: "group", Kind: "belongsTo", NewRelated: func() any { return &p10Group{} }, ForeignKey: "group_id"},
|
||||
{Field: "tags", Kind: "belongsToMany", NewRelated: func() any { return &p10Tag{} }, NewPivot: func() any { return &p10RecordTag{} },
|
||||
ParentForeignKey: "record_id", RelatedForeignKey: "tag_id", OrderColumn: "position"},
|
||||
{Field: "person", Kind: "belongsTo", NewRelated: func() any { return &p10Person{} }, ForeignKey: "user_id", LabelColumn: "email"},
|
||||
}
|
||||
if c.mutate != nil {
|
||||
out = c.mutate(out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p10Controller) RelationExtendOptionsQuery(_ context.Context, field string, db *gorm.DB) *gorm.DB {
|
||||
switch field {
|
||||
case "group", "tags":
|
||||
return db.Where("scope = ?", "visible")
|
||||
default:
|
||||
return db.Where("1 = 0")
|
||||
}
|
||||
}
|
||||
|
||||
func (p10Controller) FormBeforeCreate(ctx context.Context, model any) error {
|
||||
record, ok := model.(*p10Record)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected model %T", model)
|
||||
}
|
||||
principal, _ := bouncer.User(ctx)
|
||||
if principal != nil {
|
||||
record.UserID = principal.ID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func p10FS() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"controllers/records/config_list.yaml": &fstest.MapFile{Data: []byte(p10ListConfig)},
|
||||
"controllers/records/config_form.yaml": &fstest.MapFile{Data: []byte(p10FormConfig)},
|
||||
"models/record/columns.yaml": &fstest.MapFile{Data: []byte(p10Columns)},
|
||||
"models/record/fields.yaml": &fstest.MapFile{Data: []byte(p10Fields)},
|
||||
}
|
||||
}
|
||||
|
||||
func p10Compile(ctl p10Controller) (*Registry, error) {
|
||||
return compileRegistry([]controllerRef{{plugin: formPlugin{fsys: p10FS()}, ctl: ctl}})
|
||||
}
|
||||
|
||||
type p10Seed struct {
|
||||
groups map[string]uint
|
||||
tags map[string]uint
|
||||
person uint
|
||||
}
|
||||
|
||||
func p10Fixture(t *testing.T) (*service, *gorm.DB, p10Seed) {
|
||||
t.Helper()
|
||||
_, db := newListService(t)
|
||||
models := []any{&p10Record{}, &p10Group{}, &p10Tag{}, &p10RecordTag{}, &p10Person{}}
|
||||
if err := db.Migrator().DropTable(models...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(models...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := p10Compile(p10Controller{perms: []string{"acme.demo.access"}})
|
||||
if err != nil {
|
||||
t.Fatalf("registry: %v", err)
|
||||
}
|
||||
app := backpack.New(nil)
|
||||
if err := app.Publish(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed := p10Seed{groups: map[string]uint{}, tags: map[string]uint{}}
|
||||
for _, g := range []p10Group{{Title: "Alpha", Scope: "visible"}, {Title: "Beta", Scope: "visible"}, {Title: "Hidden", Scope: "hidden"}} {
|
||||
if err := db.Create(&g).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed.groups[g.Title] = g.ID
|
||||
}
|
||||
for _, spec := range []struct{ label, scope string }{{"one", "visible"}, {"two", "visible"}, {"three", "visible"}, {"hidden", "hidden"}} {
|
||||
label := spec.label
|
||||
tag := p10Tag{Label: &label, Scope: spec.scope}
|
||||
if err := db.Create(&tag).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed.tags[spec.label] = tag.ID
|
||||
}
|
||||
person := p10Person{ID: 1, Email: "admin@acme.test"}
|
||||
if err := db.Create(&person).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed.person = person.ID
|
||||
return &service{app: app, reg: reg}, db, seed
|
||||
}
|
||||
|
||||
func p10Principal(granted bool) *bouncer.Principal {
|
||||
p := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: map[string]bool{}}
|
||||
if granted {
|
||||
p.PermissionGrants["acme.demo.access"] = true
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func p10Request(method, id, field, query string, body any, principal *bouncer.Principal) *http.Request {
|
||||
var reader *bytes.Reader
|
||||
if body != nil {
|
||||
raw, _ := json.Marshal(body)
|
||||
reader = bytes.NewReader(raw)
|
||||
} else {
|
||||
reader = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, "/", reader)
|
||||
req.URL.RawQuery = query
|
||||
req.SetPathValue("vendor", "acme")
|
||||
req.SetPathValue("plugin", "demo")
|
||||
req.SetPathValue("controller", "records")
|
||||
if id != "" {
|
||||
req.SetPathValue("id", id)
|
||||
}
|
||||
if field != "" {
|
||||
req.SetPathValue("field", field)
|
||||
}
|
||||
if principal != nil {
|
||||
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func p10Save(svc *service, method, id string, body any) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
req := p10Request(method, id, "", "", body, p10Principal(true))
|
||||
switch method {
|
||||
case http.MethodPost:
|
||||
svc.create(rec, req)
|
||||
case http.MethodPut:
|
||||
svc.update(rec, req)
|
||||
case http.MethodGet:
|
||||
svc.show(rec, req)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
func p10Options(svc *service, field, query string, principal *bouncer.Principal) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
svc.fieldOptions(rec, p10Request(http.MethodGet, "", field, query, nil, principal))
|
||||
return rec
|
||||
}
|
||||
|
||||
type p10Envelope struct {
|
||||
Data map[string]any `json:"data"`
|
||||
Meta struct {
|
||||
Labels map[string][]RelationOption `json:"labels"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
|
||||
func p10Decode(t *testing.T, rec *httptest.ResponseRecorder, status int) p10Envelope {
|
||||
t.Helper()
|
||||
if rec.Code != status {
|
||||
t.Fatalf("status=%d want %d body=%s", rec.Code, status, rec.Body.String())
|
||||
}
|
||||
var body p10Envelope
|
||||
dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes()))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
t.Fatalf("decode %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
if body.Meta.Labels == nil {
|
||||
t.Fatalf("meta.labels missing: %s", rec.Body.String())
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func p10ID(v any) uint {
|
||||
n, ok := v.(json.Number)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
i, err := n.Int64()
|
||||
if err != nil || i < 0 {
|
||||
return 0
|
||||
}
|
||||
return uint(i)
|
||||
}
|
||||
|
||||
func p10IDs(v any) []uint {
|
||||
items, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]uint, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, p10ID(item))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func p10Pivot(t *testing.T, db *gorm.DB, recordID uint) []uint {
|
||||
t.Helper()
|
||||
var rows []p10RecordTag
|
||||
if err := db.Where("record_id = ?", recordID).Order("position, tag_id").Find(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := make([]uint, len(rows))
|
||||
for i, row := range rows {
|
||||
if row.Position != i {
|
||||
t.Fatalf("pivot positions=%+v", rows)
|
||||
}
|
||||
out[i] = row.TagID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func p10Stored(t *testing.T, db *gorm.DB, id uint) p10Record {
|
||||
t.Helper()
|
||||
var row p10Record
|
||||
if err := db.First(&row, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func sameUintSeq(got, want []uint) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestPhase10RelationOptions(t *testing.T) {
|
||||
svc, db, seed := p10Fixture(t)
|
||||
extra := []struct{ label, scope string }{
|
||||
{"alpha first", "visible"}, {"ALPHA second", "visible"}, {"alpha hidden", "hidden"},
|
||||
{"100%_off", "visible"}, {"100 off", "visible"}, {"same", "visible"}, {"same", "visible"},
|
||||
}
|
||||
ids := map[string][]uint{}
|
||||
for _, spec := range extra {
|
||||
label := spec.label
|
||||
tag := p10Tag{Label: &label, Scope: spec.scope}
|
||||
if err := db.Create(&tag).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids[spec.label] = append(ids[spec.label], tag.ID)
|
||||
}
|
||||
for i := 0; i < 25; i++ {
|
||||
label := fmt.Sprintf("bulk %02d", i)
|
||||
if err := db.Create(&p10Tag{Label: &label, Scope: "visible"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.Create(&p10Tag{Scope: "visible"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
visible := int64(3 + 6 + 25 + 1)
|
||||
|
||||
decode := func(rec *httptest.ResponseRecorder) ([]RelationOption, ListMeta) {
|
||||
t.Helper()
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Data []RelationOption `json:"data"`
|
||||
Meta ListMeta `json:"meta"`
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes()))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
t.Fatalf("decode %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
return body.Data, body.Meta
|
||||
}
|
||||
|
||||
all := p10Options(svc, "tags", "", p10Principal(true))
|
||||
rows, meta := decode(all)
|
||||
if meta.Page != 1 || meta.PerPage != 20 || meta.Total != visible || meta.LastPage != 2 || len(rows) != 20 {
|
||||
t.Fatalf("default page meta=%+v rows=%d", meta, len(rows))
|
||||
}
|
||||
if !strings.Contains(all.Body.String(), `"value":`) || strings.Contains(all.Body.String(), `"value":"`) {
|
||||
t.Fatalf("option values are not numbers: %s", all.Body.String())
|
||||
}
|
||||
// Label order follows the database collation; the "bulk NN" labels sort
|
||||
// the same under every collation.
|
||||
bulk, _ := decode(p10Options(svc, "tags", "search=bulk&per_page=100", p10Principal(true)))
|
||||
if len(bulk) != 25 {
|
||||
t.Fatalf("bulk rows=%d", len(bulk))
|
||||
}
|
||||
for i, row := range bulk {
|
||||
if row.Label != fmt.Sprintf("bulk %02d", i) {
|
||||
t.Fatalf("options not ordered by label: %+v", bulk)
|
||||
}
|
||||
}
|
||||
if rows[0].Label != "" {
|
||||
t.Fatalf("a null label must sort first as an empty string: %+v", rows[0])
|
||||
}
|
||||
for _, row := range rows {
|
||||
if strings.Contains(row.Label, "hidden") {
|
||||
t.Fatalf("scoped options leaked a hidden row: %+v", rows)
|
||||
}
|
||||
}
|
||||
page2, meta2 := decode(p10Options(svc, "tags", "page=2", p10Principal(true)))
|
||||
if meta2.Page != 2 || len(page2) != int(visible)-20 {
|
||||
t.Fatalf("page 2 meta=%+v rows=%d", meta2, len(page2))
|
||||
}
|
||||
big, bigMeta := decode(p10Options(svc, "tags", "per_page=100", p10Principal(true)))
|
||||
if bigMeta.PerPage != 100 || len(big) != int(visible) || bigMeta.LastPage != 1 {
|
||||
t.Fatalf("per_page=100 meta=%+v rows=%d", bigMeta, len(big))
|
||||
}
|
||||
|
||||
caseless, _ := decode(p10Options(svc, "tags", "search=Alpha", p10Principal(true)))
|
||||
if len(caseless) != 2 || caseless[0].Value != ids["ALPHA second"][0] && caseless[0].Value != ids["alpha first"][0] {
|
||||
t.Fatalf("case-insensitive search=%+v", caseless)
|
||||
}
|
||||
literal, _ := decode(p10Options(svc, "tags", "search=%25_", p10Principal(true)))
|
||||
if len(literal) != 1 || literal[0].Label != "100%_off" {
|
||||
t.Fatalf("LIKE metacharacters were not escaped: %+v", literal)
|
||||
}
|
||||
same, _ := decode(p10Options(svc, "tags", "search=same", p10Principal(true)))
|
||||
if len(same) != 2 || same[0].Value != ids["same"][0] || same[1].Value != ids["same"][1] {
|
||||
t.Fatalf("equal labels not ordered by id: %+v want %v", same, ids["same"])
|
||||
}
|
||||
groups, _ := decode(p10Options(svc, "group", "", p10Principal(true)))
|
||||
if len(groups) != 2 || groups[0].Value != seed.groups["Alpha"] || groups[0].Label != "Alpha" || groups[1].Label != "Beta" {
|
||||
t.Fatalf("group options=%+v", groups)
|
||||
}
|
||||
|
||||
for _, tc := range []struct{ query, field string }{
|
||||
{"per_page=101", "per_page"}, {"per_page=0", "per_page"}, {"page=0", "page"}, {"page=x", "page"},
|
||||
} {
|
||||
rec := p10Options(svc, "tags", tc.query, p10Principal(true))
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"`+tc.field+`"`) {
|
||||
t.Fatalf("%s status=%d body=%s", tc.query, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"name", "person", "nope", "Tags"} {
|
||||
rec := p10Options(svc, field, "", p10Principal(true))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("field %s status=%d body=%s", field, rec.Code, rec.Body.String())
|
||||
}
|
||||
assertErrorCode(t, rec.Body.Bytes(), "not_found")
|
||||
if strings.Contains(rec.Body.String(), "admin@acme.test") {
|
||||
t.Fatalf("read-only options disclosed a label: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Permission is checked before any SQL: this service has no database, so
|
||||
// reaching a query would be a 500, not a 403.
|
||||
denied := &service{reg: svc.reg}
|
||||
rec := p10Options(denied, "tags", "search=one", p10Principal(false))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("denied status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertErrorCode(t, rec.Body.Bytes(), "forbidden")
|
||||
frontend := p10Principal(true)
|
||||
frontend.Backend = false
|
||||
rec = httptest.NewRecorder()
|
||||
req := p10Request(http.MethodGet, "", "tags", "", nil, nil)
|
||||
req = req.WithContext(bouncer.WithUser(req.Context(), frontend))
|
||||
denied.fieldOptions(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("frontend principal status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhase10RelationSave(t *testing.T) {
|
||||
svc, db, seed := p10Fixture(t)
|
||||
one, two, three := seed.tags["one"], seed.tags["two"], seed.tags["three"]
|
||||
alpha, beta := seed.groups["Alpha"], seed.groups["Beta"]
|
||||
|
||||
created := p10Decode(t, p10Save(svc, http.MethodPost, "", map[string]any{
|
||||
"name": "record", "group": alpha, "tags": []uint{three, one}, "person": 99, "user_id": 99,
|
||||
}), http.StatusCreated)
|
||||
id := p10ID(created.Data["id"])
|
||||
if p10ID(created.Data["group"]) != alpha || !sameUintSeq(p10IDs(created.Data["tags"]), []uint{three, one}) {
|
||||
t.Fatalf("created data=%v", created.Data)
|
||||
}
|
||||
if p10ID(created.Data["person"]) != seed.person {
|
||||
t.Fatalf("read-only person value=%v want %d", created.Data["person"], seed.person)
|
||||
}
|
||||
if _, leaked := created.Data["user_id"]; leaked {
|
||||
t.Fatalf("protected foreign key leaked: %v", created.Data)
|
||||
}
|
||||
labels := created.Meta.Labels
|
||||
if len(labels["group"]) != 1 || labels["group"][0] != (RelationOption{Value: alpha, Label: "Alpha"}) {
|
||||
t.Fatalf("group labels=%+v", labels["group"])
|
||||
}
|
||||
if len(labels["tags"]) != 2 || labels["tags"][0] != (RelationOption{Value: three, Label: "three"}) || labels["tags"][1].Value != one {
|
||||
t.Fatalf("tag labels=%+v", labels["tags"])
|
||||
}
|
||||
if len(labels["person"]) != 1 || labels["person"][0] != (RelationOption{Value: seed.person, Label: "admin@acme.test"}) {
|
||||
t.Fatalf("person labels=%+v", labels["person"])
|
||||
}
|
||||
stored := p10Stored(t, db, id)
|
||||
if stored.GroupID == nil || *stored.GroupID != alpha || stored.UserID != seed.person {
|
||||
t.Fatalf("stored=%+v", stored)
|
||||
}
|
||||
if got := p10Pivot(t, db, id); !sameUintSeq(got, []uint{three, one}) {
|
||||
t.Fatalf("pivot=%v", got)
|
||||
}
|
||||
|
||||
idText := fmt.Sprintf("%d", id)
|
||||
p10Decode(t, p10Save(svc, http.MethodPut, idText, map[string]any{"name": "renamed"}), http.StatusOK)
|
||||
stored = p10Stored(t, db, id)
|
||||
if stored.Name != "renamed" || stored.GroupID == nil || *stored.GroupID != alpha {
|
||||
t.Fatalf("absent keys changed the relation: %+v", stored)
|
||||
}
|
||||
if got := p10Pivot(t, db, id); !sameUintSeq(got, []uint{three, one}) {
|
||||
t.Fatalf("absent key changed the pivot: %v", got)
|
||||
}
|
||||
|
||||
cleared := p10Decode(t, p10Save(svc, http.MethodPut, idText, map[string]any{"name": "renamed", "group": nil, "tags": []uint{two, one}}), http.StatusOK)
|
||||
if cleared.Data["group"] != nil || len(cleared.Meta.Labels["group"]) != 0 || cleared.Meta.Labels["group"] == nil {
|
||||
t.Fatalf("cleared group data=%v labels=%v", cleared.Data["group"], cleared.Meta.Labels)
|
||||
}
|
||||
if stored := p10Stored(t, db, id); stored.GroupID != nil {
|
||||
t.Fatalf("null did not clear group_id: %+v", stored)
|
||||
}
|
||||
if got := p10Pivot(t, db, id); !sameUintSeq(got, []uint{two, one}) {
|
||||
t.Fatalf("replaced pivot=%v", got)
|
||||
}
|
||||
|
||||
p10Decode(t, p10Save(svc, http.MethodPut, idText, map[string]any{"name": "renamed", "group": beta}), http.StatusOK)
|
||||
shown := p10Decode(t, p10Save(svc, http.MethodGet, idText, nil), http.StatusOK)
|
||||
if p10ID(shown.Data["group"]) != beta || !sameUintSeq(p10IDs(shown.Data["tags"]), []uint{two, one}) {
|
||||
t.Fatalf("show data=%v", shown.Data)
|
||||
}
|
||||
if len(shown.Meta.Labels["tags"]) != 2 || shown.Meta.Labels["tags"][0].Label != "two" || shown.Meta.Labels["group"][0].Label != "Beta" {
|
||||
t.Fatalf("show labels=%+v", shown.Meta.Labels)
|
||||
}
|
||||
|
||||
emptied := p10Decode(t, p10Save(svc, http.MethodPut, idText, map[string]any{"name": "renamed", "tags": []uint{}}), http.StatusOK)
|
||||
if tags, ok := emptied.Data["tags"].([]any); !ok || len(tags) != 0 {
|
||||
t.Fatalf("emptied tags=%#v", emptied.Data["tags"])
|
||||
}
|
||||
if got := p10Pivot(t, db, id); len(got) != 0 {
|
||||
t.Fatalf("empty list left pivot rows %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhase10RelationForgedID(t *testing.T) {
|
||||
svc, db, seed := p10Fixture(t)
|
||||
one := seed.tags["one"]
|
||||
created := p10Decode(t, p10Save(svc, http.MethodPost, "", map[string]any{"name": "keep", "group": seed.groups["Alpha"], "tags": []uint{one}}), http.StatusCreated)
|
||||
id := p10ID(created.Data["id"])
|
||||
idText := fmt.Sprintf("%d", id)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
field string
|
||||
}{
|
||||
{"out of scope tag", map[string]any{"tags": []uint{seed.tags["hidden"]}}, "tags"},
|
||||
{"unknown tag", map[string]any{"tags": []uint{999999}}, "tags"},
|
||||
{"text tag", map[string]any{"tags": []any{"x"}}, "tags"},
|
||||
{"fractional tag", map[string]any{"tags": []any{1.5}}, "tags"},
|
||||
{"negative tag", map[string]any{"tags": []any{-1}}, "tags"},
|
||||
{"duplicate tag", map[string]any{"tags": []uint{one, one}}, "tags"},
|
||||
{"scalar for many", map[string]any{"tags": one}, "tags"},
|
||||
{"out of scope group", map[string]any{"group": seed.groups["Hidden"]}, "group"},
|
||||
{"list for one", map[string]any{"group": []uint{seed.groups["Beta"]}}, "group"},
|
||||
{"text group", map[string]any{"group": "abc"}, "group"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body := map[string]any{"name": "changed"}
|
||||
for key, value := range tc.body {
|
||||
body[key] = value
|
||||
}
|
||||
rec := p10Save(svc, http.MethodPut, idText, body)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertErrorCode(t, rec.Body.Bytes(), "validation_failed")
|
||||
var envelope struct {
|
||||
Error struct {
|
||||
Details map[string][]string `json:"details"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(envelope.Error.Details[tc.field]) == 0 {
|
||||
t.Fatalf("details missing %s: %s", tc.field, rec.Body.String())
|
||||
}
|
||||
stored := p10Stored(t, db, id)
|
||||
if stored.Name != "keep" || stored.GroupID == nil || *stored.GroupID != seed.groups["Alpha"] {
|
||||
t.Fatalf("rejected save committed: %+v", stored)
|
||||
}
|
||||
if got := p10Pivot(t, db, id); !sameUintSeq(got, []uint{one}) {
|
||||
t.Fatalf("rejected save changed the pivot: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var before int64
|
||||
if err := db.Model(&p10Record{}).Count(&before).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := p10Save(svc, http.MethodPost, "", map[string]any{"name": "forged", "tags": []uint{one, seed.tags["hidden"]}})
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("forged create status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var after int64
|
||||
if err := db.Model(&p10Record{}).Count(&after).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var pivots int64
|
||||
if err := db.Model(&p10RecordTag{}).Count(&pivots).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after != before || pivots != 1 {
|
||||
t.Fatalf("forged create committed rows=%d->%d pivots=%d", before, after, pivots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhase10RelationBoot(t *testing.T) {
|
||||
reg, err := p10Compile(p10Controller{})
|
||||
if err != nil {
|
||||
t.Fatalf("valid contracts: %v", err)
|
||||
}
|
||||
cc, _ := reg.Get("acme.demo.records")
|
||||
raw, err := json.Marshal(cc.Form.Fields)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fields := map[string]map[string]any{}
|
||||
var list []map[string]any
|
||||
if err := json.Unmarshal(raw, &list); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, field := range list {
|
||||
fields[field["name"].(string)] = field
|
||||
}
|
||||
if fields["tags"]["multiple"] != true || fields["tags"]["readOnly"] != nil {
|
||||
t.Fatalf("tags field=%v", fields["tags"])
|
||||
}
|
||||
if fields["group"]["multiple"] != nil || fields["group"]["readOnly"] != nil {
|
||||
t.Fatalf("group field=%v", fields["group"])
|
||||
}
|
||||
if fields["person"]["readOnly"] != true || fields["person"]["multiple"] != nil {
|
||||
t.Fatalf("person field=%v", fields["person"])
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
mutate func([]FieldRelationContract) []FieldRelationContract
|
||||
want []string
|
||||
}{
|
||||
{"missing contract", func(in []FieldRelationContract) []FieldRelationContract { return in[1:] }, []string{"field group", "no relation contract"}},
|
||||
{"missing foreign key column", func(in []FieldRelationContract) []FieldRelationContract {
|
||||
in[0].ForeignKey = "missing_id"
|
||||
return in
|
||||
}, []string{"field group", "missing_id"}},
|
||||
{"missing pivot column", func(in []FieldRelationContract) []FieldRelationContract {
|
||||
in[1].OrderColumn = "rank"
|
||||
return in
|
||||
}, []string{"field tags", "rank"}},
|
||||
{"missing label column", func(in []FieldRelationContract) []FieldRelationContract {
|
||||
in[2].LabelColumn = "username"
|
||||
return in
|
||||
}, []string{"field person", "username"}},
|
||||
{"unknown kind", func(in []FieldRelationContract) []FieldRelationContract {
|
||||
in[0].Kind = "hasMany"
|
||||
return in
|
||||
}, []string{"field group", "unknown relation kind hasMany"}},
|
||||
{"missing related model", func(in []FieldRelationContract) []FieldRelationContract {
|
||||
in[0].NewRelated = nil
|
||||
return in
|
||||
}, []string{"field group", "related model"}},
|
||||
{"duplicate contract", func(in []FieldRelationContract) []FieldRelationContract { return append(in, in[0]) }, []string{"field group", "duplicate"}},
|
||||
{"orphan contract", func(in []FieldRelationContract) []FieldRelationContract {
|
||||
return append(in, FieldRelationContract{Field: "extra", Kind: "belongsTo", NewRelated: func() any { return &p10Group{} }, ForeignKey: "group_id"})
|
||||
}, []string{"field extra", "not a relation field"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := p10Compile(p10Controller{mutate: tc.mutate})
|
||||
if err == nil {
|
||||
t.Fatal("activation accepted a broken relation contract")
|
||||
}
|
||||
for _, want := range append([]string{"acme.demo", "acme.demo.records"}, tc.want...) {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("err=%v missing %q", err, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_, err = compileRegistry([]controllerRef{{plugin: formPlugin{fsys: p10FS()}, ctl: crudControllerLike{}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "field group") || !strings.Contains(err.Error(), "AdminFieldRelations") {
|
||||
t.Fatalf("controller without contracts err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// crudControllerLike has the relation form but declares no contracts.
|
||||
type crudControllerLike struct{}
|
||||
|
||||
func (crudControllerLike) ID() string { return "acme.demo.records" }
|
||||
func (crudControllerLike) ModelName() string { return "Record" }
|
||||
func (crudControllerLike) ConfigDir() string { return "controllers/records" }
|
||||
func (crudControllerLike) NewRecord() any { return &p10Record{} }
|
||||
|
||||
func TestPhase10NestedGetDispatch(t *testing.T) {
|
||||
svc, _, seed := p10Fixture(t)
|
||||
call := func(id, segment, name string) *httptest.ResponseRecorder {
|
||||
req := p10Request(http.MethodGet, id, "", "", nil, p10Principal(true))
|
||||
req.SetPathValue("segment", segment)
|
||||
req.SetPathValue("name", name)
|
||||
rec := httptest.NewRecorder()
|
||||
svc.nestedGet(rec, req)
|
||||
return rec
|
||||
}
|
||||
options := call("fields", "group", "options")
|
||||
if options.Code != http.StatusOK || !strings.Contains(options.Body.String(), fmt.Sprintf(`"value":%d`, seed.groups["Alpha"])) {
|
||||
t.Fatalf("fields dispatch status=%d body=%s", options.Code, options.Body.String())
|
||||
}
|
||||
for _, tc := range [][3]string{{"fields", "group", "choices"}, {"5", "other", "x"}, {"filters", "group", "list"}} {
|
||||
rec := call(tc[0], tc[1], tc[2])
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("%v status=%d body=%s", tc, rec.Code, rec.Body.String())
|
||||
}
|
||||
assertErrorCode(t, rec.Body.Bytes(), "not_found")
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,8 @@ type FormField struct {
|
||||
NameFrom string `json:"nameFrom,omitempty"`
|
||||
EmptyOption string `json:"emptyOption,omitempty"`
|
||||
Relation string `json:"relation,omitempty"`
|
||||
Multiple bool `json:"multiple,omitempty"`
|
||||
ReadOnly bool `json:"readOnly,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Default *jsonScalar `json:"default,omitempty"`
|
||||
Attributes map[string]jsonScalar `json:"attributes,omitempty"`
|
||||
|
||||
@@ -12,46 +12,61 @@ import (
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
)
|
||||
|
||||
// phase09Routes is the admin surface mounted by service.mount. API keys are
|
||||
// method plus the path relative to {backend.uri}/api/v1 (D-03); spa entries
|
||||
// are the public SPA shell routes relative to {backend.uri} and are not part
|
||||
// of the OpenAPI inventory. A handler added outside this set, or a protected
|
||||
// handler missing the backend guard, fails TestPhase09PermissionMatrix.
|
||||
var phase09Routes = []struct {
|
||||
key string
|
||||
public bool
|
||||
spa bool
|
||||
}{
|
||||
{"POST /auth/login", true, false},
|
||||
{"POST /auth/refresh", true, false},
|
||||
{"POST /auth/logout", false, false},
|
||||
{"GET /auth/me", false, false},
|
||||
{"GET /navigation", false, false},
|
||||
{"GET /settings", false, false},
|
||||
{"GET /settings/{code}/schema", false, false},
|
||||
{"GET /settings/{code}", false, false},
|
||||
{"PUT /settings/{code}", false, false},
|
||||
{"GET /{vendor}/{plugin}/{controller}/schema/list", false, false},
|
||||
{"GET /{vendor}/{plugin}/{controller}/schema/form", false, false},
|
||||
{"GET /{vendor}/{plugin}/{controller}/schema/relation/{name}", false, false},
|
||||
{"GET /{vendor}/{plugin}/{controller}", false, false},
|
||||
{"POST /{vendor}/{plugin}/{controller}", false, false},
|
||||
{"POST /{vendor}/{plugin}/{controller}/bulk-delete", false, false},
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}", false, false},
|
||||
{"PUT /{vendor}/{plugin}/{controller}/{id}", false, false},
|
||||
{"DELETE /{vendor}/{plugin}/{controller}/{id}", false, false},
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}", false, false},
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", false, false},
|
||||
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", false, false},
|
||||
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", false, false},
|
||||
{"GET ", true, true},
|
||||
{"GET /{path...}", true, true},
|
||||
// adminRoute is one logical admin route. key is method plus the path relative
|
||||
// to {backend.uri}/api/v1 (D-03); spa entries are the public SPA shell routes
|
||||
// relative to {backend.uri} and are not part of the OpenAPI inventory.
|
||||
// mounted, when set, is the relative ServeMux key that serves the route:
|
||||
// logical routes ServeMux cannot hold side by side share one dispatching
|
||||
// pattern (service.nestedGet).
|
||||
type adminRoute struct {
|
||||
key string
|
||||
public bool
|
||||
spa bool
|
||||
mounted string
|
||||
}
|
||||
|
||||
// nestedGetRoute is the shared six-segment GET pattern.
|
||||
const nestedGetRoute = "GET /{vendor}/{plugin}/{controller}/{id}/{segment}/{name}"
|
||||
|
||||
// phase09Routes is the admin surface mounted by service.mount. A handler added
|
||||
// outside this set, or a protected handler missing the backend guard, fails
|
||||
// TestPhase09PermissionMatrix.
|
||||
var phase09Routes = []adminRoute{
|
||||
{key: "POST /auth/login", public: true},
|
||||
{key: "POST /auth/refresh", public: true},
|
||||
{key: "POST /auth/logout"},
|
||||
{key: "GET /auth/me"},
|
||||
{key: "GET /navigation"},
|
||||
{key: "GET /settings"},
|
||||
{key: "GET /settings/{code}/schema"},
|
||||
{key: "GET /settings/{code}"},
|
||||
{key: "PUT /settings/{code}"},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/schema/list"},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/schema/form"},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/schema/relation/{name}"},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/fields/{field}/options", mounted: nestedGetRoute},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}"},
|
||||
{key: "POST /{vendor}/{plugin}/{controller}"},
|
||||
{key: "POST /{vendor}/{plugin}/{controller}/bulk-delete"},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/{id}"},
|
||||
{key: "PUT /{vendor}/{plugin}/{controller}/{id}"},
|
||||
{key: "DELETE /{vendor}/{plugin}/{controller}/{id}"},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}", mounted: nestedGetRoute},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates"},
|
||||
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link"},
|
||||
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink"},
|
||||
{key: "GET ", public: true, spa: true},
|
||||
{key: "GET /{path...}", public: true, spa: true},
|
||||
}
|
||||
|
||||
// mountedKey is the full mounted route key for an inventory entry.
|
||||
func mountedKey(key string, spa bool) string {
|
||||
func mountedKey(route adminRoute) string {
|
||||
key := route.key
|
||||
if route.mounted != "" {
|
||||
key = route.mounted
|
||||
}
|
||||
method, rel, _ := strings.Cut(key, " ")
|
||||
if spa {
|
||||
if route.spa {
|
||||
return method + " " + DefaultAdminPrefix + rel
|
||||
}
|
||||
return method + " " + adminAPI(rel)
|
||||
@@ -67,11 +82,15 @@ func TestPhase09PermissionMatrix(t *testing.T) {
|
||||
}
|
||||
got[key] = router.middleware[key]
|
||||
}
|
||||
if len(got) != len(phase09Routes) {
|
||||
t.Fatalf("mounted %d admin routes, want %d: %#v", len(got), len(phase09Routes), router.routes)
|
||||
want := map[string]bool{}
|
||||
for _, route := range phase09Routes {
|
||||
want[mountedKey(route)] = true
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("mounted %d admin routes, want %d: %#v", len(got), len(want), router.routes)
|
||||
}
|
||||
for _, route := range phase09Routes {
|
||||
key := mountedKey(route.key, route.spa)
|
||||
key := mountedKey(route)
|
||||
mw, ok := got[key]
|
||||
if !ok {
|
||||
t.Fatalf("missing mounted route %s in %v", key, router.routes)
|
||||
@@ -250,6 +269,8 @@ func phase09ProtectedCalls() []phase09Call {
|
||||
{"form-schema", (*service).formSchema},
|
||||
{"relation-schema", (*service).relationSchema},
|
||||
{"relation-linked", (*service).relationLinked},
|
||||
{"field-options", (*service).fieldOptions},
|
||||
{"nested-get", (*service).nestedGet},
|
||||
{"relation-candidates", (*service).relationCandidates},
|
||||
{"relation-link", (*service).relationLink},
|
||||
{"relation-unlink", (*service).relationUnlink},
|
||||
@@ -278,6 +299,7 @@ func phase09Request(principal *bouncer.Principal) *http.Request {
|
||||
req.SetPathValue("controller", "widgets")
|
||||
req.SetPathValue("id", "1")
|
||||
req.SetPathValue("name", "editors")
|
||||
req.SetPathValue("segment", "relations")
|
||||
req.SetPathValue("code", "demo")
|
||||
if principal != nil {
|
||||
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
|
||||
|
||||
@@ -242,6 +242,13 @@ type RelationExtendManageQuery interface {
|
||||
RelationExtendManageQuery(ctx context.Context, relation string, db *gorm.DB) *gorm.DB
|
||||
}
|
||||
|
||||
// RelationExtendOptionsQuery optionally narrows the rows a form relation
|
||||
// field offers (D-17). The same scoped query revalidates submitted ids on
|
||||
// save, so a row it does not return cannot be attached (D-18).
|
||||
type RelationExtendOptionsQuery interface {
|
||||
RelationExtendOptionsQuery(ctx context.Context, field string, db *gorm.DB) *gorm.DB
|
||||
}
|
||||
|
||||
// RelationBeforeLink optionally stamps pivot columns before a link insert.
|
||||
type RelationBeforeLink interface {
|
||||
RelationBeforeLink(ctx context.Context, relation string, parent, related any, pivot map[string]any) error
|
||||
|
||||
Reference in New Issue
Block a user