json-rest-api 1.0.24 → 1.0.26
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 +2 -1
- package/docs/API.md +12 -9
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +5 -2
- 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_Row_Policies.md +605 -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/package.json +1 -1
- package/plugins/core/bulk-operations-plugin.js +5 -4
- package/plugins/core/connectors/express-plugin.js +6 -1
- package/plugins/core/connectors/fastify-plugin.js +5 -1
- 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/query/anyapi-query-adapter.js +36 -26
- package/plugins/core/lib/anyapi/schema-utils.js +1 -0
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +7 -1
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -15
- package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
- package/plugins/core/lib/querying/knex-relationship-includes.js +29 -28
- package/plugins/core/lib/querying/knex-window-queries.js +10 -77
- package/plugins/core/lib/querying/storage-adapter-utils.js +8 -8
- package/plugins/core/lib/querying/url-helpers.js +9 -21
- 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 +6 -1
- 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/return-record-settings.js +47 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +34 -41
- package/plugins/core/lib/storage/canonical-storage-mapping.js +2 -1
- package/plugins/core/lib/storage/storage-adapter.js +74 -13
- package/plugins/core/lib/writing/many-to-many-manipulations.js +4 -3
- package/plugins/core/lib/writing/relationship-processor.js +10 -1
- package/plugins/core/rest-api-anyapi-knex-plugin.js +210 -97
- package/plugins/core/rest-api-knex-plugin.js +70 -60
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +8 -2
- package/plugins/core/rest-api-plugin-methods/common.js +85 -63
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +23 -13
- package/plugins/core/rest-api-plugin-methods/delete.js +2 -6
- package/plugins/core/rest-api-plugin-methods/get-related.js +15 -13
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +1 -1
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +17 -6
- package/plugins/core/rest-api-plugin-methods/patch.js +3 -6
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +24 -14
- package/plugins/core/rest-api-plugin-methods/post.js +4 -7
- package/plugins/core/rest-api-plugin-methods/put.js +4 -7
- package/plugins/core/rest-api-plugin.js +6 -36
- package/plugins/core/rest-api-row-policy-plugin.js +157 -0
- package/plugins/storage/local-storage.js +44 -19
- package/plugins/storage/s3-storage.js +21 -55
- package/tests/README.md +1 -1
- package/tests/anyapi-cursor-pagination.test.js +7 -1
- package/tests/anyapi-custom-idproperty.test.js +163 -0
- package/tests/fastify-plugin.test.js +28 -0
- package/tests/field-setters.test.js +10 -10
- package/tests/file-handling.test.js +118 -0
- package/tests/fixtures/api-configs.js +383 -4
- package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
- package/tests/http-connectors-parity.test.js +165 -5
- package/tests/local-storage.test.js +96 -0
- package/tests/pagination-cursor-multifield.test.js +121 -0
- 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 +489 -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 +1 -0
- package/tests/test-development-guide.md +5 -5
|
@@ -63,7 +63,7 @@ const parseVersion = (versionString) => {
|
|
|
63
63
|
*
|
|
64
64
|
* @description
|
|
65
65
|
* Used by:
|
|
66
|
-
* -
|
|
66
|
+
* - buildWindowedIncludeSubquery to validate before using window functions
|
|
67
67
|
* - rest-api-knex-plugin to determine query strategies
|
|
68
68
|
*
|
|
69
69
|
* Purpose:
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
* 4. Handles MySQL datetime format (no T separator)
|
|
49
49
|
* 5. Forces UTC interpretation to prevent timezone issues
|
|
50
50
|
*/
|
|
51
|
-
function normalizeDateValue (value, type) {
|
|
51
|
+
export function normalizeDateValue (value, type) {
|
|
52
52
|
// Handle null/undefined
|
|
53
53
|
if (value === null || value === undefined) {
|
|
54
54
|
return null
|
|
@@ -81,25 +81,34 @@ function normalizeDateValue (value, type) {
|
|
|
81
81
|
|
|
82
82
|
// Handle string values
|
|
83
83
|
if (typeof value === 'string') {
|
|
84
|
+
const trimmed = value.trim()
|
|
85
|
+
|
|
86
|
+
if (/^-?\d+$/.test(trimmed)) {
|
|
87
|
+
const numericDate = new Date(Number(trimmed))
|
|
88
|
+
if (!isNaN(numericDate.getTime())) {
|
|
89
|
+
return numericDate
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
84
93
|
// Detect MySQL datetime format: 'YYYY-MM-DD HH:MM:SS'
|
|
85
94
|
// These have no T separator and no timezone indicator
|
|
86
95
|
const isMySQLDateTime = type === 'dateTime' &&
|
|
87
|
-
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(
|
|
88
|
-
!
|
|
89
|
-
!
|
|
90
|
-
!
|
|
91
|
-
!
|
|
96
|
+
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(trimmed) &&
|
|
97
|
+
!trimmed.includes('T') &&
|
|
98
|
+
!trimmed.includes('Z') &&
|
|
99
|
+
!trimmed.includes('+') &&
|
|
100
|
+
!trimmed.includes('-', 10) // Don't match date separators
|
|
92
101
|
|
|
93
102
|
if (isMySQLDateTime) {
|
|
94
103
|
// Convert to ISO format and force UTC interpretation
|
|
95
104
|
// '2024-01-15 10:30:00' becomes '2024-01-15T10:30:00Z'
|
|
96
|
-
return new Date(
|
|
105
|
+
return new Date(trimmed.replace(' ', 'T') + 'Z')
|
|
97
106
|
}
|
|
98
107
|
|
|
99
108
|
// For date-only fields, ensure parsing at UTC midnight
|
|
100
109
|
// This prevents timezone shifts when parsing dates
|
|
101
|
-
if (type === 'date' && /^\d{4}-\d{2}-\d{2}$/.test(
|
|
102
|
-
return new Date(
|
|
110
|
+
if (type === 'date' && /^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
|
111
|
+
return new Date(trimmed + 'T00:00:00Z')
|
|
103
112
|
}
|
|
104
113
|
}
|
|
105
114
|
|
|
@@ -150,7 +150,8 @@ export const buildFieldSelection = async (scope, deps) => {
|
|
|
150
150
|
const scopeSchemaInfo = scope?.vars?.schemaInfo || {}
|
|
151
151
|
const {
|
|
152
152
|
schemaInstance,
|
|
153
|
-
computed: computedFields = {}
|
|
153
|
+
computed: computedFields = {},
|
|
154
|
+
idProperty = 'id'
|
|
154
155
|
} = scopeSchemaInfo
|
|
155
156
|
let schemaStructure = scopeSchemaInfo.schemaStructure
|
|
156
157
|
const queryFields = scope?.vars?.queryFields || {}
|
|
@@ -196,6 +197,8 @@ export const buildFieldSelection = async (scope, deps) => {
|
|
|
196
197
|
// Sparse fieldsets requested - only select specified fields
|
|
197
198
|
// Example: ?fields[products]=name,price,profit_margin
|
|
198
199
|
requested.forEach(field => {
|
|
200
|
+
if (field === 'id' || field === idProperty) return
|
|
201
|
+
|
|
199
202
|
if (queryFieldNames.has(field)) {
|
|
200
203
|
if (queryFields[field]?.hidden === true) return
|
|
201
204
|
queryFieldsToSelect.add(field)
|
|
@@ -261,6 +264,8 @@ export const buildFieldSelection = async (scope, deps) => {
|
|
|
261
264
|
// No sparse fieldsets - return all visible fields
|
|
262
265
|
// This is the default behavior when no ?fields parameter is provided
|
|
263
266
|
Object.entries(schemaStructure).forEach(([field, fieldDef]) => {
|
|
267
|
+
if (field === 'id' || field === idProperty) return
|
|
268
|
+
|
|
264
269
|
// Skip virtual fields - they don't exist in database
|
|
265
270
|
if (fieldDef.virtual === true) return
|
|
266
271
|
|
|
@@ -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,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
|
+
}
|
|
@@ -96,6 +96,36 @@ export function ensureSearchFieldsAreIndexed (searchSchema) {
|
|
|
96
96
|
})
|
|
97
97
|
}
|
|
98
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
|
+
|
|
99
129
|
/**
|
|
100
130
|
* Generates complete searchSchema by merging schema search definitions with explicit searchSchema
|
|
101
131
|
*
|
|
@@ -241,31 +271,12 @@ export const generateSearchSchemaFromSchema = (schema, explicitSearchSchema) =>
|
|
|
241
271
|
|
|
242
272
|
if (effectiveSearch) {
|
|
243
273
|
if (effectiveSearch === true) {
|
|
244
|
-
// For belongsTo relationships, use the relationship name (as property) instead of the field name
|
|
245
|
-
const searchFieldName = (fieldDef.belongsTo && fieldDef.as) ? fieldDef.as : fieldName
|
|
246
|
-
|
|
247
|
-
// Check if field already exists in explicit searchSchema
|
|
248
|
-
if (searchSchema[searchFieldName]) {
|
|
249
|
-
// Skip - explicit searchSchema takes precedence
|
|
250
|
-
// This allows searchSchema to override fields marked with search:true
|
|
251
|
-
return
|
|
252
|
-
}
|
|
253
|
-
|
|
254
274
|
// Simple boolean - copy entire field definition (except search)
|
|
255
275
|
const { search, ...fieldDefWithoutSearch } = fieldDef
|
|
256
|
-
|
|
276
|
+
registerSearchEntries(searchSchema, createRelationshipSearchEntries(fieldName, fieldDef, {
|
|
257
277
|
...fieldDefWithoutSearch,
|
|
258
278
|
// Do not set filterOperator here; centralized resolver will apply sensible defaults
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// For belongsTo relationships, add metadata to map back to the actual field
|
|
262
|
-
if (fieldDef.belongsTo && fieldDef.as) {
|
|
263
|
-
searchEntry.actualField = fieldName // Maps to the DB column
|
|
264
|
-
searchEntry.isRelationship = true
|
|
265
|
-
searchEntry.targetResource = fieldDef.belongsTo
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
searchSchema[searchFieldName] = searchEntry
|
|
279
|
+
}))
|
|
269
280
|
} else if (typeof effectiveSearch === 'object') {
|
|
270
281
|
// Check if search defines multiple filter fields
|
|
271
282
|
const hasNestedFilters = Object.values(effectiveSearch).some(
|
|
@@ -288,29 +299,11 @@ export const generateSearchSchemaFromSchema = (schema, explicitSearchSchema) =>
|
|
|
288
299
|
}
|
|
289
300
|
})
|
|
290
301
|
} else {
|
|
291
|
-
// For belongsTo relationships, use the relationship name (as property) instead of the field name
|
|
292
|
-
const searchFieldName = (fieldDef.belongsTo && fieldDef.as) ? fieldDef.as : fieldName
|
|
293
|
-
|
|
294
|
-
// Check if field already exists in explicit searchSchema
|
|
295
|
-
if (searchSchema[searchFieldName]) {
|
|
296
|
-
// Skip - explicit searchSchema takes precedence
|
|
297
|
-
return
|
|
298
|
-
}
|
|
299
|
-
|
|
300
302
|
// Single filter with config
|
|
301
|
-
|
|
303
|
+
registerSearchEntries(searchSchema, createRelationshipSearchEntries(fieldName, fieldDef, {
|
|
302
304
|
type: fieldDef.type,
|
|
303
305
|
...effectiveSearch
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// For belongsTo relationships, add metadata to map back to the actual field
|
|
307
|
-
if (fieldDef.belongsTo && fieldDef.as) {
|
|
308
|
-
searchEntry.actualField = fieldName // Maps to the DB column
|
|
309
|
-
searchEntry.isRelationship = true
|
|
310
|
-
searchEntry.targetResource = fieldDef.belongsTo
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
searchSchema[searchFieldName] = searchEntry
|
|
306
|
+
}))
|
|
314
307
|
}
|
|
315
308
|
}
|
|
316
309
|
}
|
|
@@ -116,7 +116,8 @@ export const translateCanonicalRecordFromStorage = (row = {}, descriptor = {}, o
|
|
|
116
116
|
export const getCanonicalFieldValue = (row, descriptor = {}, fieldName) => {
|
|
117
117
|
if (!row || !fieldName) return undefined
|
|
118
118
|
|
|
119
|
-
|
|
119
|
+
const idProperty = descriptor.idProperty || 'id'
|
|
120
|
+
if (fieldName === 'id' || fieldName === idProperty) {
|
|
120
121
|
return getCanonicalResourceId(row, descriptor)
|
|
121
122
|
}
|
|
122
123
|
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getCanonicalResourceIdColumn,
|
|
10
10
|
translateCanonicalAttributesForStorage,
|
|
11
11
|
} from './canonical-storage-mapping.js'
|
|
12
|
+
import { normalizeDateValue } from '../querying-writing/database-value-normalizers.js'
|
|
12
13
|
|
|
13
14
|
const passthrough = (value) => value
|
|
14
15
|
const identityTranslate = (_field, value) => value
|
|
@@ -34,6 +35,54 @@ const normalizeBelongsToValue = (value) => {
|
|
|
34
35
|
return String(value)
|
|
35
36
|
}
|
|
36
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
|
+
|
|
37
86
|
const translateSourceColumn = (source, adapter) => {
|
|
38
87
|
if (!adapter || source === '*') return source
|
|
39
88
|
return adapter.translateColumn(source) || source
|
|
@@ -124,13 +173,24 @@ const createLegacyAdapter = ({ knex, schemaInfo }) => {
|
|
|
124
173
|
const translateColumn = (field) => getStorageColumn(schemaInfo, field)
|
|
125
174
|
const toStorageRow = (attributes, options = {}) => translateAttributesForStorage(attributes, schemaInfo, options)
|
|
126
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
|
+
}
|
|
127
187
|
|
|
128
188
|
return baseAdapter({
|
|
129
189
|
knex,
|
|
130
190
|
tableName,
|
|
131
191
|
idColumn,
|
|
132
192
|
translateColumn,
|
|
133
|
-
translateFilterValue
|
|
193
|
+
translateFilterValue,
|
|
134
194
|
applyResourceScope: identityScope,
|
|
135
195
|
toStorageRow,
|
|
136
196
|
getFieldValue,
|
|
@@ -144,10 +204,15 @@ const createCanonicalAdapter = ({ knex, schemaInfo }) => {
|
|
|
144
204
|
const canonicalFieldMap = descriptor.canonicalFieldMap || {}
|
|
145
205
|
const fieldsInfo = descriptor.fields || {}
|
|
146
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 }
|
|
147
212
|
|
|
148
213
|
const translateColumn = (field) => {
|
|
149
214
|
if (!field) return field
|
|
150
|
-
if (field
|
|
215
|
+
if (isLogicalIdField(field)) return getCanonicalResourceIdColumn(descriptor)
|
|
151
216
|
|
|
152
217
|
const canonicalEntry = canonicalFieldMap[field]
|
|
153
218
|
if (typeof canonicalEntry === 'string') {
|
|
@@ -183,8 +248,6 @@ const createCanonicalAdapter = ({ knex, schemaInfo }) => {
|
|
|
183
248
|
}
|
|
184
249
|
|
|
185
250
|
const translateFilterValue = (field, value) => {
|
|
186
|
-
if (value === null || value === undefined) return value
|
|
187
|
-
|
|
188
251
|
const searchField = schemaInfo.searchSchemaStructure?.[field]
|
|
189
252
|
const schemaField = schemaInfo.schemaStructure?.[field]
|
|
190
253
|
const isRelationship = Boolean(
|
|
@@ -192,15 +255,9 @@ const createCanonicalAdapter = ({ knex, schemaInfo }) => {
|
|
|
192
255
|
schemaField?.belongsTo ||
|
|
193
256
|
schemaField?.belongsToPolymorphic
|
|
194
257
|
)
|
|
258
|
+
const definition = searchField || schemaField || {}
|
|
195
259
|
|
|
196
|
-
|
|
197
|
-
if (Array.isArray(value)) {
|
|
198
|
-
return normalizeArray(value, passthrough)
|
|
199
|
-
}
|
|
200
|
-
return value
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
return normalizeBelongsToValue(value)
|
|
260
|
+
return normalizeFilterValueForDefinition(value, definition, { isRelationship })
|
|
204
261
|
}
|
|
205
262
|
|
|
206
263
|
const applyResourceScope = (query) => {
|
|
@@ -210,7 +267,11 @@ const createCanonicalAdapter = ({ knex, schemaInfo }) => {
|
|
|
210
267
|
}
|
|
211
268
|
|
|
212
269
|
const toStorageRow = (attributes) => translateCanonicalAttributesForStorage(attributes, descriptor)
|
|
213
|
-
const getFieldValue = (record, fieldName) =>
|
|
270
|
+
const getFieldValue = (record, fieldName) => (
|
|
271
|
+
isLogicalIdField(fieldName)
|
|
272
|
+
? getCanonicalFieldValue(record, descriptorWithIdProperty, fieldName)
|
|
273
|
+
: getCanonicalFieldValue(record, descriptor, fieldName)
|
|
274
|
+
)
|
|
214
275
|
|
|
215
276
|
return baseAdapter({
|
|
216
277
|
knex,
|
|
@@ -136,7 +136,7 @@ export const updateManyToManyRelationship = async (scope, deps) => {
|
|
|
136
136
|
await api.resources[related.type].get({
|
|
137
137
|
id: related.id,
|
|
138
138
|
transaction: trx
|
|
139
|
-
})
|
|
139
|
+
}, { ...context })
|
|
140
140
|
} catch (error) {
|
|
141
141
|
throw new RestApiResourceError(
|
|
142
142
|
`Related ${related.type} with id ${related.id} not found`,
|
|
@@ -184,6 +184,7 @@ export const updateManyToManyRelationship = async (scope, deps) => {
|
|
|
184
184
|
* @param {Object} relDef - The relationship definition
|
|
185
185
|
* @param {Array} relData - Array of related resources to link
|
|
186
186
|
* @param {Object} trx - Database transaction object
|
|
187
|
+
* @param {Object} [context] - Caller context forwarded to relationship validation
|
|
187
188
|
* @returns {Promise<void>}
|
|
188
189
|
*
|
|
189
190
|
* @example
|
|
@@ -257,7 +258,7 @@ export const updateManyToManyRelationship = async (scope, deps) => {
|
|
|
257
258
|
* 5. Performs single INSERT with all records
|
|
258
259
|
* 6. Returns (no data returned, throws on error)
|
|
259
260
|
*/
|
|
260
|
-
export const createPivotRecords = async (api, resourceId, relDef, relData, trx) => {
|
|
261
|
+
export const createPivotRecords = async (api, resourceId, relDef, relData, trx, context = {}) => {
|
|
261
262
|
relData = normalizeRelationshipIdentifiers(relData, { api })
|
|
262
263
|
if (relData.length === 0) return // Early exit if nothing to create
|
|
263
264
|
|
|
@@ -277,7 +278,7 @@ export const createPivotRecords = async (api, resourceId, relDef, relData, trx)
|
|
|
277
278
|
await api.resources[related.type].get({
|
|
278
279
|
id: related.id,
|
|
279
280
|
transaction: trx
|
|
280
|
-
})
|
|
281
|
+
}, { ...context })
|
|
281
282
|
} catch (error) {
|
|
282
283
|
throw new RestApiResourceError(
|
|
283
284
|
`Related ${related.type} with id ${related.id} not found`,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RestApiValidationError } from '../../../../lib/rest-api-errors.js'
|
|
2
|
+
import { validateRelationshipDataCardinality } from '../querying-writing/relationship-contracts.js'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Processes JSON:API relationship data and converts to database operations
|
|
@@ -183,6 +184,14 @@ export const processRelationships = (scope, deps) => {
|
|
|
183
184
|
fieldDef.as === relName
|
|
184
185
|
)
|
|
185
186
|
|
|
187
|
+
if (schemaField || relDef) {
|
|
188
|
+
validateRelationshipDataCardinality({
|
|
189
|
+
relationshipName: relName,
|
|
190
|
+
relDef: schemaField ? schemaField[1] : relDef,
|
|
191
|
+
data: relData?.data
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
186
195
|
if (schemaField) {
|
|
187
196
|
const [fieldName, fieldDef] = schemaField
|
|
188
197
|
|
|
@@ -235,7 +244,7 @@ export const processRelationships = (scope, deps) => {
|
|
|
235
244
|
foreignKey: relDef.foreignKey,
|
|
236
245
|
otherKey: relDef.otherKey
|
|
237
246
|
},
|
|
238
|
-
relData: relData.data
|
|
247
|
+
relData: relData.data
|
|
239
248
|
})
|
|
240
249
|
}
|
|
241
250
|
// Note: hasOne and hasMany don't need processing here as they don't update the current record
|