json-rest-api 1.0.23 → 1.0.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -2
- package/docs/API.md +157 -33
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +88 -6
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +3 -3
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +2 -0
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1 -1
- package/docs/GUIDE/GUIDE_X_Autofiltering.md +63 -0
- package/docs/GUIDE/GUIDE_X_File_Uploads.md +17 -12
- package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +122 -2
- package/docs/GUIDE/GUIDE_X_Row_Policies.md +603 -0
- package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
- package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
- package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
- package/docs/GUIDE/index.md +4 -1
- package/fixing_plan.md +112 -0
- package/index.js +2 -0
- package/lib/enhanced-logger.js +1 -1
- package/package.json +6 -2
- package/plugins/core/bulk-operations-plugin.js +31 -38
- package/plugins/core/connectors/express-plugin.js +6 -7
- package/plugins/core/connectors/fastify-plugin.js +5 -1
- package/plugins/core/connectors/lib/busboy-detector.js +4 -4
- package/plugins/core/connectors/lib/connector-core.js +17 -11
- package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
- package/plugins/core/file-handling-plugin.js +126 -96
- package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
- package/plugins/core/lib/anyapi/schema-utils.js +44 -4
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +11 -2
- package/plugins/core/lib/dbIntrospection.js +1 -1
- package/plugins/core/lib/dbTablesOperations.js +10 -3
- package/plugins/core/lib/querying/knex-cross-table-search.js +0 -7
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -16
- package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
- package/plugins/core/lib/querying/knex-query-helpers.js +5 -6
- package/plugins/core/lib/querying/knex-relationship-includes.js +40 -46
- package/plugins/core/lib/querying/knex-window-queries.js +10 -77
- package/plugins/core/lib/querying/url-helpers.js +9 -21
- package/plugins/core/lib/querying-writing/compile-schemas.js +10 -6
- package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
- package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +7 -3
- package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
- package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
- package/plugins/core/lib/querying-writing/resource-id-normalization.js +131 -0
- package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +34 -43
- package/plugins/core/lib/querying-writing/simplified-helpers.js +4 -11
- package/plugins/core/lib/storage/canonical-storage-mapping.js +24 -3
- package/plugins/core/lib/storage/storage-adapter.js +76 -14
- package/plugins/core/lib/storage/storage-mapping.js +86 -16
- package/plugins/core/lib/writing/many-to-many-manipulations.js +8 -5
- package/plugins/core/lib/writing/relationship-processor.js +10 -1
- package/plugins/core/rest-api-anyapi-knex-plugin.js +402 -197
- package/plugins/core/rest-api-knex-plugin.js +93 -76
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +9 -2
- package/plugins/core/rest-api-plugin-methods/common.js +112 -72
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +34 -15
- package/plugins/core/rest-api-plugin-methods/delete.js +8 -7
- package/plugins/core/rest-api-plugin-methods/get-related.js +22 -15
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +8 -3
- package/plugins/core/rest-api-plugin-methods/get.js +7 -1
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +26 -8
- package/plugins/core/rest-api-plugin-methods/patch.js +34 -9
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +35 -16
- package/plugins/core/rest-api-plugin-methods/post.js +12 -7
- package/plugins/core/rest-api-plugin-methods/put.js +35 -10
- package/plugins/core/rest-api-plugin-methods/release.js +0 -2
- package/plugins/core/rest-api-plugin.js +11 -37
- package/plugins/core/rest-api-positioning-plugin.js +1 -4
- package/plugins/core/rest-api-row-policy-plugin.js +157 -0
- package/plugins/core/socketio-plugin.js +0 -1
- package/plugins/storage/local-storage.js +45 -20
- package/plugins/storage/s3-storage.js +23 -54
- package/quickTest.js +1 -0
- package/scripts/run-docs.js +80 -84
- package/test-debug.js +1 -1
- package/tests/README.md +8 -1
- package/tests/anyapi-basic.test.js +3 -0
- package/tests/anyapi-cursor-pagination.test.js +7 -1
- package/tests/anyapi-custom-idproperty.test.js +163 -0
- package/tests/custom-id-mysql-returning-fallback.test.js +128 -0
- package/tests/db-introspection.test.js +4 -1
- package/tests/db-table-operations.test.js +25 -20
- package/tests/fastify-plugin.test.js +49 -6
- package/tests/field-setters.test.js +10 -10
- package/tests/file-handling.test.js +118 -0
- package/tests/fixtures/api-configs.js +495 -4
- package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
- package/tests/http-connectors-parity.test.js +165 -5
- package/tests/id-normalization.test.js +444 -0
- package/tests/local-storage.test.js +96 -0
- package/tests/logical-id-write-validation.test.js +151 -0
- package/tests/pagination-cursor-multifield.test.js +132 -11
- package/tests/pagination-enhanced.test.js +66 -15
- package/tests/post-commit-hooks.test.js +120 -0
- package/tests/public-exports.test.js +15 -0
- package/tests/put-create-parity.test.js +216 -0
- package/tests/relationship-endpoints.test.js +259 -0
- package/tests/return-record-settings.test.js +184 -0
- package/tests/row-policy.test.js +433 -0
- package/tests/s3-storage.test.js +38 -0
- package/tests/searchschema-merge.test.js +17 -0
- package/tests/socketio.test.js +62 -0
- package/tests/storage-mapping.test.js +56 -6
- package/tests/test-development-guide.md +5 -5
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { RestApiValidationError } from '../../../../lib/rest-api-errors.js'
|
|
2
|
+
|
|
3
|
+
export function getRelationshipCardinality (relDef) {
|
|
4
|
+
if (!relDef) return null
|
|
5
|
+
|
|
6
|
+
if (
|
|
7
|
+
relDef.belongsTo ||
|
|
8
|
+
relDef.belongsToPolymorphic ||
|
|
9
|
+
relDef.type === 'hasOne'
|
|
10
|
+
) {
|
|
11
|
+
return 'one'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
|
|
15
|
+
return 'many'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function validateRelationshipDataCardinality ({
|
|
22
|
+
relationshipName,
|
|
23
|
+
relDef,
|
|
24
|
+
data,
|
|
25
|
+
fieldPath = `data.relationships.${relationshipName}.data`
|
|
26
|
+
}) {
|
|
27
|
+
const cardinality = getRelationshipCardinality(relDef)
|
|
28
|
+
|
|
29
|
+
if (cardinality === 'one' && Array.isArray(data)) {
|
|
30
|
+
throw new RestApiValidationError(
|
|
31
|
+
`Relationship '${relationshipName}' expects a single resource identifier or null`,
|
|
32
|
+
{
|
|
33
|
+
fields: [fieldPath],
|
|
34
|
+
violations: [{
|
|
35
|
+
field: fieldPath,
|
|
36
|
+
rule: 'relationship_cardinality',
|
|
37
|
+
message: 'Expected a single resource identifier or null'
|
|
38
|
+
}]
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (cardinality === 'many' && !Array.isArray(data)) {
|
|
44
|
+
throw new RestApiValidationError(
|
|
45
|
+
`Relationship '${relationshipName}' expects an array of resource identifiers`,
|
|
46
|
+
{
|
|
47
|
+
fields: [fieldPath],
|
|
48
|
+
violations: [{
|
|
49
|
+
field: fieldPath,
|
|
50
|
+
rule: 'relationship_cardinality',
|
|
51
|
+
message: 'Expected an array of resource identifiers'
|
|
52
|
+
}]
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { addType, addValidator, createSchema } from 'json-rest-schema'
|
|
2
2
|
import { RestApiValidationError } from '../../../../lib/rest-api-errors.js'
|
|
3
|
+
import { getRelationshipCardinality } from './relationship-contracts.js'
|
|
3
4
|
|
|
4
5
|
function isPlainObject (value) {
|
|
5
6
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
@@ -28,9 +29,20 @@ function buildResourceIdentifierTransportSchema (allowedTypes = null) {
|
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
function buildRelationshipDataTransportSchema (allowedTypes = null) {
|
|
32
|
+
function buildRelationshipDataTransportSchema (allowedTypes = null, cardinality = null) {
|
|
32
33
|
const identifierSchema = buildResourceIdentifierTransportSchema(allowedTypes)
|
|
33
34
|
|
|
35
|
+
if (cardinality === 'one') {
|
|
36
|
+
return identifierSchema
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (cardinality === 'many') {
|
|
40
|
+
return {
|
|
41
|
+
type: 'array',
|
|
42
|
+
items: identifierSchema
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
return {
|
|
35
47
|
anyOf: [
|
|
36
48
|
identifierSchema,
|
|
@@ -156,16 +168,31 @@ function installRequestContractSupport () {
|
|
|
156
168
|
const allowedTypes = Array.isArray(context.definition.allowedTypes)
|
|
157
169
|
? context.definition.allowedTypes
|
|
158
170
|
: null
|
|
171
|
+
const cardinality = context.definition.cardinality || null
|
|
159
172
|
|
|
160
173
|
if (Array.isArray(context.value)) {
|
|
174
|
+
if (cardinality === 'one') {
|
|
175
|
+
context.throwParamError(
|
|
176
|
+
'INVALID_RELATIONSHIP_CARDINALITY',
|
|
177
|
+
'Relationship data must be a single resource identifier or null.'
|
|
178
|
+
)
|
|
179
|
+
}
|
|
161
180
|
context.value.forEach((entry) => validateResourceIdentifierValue(entry, context, allowedTypes))
|
|
162
181
|
return context.value
|
|
163
182
|
}
|
|
164
183
|
|
|
184
|
+
if (cardinality === 'many') {
|
|
185
|
+
context.throwParamError(
|
|
186
|
+
'INVALID_RELATIONSHIP_CARDINALITY',
|
|
187
|
+
'Relationship data must be an array of resource identifiers.'
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
165
191
|
return validateResourceIdentifierValue(context.value, context, allowedTypes)
|
|
166
192
|
}
|
|
167
193
|
jsonApiRelationshipDataType.toJsonSchema = ({ definition }) => buildRelationshipDataTransportSchema(
|
|
168
|
-
Array.isArray(definition.allowedTypes) ? definition.allowedTypes : null
|
|
194
|
+
Array.isArray(definition.allowedTypes) ? definition.allowedTypes : null,
|
|
195
|
+
definition.cardinality || null
|
|
169
196
|
)
|
|
170
197
|
addType('jsonApiRelationshipData', jsonApiRelationshipDataType)
|
|
171
198
|
|
|
@@ -379,6 +406,7 @@ function buildRelationshipStructure (schemaInfo = {}) {
|
|
|
379
406
|
type: 'jsonApiRelationshipData',
|
|
380
407
|
required: true,
|
|
381
408
|
nullable: true,
|
|
409
|
+
cardinality: 'one',
|
|
382
410
|
allowedTypes: [fieldDef.belongsTo]
|
|
383
411
|
}
|
|
384
412
|
})
|
|
@@ -386,13 +414,15 @@ function buildRelationshipStructure (schemaInfo = {}) {
|
|
|
386
414
|
}
|
|
387
415
|
|
|
388
416
|
for (const [relName, relDef] of Object.entries(schemaRelationships)) {
|
|
417
|
+
const cardinality = getRelationshipCardinality(relDef)
|
|
389
418
|
relationshipStructure[relName] = {
|
|
390
419
|
type: 'object',
|
|
391
420
|
schema: createSchema({
|
|
392
421
|
data: {
|
|
393
422
|
type: 'jsonApiRelationshipData',
|
|
394
423
|
required: true,
|
|
395
|
-
nullable:
|
|
424
|
+
nullable: cardinality === 'one',
|
|
425
|
+
cardinality,
|
|
396
426
|
allowedTypes: resolveRelationshipAllowedTypes(relName, relDef)
|
|
397
427
|
}
|
|
398
428
|
})
|
|
@@ -520,6 +550,22 @@ function buildQueryRequestContract ({ includeDepthLimit, sortableFields, searchS
|
|
|
520
550
|
}
|
|
521
551
|
}
|
|
522
552
|
|
|
553
|
+
function buildRelationshipRouteBodyContract (operation) {
|
|
554
|
+
const isPatch = operation === 'patchRelationship'
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
schema: createSchema({
|
|
558
|
+
data: {
|
|
559
|
+
type: 'jsonApiRelationshipData',
|
|
560
|
+
required: true,
|
|
561
|
+
nullable: isPatch,
|
|
562
|
+
cardinality: isPatch ? null : 'many'
|
|
563
|
+
}
|
|
564
|
+
}),
|
|
565
|
+
mode: 'replace'
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
523
569
|
function buildRequestContracts ({ scopeName, schemaInfo, includeDepthLimit, sortableFields }) {
|
|
524
570
|
return {
|
|
525
571
|
post: buildWriteDocumentContract(scopeName, schemaInfo, 'post'),
|
|
@@ -530,7 +576,10 @@ function buildRequestContracts ({ scopeName, schemaInfo, includeDepthLimit, sort
|
|
|
530
576
|
includeDepthLimit,
|
|
531
577
|
sortableFields,
|
|
532
578
|
searchSchemaInstance: schemaInfo.searchSchemaInstance
|
|
533
|
-
})
|
|
579
|
+
}),
|
|
580
|
+
postRelationship: buildRelationshipRouteBodyContract('postRelationship'),
|
|
581
|
+
patchRelationship: buildRelationshipRouteBodyContract('patchRelationship'),
|
|
582
|
+
deleteRelationship: buildRelationshipRouteBodyContract('deleteRelationship')
|
|
534
583
|
}
|
|
535
584
|
}
|
|
536
585
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RestApiResourceError,
|
|
3
|
+
RestApiValidationError
|
|
4
|
+
} from '../../../../lib/rest-api-errors.js'
|
|
5
|
+
|
|
6
|
+
export function defaultNormalizeResourceId (value) {
|
|
7
|
+
if (value === null || value === undefined) {
|
|
8
|
+
return null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (typeof value === 'string') {
|
|
12
|
+
const normalized = value.trim()
|
|
13
|
+
return normalized || null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (typeof value === 'number') {
|
|
17
|
+
return Number.isFinite(value) ? String(value) : null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (typeof value === 'bigint') {
|
|
21
|
+
return String(value)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const normalized = String(value).trim()
|
|
25
|
+
return normalized || null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveResourceIdNormalizer ({ scopeOptions = null, vars = null } = {}) {
|
|
29
|
+
if (typeof scopeOptions?.normalizeId === 'function') {
|
|
30
|
+
return scopeOptions.normalizeId
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (typeof vars?.normalizeId === 'function') {
|
|
34
|
+
return vars.normalizeId
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return defaultNormalizeResourceId
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function normalizeResourceId (value, options = {}) {
|
|
41
|
+
const normalizer = resolveResourceIdNormalizer(options)
|
|
42
|
+
return defaultNormalizeResourceId(normalizer(value))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function requireExistingResourceId (value, {
|
|
46
|
+
scopeOptions = null,
|
|
47
|
+
vars = null,
|
|
48
|
+
scopeName = ''
|
|
49
|
+
} = {}) {
|
|
50
|
+
const normalizedId = normalizeResourceId(value, { scopeOptions, vars })
|
|
51
|
+
if (normalizedId) {
|
|
52
|
+
return normalizedId
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
throw new RestApiResourceError(
|
|
56
|
+
'Resource not found',
|
|
57
|
+
{
|
|
58
|
+
subtype: 'not_found',
|
|
59
|
+
resourceType: scopeName,
|
|
60
|
+
resourceId: value == null ? null : String(value)
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function requireDocumentResourceId (value, {
|
|
66
|
+
scopeOptions = null,
|
|
67
|
+
vars = null
|
|
68
|
+
} = {}) {
|
|
69
|
+
const normalizedId = normalizeResourceId(value, { scopeOptions, vars })
|
|
70
|
+
if (normalizedId) {
|
|
71
|
+
return normalizedId
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
throw new RestApiValidationError(
|
|
75
|
+
'Resource document id is invalid',
|
|
76
|
+
{
|
|
77
|
+
fields: ['data.id'],
|
|
78
|
+
violations: [{
|
|
79
|
+
field: 'data.id',
|
|
80
|
+
rule: 'invalid_resource_id',
|
|
81
|
+
message: 'Resource document id must normalize to a non-empty value.'
|
|
82
|
+
}]
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resolveResourceNormalizationOptions (resourceType, { api } = {}) {
|
|
88
|
+
const scope = api?.resources?.[resourceType]
|
|
89
|
+
return {
|
|
90
|
+
scopeOptions: scope?.scopeOptions || scope?._scopeOptions || null,
|
|
91
|
+
vars: scope?.vars || null,
|
|
92
|
+
scopeName: resourceType
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function requireReferencedResourceId (resourceType, value, { api } = {}) {
|
|
97
|
+
return requireExistingResourceId(
|
|
98
|
+
value,
|
|
99
|
+
resolveResourceNormalizationOptions(resourceType, { api })
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeRelationshipIdentifier (identifier, { api } = {}) {
|
|
104
|
+
if (!identifier || typeof identifier !== 'object' || Array.isArray(identifier)) {
|
|
105
|
+
return identifier
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const type = typeof identifier.type === 'string' ? identifier.type : ''
|
|
109
|
+
if (!type || !Object.hasOwn(identifier, 'id')) {
|
|
110
|
+
return {
|
|
111
|
+
...identifier
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
...identifier,
|
|
117
|
+
id: requireReferencedResourceId(type, identifier.id, { api })
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function normalizeRelationshipIdentifiers (relationshipData, { api } = {}) {
|
|
122
|
+
if (Array.isArray(relationshipData)) {
|
|
123
|
+
return relationshipData.map((identifier) => normalizeRelationshipIdentifier(identifier, { api }))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (relationshipData == null) {
|
|
127
|
+
return relationshipData
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return normalizeRelationshipIdentifier(relationshipData, { api })
|
|
131
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const RETURN_RECORD_METHODS = ['post', 'put', 'patch']
|
|
2
|
+
const RETURN_RECORD_MODES = ['no', 'minimal', 'full']
|
|
3
|
+
|
|
4
|
+
export function normalizeReturnRecordMode (value, defaultValue = 'no') {
|
|
5
|
+
if (value === true) return 'full'
|
|
6
|
+
if (value === false) return 'no'
|
|
7
|
+
if (RETURN_RECORD_MODES.includes(value)) return value
|
|
8
|
+
|
|
9
|
+
if (defaultValue === true) return 'full'
|
|
10
|
+
if (defaultValue === false) return 'no'
|
|
11
|
+
if (RETURN_RECORD_MODES.includes(defaultValue)) return defaultValue
|
|
12
|
+
|
|
13
|
+
return 'no'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function defaultModeForMethod (defaultValue, method) {
|
|
17
|
+
if (defaultValue && typeof defaultValue === 'object' && !Array.isArray(defaultValue)) {
|
|
18
|
+
return normalizeReturnRecordMode(defaultValue[method], 'no')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return normalizeReturnRecordMode(defaultValue, 'no')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeReturnRecordSetting (value, defaultValue = 'no') {
|
|
25
|
+
if (value === undefined) {
|
|
26
|
+
return Object.fromEntries(
|
|
27
|
+
RETURN_RECORD_METHODS.map(method => [
|
|
28
|
+
method,
|
|
29
|
+
defaultModeForMethod(defaultValue, method)
|
|
30
|
+
])
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
35
|
+
return Object.fromEntries(
|
|
36
|
+
RETURN_RECORD_METHODS.map(method => [
|
|
37
|
+
method,
|
|
38
|
+
normalizeReturnRecordMode(value[method], defaultModeForMethod(defaultValue, method))
|
|
39
|
+
])
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const normalized = normalizeReturnRecordMode(value, defaultModeForMethod(defaultValue, 'post'))
|
|
44
|
+
return Object.fromEntries(
|
|
45
|
+
RETURN_RECORD_METHODS.map(method => [method, normalized])
|
|
46
|
+
)
|
|
47
|
+
}
|
|
@@ -9,8 +9,6 @@
|
|
|
9
9
|
* - Detecting circular dependencies in field definitions
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { RestApiValidationError } from '../../../../lib/rest-api-errors.js'
|
|
13
|
-
|
|
14
12
|
/**
|
|
15
13
|
* Marks all fields in a searchSchema as indexed for database optimization
|
|
16
14
|
*
|
|
@@ -98,6 +96,36 @@ export function ensureSearchFieldsAreIndexed (searchSchema) {
|
|
|
98
96
|
})
|
|
99
97
|
}
|
|
100
98
|
|
|
99
|
+
function createRelationshipSearchEntries (fieldName, fieldDef, searchEntry) {
|
|
100
|
+
if (!fieldDef?.belongsTo || !fieldDef?.as) {
|
|
101
|
+
return [[fieldName, searchEntry]]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const relationshipEntry = {
|
|
105
|
+
...searchEntry,
|
|
106
|
+
actualField: fieldName,
|
|
107
|
+
isRelationship: true,
|
|
108
|
+
targetResource: fieldDef.belongsTo
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return [
|
|
112
|
+
[fieldDef.as, relationshipEntry],
|
|
113
|
+
[fieldName, {
|
|
114
|
+
...relationshipEntry
|
|
115
|
+
}]
|
|
116
|
+
]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function registerSearchEntries (searchSchema, entries = []) {
|
|
120
|
+
for (const [searchFieldName, searchEntry] of entries) {
|
|
121
|
+
if (!searchFieldName || searchSchema[searchFieldName]) {
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
searchSchema[searchFieldName] = searchEntry
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
101
129
|
/**
|
|
102
130
|
* Generates complete searchSchema by merging schema search definitions with explicit searchSchema
|
|
103
131
|
*
|
|
@@ -243,31 +271,12 @@ export const generateSearchSchemaFromSchema = (schema, explicitSearchSchema) =>
|
|
|
243
271
|
|
|
244
272
|
if (effectiveSearch) {
|
|
245
273
|
if (effectiveSearch === true) {
|
|
246
|
-
// For belongsTo relationships, use the relationship name (as property) instead of the field name
|
|
247
|
-
const searchFieldName = (fieldDef.belongsTo && fieldDef.as) ? fieldDef.as : fieldName
|
|
248
|
-
|
|
249
|
-
// Check if field already exists in explicit searchSchema
|
|
250
|
-
if (searchSchema[searchFieldName]) {
|
|
251
|
-
// Skip - explicit searchSchema takes precedence
|
|
252
|
-
// This allows searchSchema to override fields marked with search:true
|
|
253
|
-
return
|
|
254
|
-
}
|
|
255
|
-
|
|
256
274
|
// Simple boolean - copy entire field definition (except search)
|
|
257
275
|
const { search, ...fieldDefWithoutSearch } = fieldDef
|
|
258
|
-
|
|
276
|
+
registerSearchEntries(searchSchema, createRelationshipSearchEntries(fieldName, fieldDef, {
|
|
259
277
|
...fieldDefWithoutSearch,
|
|
260
278
|
// Do not set filterOperator here; centralized resolver will apply sensible defaults
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// For belongsTo relationships, add metadata to map back to the actual field
|
|
264
|
-
if (fieldDef.belongsTo && fieldDef.as) {
|
|
265
|
-
searchEntry.actualField = fieldName // Maps to the DB column
|
|
266
|
-
searchEntry.isRelationship = true
|
|
267
|
-
searchEntry.targetResource = fieldDef.belongsTo
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
searchSchema[searchFieldName] = searchEntry
|
|
279
|
+
}))
|
|
271
280
|
} else if (typeof effectiveSearch === 'object') {
|
|
272
281
|
// Check if search defines multiple filter fields
|
|
273
282
|
const hasNestedFilters = Object.values(effectiveSearch).some(
|
|
@@ -290,29 +299,11 @@ export const generateSearchSchemaFromSchema = (schema, explicitSearchSchema) =>
|
|
|
290
299
|
}
|
|
291
300
|
})
|
|
292
301
|
} else {
|
|
293
|
-
// For belongsTo relationships, use the relationship name (as property) instead of the field name
|
|
294
|
-
const searchFieldName = (fieldDef.belongsTo && fieldDef.as) ? fieldDef.as : fieldName
|
|
295
|
-
|
|
296
|
-
// Check if field already exists in explicit searchSchema
|
|
297
|
-
if (searchSchema[searchFieldName]) {
|
|
298
|
-
// Skip - explicit searchSchema takes precedence
|
|
299
|
-
return
|
|
300
|
-
}
|
|
301
|
-
|
|
302
302
|
// Single filter with config
|
|
303
|
-
|
|
303
|
+
registerSearchEntries(searchSchema, createRelationshipSearchEntries(fieldName, fieldDef, {
|
|
304
304
|
type: fieldDef.type,
|
|
305
305
|
...effectiveSearch
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// For belongsTo relationships, add metadata to map back to the actual field
|
|
309
|
-
if (fieldDef.belongsTo && fieldDef.as) {
|
|
310
|
-
searchEntry.actualField = fieldName // Maps to the DB column
|
|
311
|
-
searchEntry.isRelationship = true
|
|
312
|
-
searchEntry.targetResource = fieldDef.belongsTo
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
searchSchema[searchFieldName] = searchEntry
|
|
306
|
+
}))
|
|
316
307
|
}
|
|
317
308
|
}
|
|
318
309
|
}
|
|
@@ -280,22 +280,15 @@ export const transformSimplifiedToJsonApi = (scope, deps) => {
|
|
|
280
280
|
relationshipsData[relName] = {
|
|
281
281
|
data: value.map(relId => ({ type: targetType, id: String(relId) }))
|
|
282
282
|
}
|
|
283
|
-
}
|
|
284
|
-
// Handle hasOne relationships
|
|
285
|
-
else if (relConfig.type === 'hasOne' && value !== undefined) {
|
|
283
|
+
} else if (relConfig.type === 'hasOne' && value !== undefined) {
|
|
286
284
|
relationshipsData[relName] = {
|
|
287
285
|
data: value ? { type: relConfig.target, id: String(value) } : null
|
|
288
286
|
}
|
|
289
|
-
}
|
|
290
|
-
// Handle single belongsTo relationships if they were also defined in 'relationships'
|
|
291
|
-
// (though typically they are defined in schema.belongsTo)
|
|
292
|
-
else if (relConfig.belongsTo && value !== undefined) {
|
|
287
|
+
} else if (relConfig.belongsTo && value !== undefined) {
|
|
293
288
|
relationshipsData[relName] = {
|
|
294
289
|
data: value ? { type: relConfig.belongsTo, id: String(value) } : null
|
|
295
290
|
}
|
|
296
|
-
}
|
|
297
|
-
// Handle polymorphic belongsTo relationships with minimal object format
|
|
298
|
-
else if (relConfig.belongsToPolymorphic && value !== undefined) {
|
|
291
|
+
} else if (relConfig.belongsToPolymorphic && value !== undefined) {
|
|
299
292
|
if (value === null) {
|
|
300
293
|
relationshipsData[relName] = { data: null }
|
|
301
294
|
} else if (typeof value === 'object' && value !== null && value.id && value._type) {
|
|
@@ -780,7 +773,7 @@ export const transformSingleJsonApiToSimplified = (scope, deps) => {
|
|
|
780
773
|
const schemaEntry = Object.entries(schema).find(([_, def]) => def.as === relName)
|
|
781
774
|
|
|
782
775
|
if (schemaEntry) {
|
|
783
|
-
const [
|
|
776
|
+
const [, fieldDef] = schemaEntry
|
|
784
777
|
if (fieldDef.belongsTo && relData.data) {
|
|
785
778
|
// Create minimal relationship object (no more foreign key field)
|
|
786
779
|
simplified[relName] = { id: relData.data.id }
|
|
@@ -23,9 +23,29 @@ export const translateCanonicalAttributesForStorage = (attributes = {}, descript
|
|
|
23
23
|
return row
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export const getCanonicalResourceIdColumn = (descriptor = {}) => (
|
|
27
|
+
descriptor?.canonical?.logicalIdColumn || 'logical_id'
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
export const getCanonicalResourceId = (row = {}, descriptor = {}) => {
|
|
31
|
+
const logicalIdColumn = getCanonicalResourceIdColumn(descriptor)
|
|
32
|
+
const logicalId = row?.[logicalIdColumn]
|
|
33
|
+
|
|
34
|
+
if (logicalId !== undefined && logicalId !== null) {
|
|
35
|
+
return String(logicalId)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (row?.id !== undefined && row?.id !== null) {
|
|
39
|
+
return String(row.id)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
26
45
|
export const translateCanonicalRecordFromStorage = (row = {}, descriptor = {}, options = {}) => {
|
|
27
46
|
const attributes = {}
|
|
28
|
-
const
|
|
47
|
+
const logicalIdColumn = getCanonicalResourceIdColumn(descriptor)
|
|
48
|
+
const consumedColumns = new Set(['id', logicalIdColumn])
|
|
29
49
|
const allowedExtraFields = new Set(options.allowedExtraFields || [])
|
|
30
50
|
|
|
31
51
|
for (const [slot, logical] of Object.entries(descriptor.reverseAttributes || {})) {
|
|
@@ -96,8 +116,9 @@ export const translateCanonicalRecordFromStorage = (row = {}, descriptor = {}, o
|
|
|
96
116
|
export const getCanonicalFieldValue = (row, descriptor = {}, fieldName) => {
|
|
97
117
|
if (!row || !fieldName) return undefined
|
|
98
118
|
|
|
99
|
-
|
|
100
|
-
|
|
119
|
+
const idProperty = descriptor.idProperty || 'id'
|
|
120
|
+
if (fieldName === 'id' || fieldName === idProperty) {
|
|
121
|
+
return getCanonicalResourceId(row, descriptor)
|
|
101
122
|
}
|
|
102
123
|
|
|
103
124
|
const fieldInfo = descriptor.fields?.[fieldName]
|
|
@@ -6,8 +6,10 @@ import {
|
|
|
6
6
|
} from './storage-mapping.js'
|
|
7
7
|
import {
|
|
8
8
|
getCanonicalFieldValue,
|
|
9
|
+
getCanonicalResourceIdColumn,
|
|
9
10
|
translateCanonicalAttributesForStorage,
|
|
10
11
|
} from './canonical-storage-mapping.js'
|
|
12
|
+
import { normalizeDateValue } from '../querying-writing/database-value-normalizers.js'
|
|
11
13
|
|
|
12
14
|
const passthrough = (value) => value
|
|
13
15
|
const identityTranslate = (_field, value) => value
|
|
@@ -33,6 +35,54 @@ const normalizeBelongsToValue = (value) => {
|
|
|
33
35
|
return String(value)
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
const normalizeBooleanValue = (value) => {
|
|
39
|
+
if (typeof value === 'boolean') return value
|
|
40
|
+
if (typeof value === 'string') {
|
|
41
|
+
const normalized = value.trim().toLowerCase()
|
|
42
|
+
if (normalized === 'true' || normalized === '1') return true
|
|
43
|
+
if (normalized === 'false' || normalized === '0') return false
|
|
44
|
+
}
|
|
45
|
+
if (typeof value === 'number') {
|
|
46
|
+
if (value === 1) return true
|
|
47
|
+
if (value === 0) return false
|
|
48
|
+
}
|
|
49
|
+
return Boolean(value)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const normalizeFilterValueForDefinition = (value, definition = {}, { isRelationship = false } = {}) => {
|
|
53
|
+
if (value === null || value === undefined) return value
|
|
54
|
+
if (Array.isArray(value)) {
|
|
55
|
+
return normalizeArray(
|
|
56
|
+
value,
|
|
57
|
+
(entry) => normalizeFilterValueForDefinition(entry, definition, { isRelationship })
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (isRelationship) {
|
|
62
|
+
return normalizeBelongsToValue(value)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const type = definition?.type || definition?.dataType
|
|
66
|
+
if (!type) {
|
|
67
|
+
return value
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (['number', 'integer', 'float', 'decimal'].includes(type)) {
|
|
71
|
+
const numeric = Number(value)
|
|
72
|
+
return Number.isNaN(numeric) ? value : numeric
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (type === 'boolean') {
|
|
76
|
+
return normalizeBooleanValue(value)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (['date', 'dateTime', 'time'].includes(type)) {
|
|
80
|
+
return normalizeDateValue(value, type)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return value
|
|
84
|
+
}
|
|
85
|
+
|
|
36
86
|
const translateSourceColumn = (source, adapter) => {
|
|
37
87
|
if (!adapter || source === '*') return source
|
|
38
88
|
return adapter.translateColumn(source) || source
|
|
@@ -123,13 +173,24 @@ const createLegacyAdapter = ({ knex, schemaInfo }) => {
|
|
|
123
173
|
const translateColumn = (field) => getStorageColumn(schemaInfo, field)
|
|
124
174
|
const toStorageRow = (attributes, options = {}) => translateAttributesForStorage(attributes, schemaInfo, options)
|
|
125
175
|
const getFieldValue = (record, fieldName) => getLegacyFieldValue(record, schemaInfo, fieldName)
|
|
176
|
+
const translateFilterValue = (field, value) => {
|
|
177
|
+
const searchField = schemaInfo.searchSchemaStructure?.[field]
|
|
178
|
+
const schemaField = schemaInfo.schemaStructure?.[field]
|
|
179
|
+
const definition = searchField || schemaField || {}
|
|
180
|
+
const isRelationship = Boolean(
|
|
181
|
+
searchField?.isRelationship ||
|
|
182
|
+
schemaField?.belongsTo ||
|
|
183
|
+
schemaField?.belongsToPolymorphic
|
|
184
|
+
)
|
|
185
|
+
return normalizeFilterValueForDefinition(value, definition, { isRelationship })
|
|
186
|
+
}
|
|
126
187
|
|
|
127
188
|
return baseAdapter({
|
|
128
189
|
knex,
|
|
129
190
|
tableName,
|
|
130
191
|
idColumn,
|
|
131
192
|
translateColumn,
|
|
132
|
-
translateFilterValue
|
|
193
|
+
translateFilterValue,
|
|
133
194
|
applyResourceScope: identityScope,
|
|
134
195
|
toStorageRow,
|
|
135
196
|
getFieldValue,
|
|
@@ -143,10 +204,15 @@ const createCanonicalAdapter = ({ knex, schemaInfo }) => {
|
|
|
143
204
|
const canonicalFieldMap = descriptor.canonicalFieldMap || {}
|
|
144
205
|
const fieldsInfo = descriptor.fields || {}
|
|
145
206
|
const belongsToInfo = descriptor.belongsTo || {}
|
|
207
|
+
const idProperty = schemaInfo.idProperty || descriptor.idProperty || 'id'
|
|
208
|
+
const isLogicalIdField = (field) => field === 'id' || field === idProperty
|
|
209
|
+
const descriptorWithIdProperty = descriptor.idProperty === idProperty
|
|
210
|
+
? descriptor
|
|
211
|
+
: { ...descriptor, idProperty }
|
|
146
212
|
|
|
147
213
|
const translateColumn = (field) => {
|
|
148
214
|
if (!field) return field
|
|
149
|
-
if (field
|
|
215
|
+
if (isLogicalIdField(field)) return getCanonicalResourceIdColumn(descriptor)
|
|
150
216
|
|
|
151
217
|
const canonicalEntry = canonicalFieldMap[field]
|
|
152
218
|
if (typeof canonicalEntry === 'string') {
|
|
@@ -182,8 +248,6 @@ const createCanonicalAdapter = ({ knex, schemaInfo }) => {
|
|
|
182
248
|
}
|
|
183
249
|
|
|
184
250
|
const translateFilterValue = (field, value) => {
|
|
185
|
-
if (value === null || value === undefined) return value
|
|
186
|
-
|
|
187
251
|
const searchField = schemaInfo.searchSchemaStructure?.[field]
|
|
188
252
|
const schemaField = schemaInfo.schemaStructure?.[field]
|
|
189
253
|
const isRelationship = Boolean(
|
|
@@ -191,15 +255,9 @@ const createCanonicalAdapter = ({ knex, schemaInfo }) => {
|
|
|
191
255
|
schemaField?.belongsTo ||
|
|
192
256
|
schemaField?.belongsToPolymorphic
|
|
193
257
|
)
|
|
258
|
+
const definition = searchField || schemaField || {}
|
|
194
259
|
|
|
195
|
-
|
|
196
|
-
if (Array.isArray(value)) {
|
|
197
|
-
return normalizeArray(value, passthrough)
|
|
198
|
-
}
|
|
199
|
-
return value
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
return normalizeBelongsToValue(value)
|
|
260
|
+
return normalizeFilterValueForDefinition(value, definition, { isRelationship })
|
|
203
261
|
}
|
|
204
262
|
|
|
205
263
|
const applyResourceScope = (query) => {
|
|
@@ -209,12 +267,16 @@ const createCanonicalAdapter = ({ knex, schemaInfo }) => {
|
|
|
209
267
|
}
|
|
210
268
|
|
|
211
269
|
const toStorageRow = (attributes) => translateCanonicalAttributesForStorage(attributes, descriptor)
|
|
212
|
-
const getFieldValue = (record, fieldName) =>
|
|
270
|
+
const getFieldValue = (record, fieldName) => (
|
|
271
|
+
isLogicalIdField(fieldName)
|
|
272
|
+
? getCanonicalFieldValue(record, descriptorWithIdProperty, fieldName)
|
|
273
|
+
: getCanonicalFieldValue(record, descriptor, fieldName)
|
|
274
|
+
)
|
|
213
275
|
|
|
214
276
|
return baseAdapter({
|
|
215
277
|
knex,
|
|
216
278
|
tableName: canonical.tableName,
|
|
217
|
-
idColumn:
|
|
279
|
+
idColumn: getCanonicalResourceIdColumn(descriptor),
|
|
218
280
|
translateColumn,
|
|
219
281
|
translateFilterValue,
|
|
220
282
|
applyResourceScope,
|