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.
Files changed (84) hide show
  1. package/README.md +2 -1
  2. package/docs/API.md +12 -9
  3. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +5 -2
  4. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +3 -3
  5. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +2 -0
  6. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1 -1
  7. package/docs/GUIDE/GUIDE_X_Autofiltering.md +63 -0
  8. package/docs/GUIDE/GUIDE_X_File_Uploads.md +17 -12
  9. package/docs/GUIDE/GUIDE_X_Row_Policies.md +605 -0
  10. package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
  11. package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
  12. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
  13. package/docs/GUIDE/index.md +4 -1
  14. package/fixing_plan.md +112 -0
  15. package/index.js +2 -0
  16. package/package.json +1 -1
  17. package/plugins/core/bulk-operations-plugin.js +5 -4
  18. package/plugins/core/connectors/express-plugin.js +6 -1
  19. package/plugins/core/connectors/fastify-plugin.js +5 -1
  20. package/plugins/core/connectors/lib/connector-core.js +17 -11
  21. package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
  22. package/plugins/core/file-handling-plugin.js +126 -96
  23. package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
  24. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +36 -26
  25. package/plugins/core/lib/anyapi/schema-utils.js +1 -0
  26. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +7 -1
  27. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -15
  28. package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
  29. package/plugins/core/lib/querying/knex-relationship-includes.js +29 -28
  30. package/plugins/core/lib/querying/knex-window-queries.js +10 -77
  31. package/plugins/core/lib/querying/storage-adapter-utils.js +8 -8
  32. package/plugins/core/lib/querying/url-helpers.js +9 -21
  33. package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
  34. package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
  35. package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
  36. package/plugins/core/lib/querying-writing/knex-field-helpers.js +6 -1
  37. package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
  38. package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
  39. package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
  40. package/plugins/core/lib/querying-writing/schema-helpers.js +34 -41
  41. package/plugins/core/lib/storage/canonical-storage-mapping.js +2 -1
  42. package/plugins/core/lib/storage/storage-adapter.js +74 -13
  43. package/plugins/core/lib/writing/many-to-many-manipulations.js +4 -3
  44. package/plugins/core/lib/writing/relationship-processor.js +10 -1
  45. package/plugins/core/rest-api-anyapi-knex-plugin.js +210 -97
  46. package/plugins/core/rest-api-knex-plugin.js +70 -60
  47. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +8 -2
  48. package/plugins/core/rest-api-plugin-methods/common.js +85 -63
  49. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +23 -13
  50. package/plugins/core/rest-api-plugin-methods/delete.js +2 -6
  51. package/plugins/core/rest-api-plugin-methods/get-related.js +15 -13
  52. package/plugins/core/rest-api-plugin-methods/get-relationship.js +1 -1
  53. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +17 -6
  54. package/plugins/core/rest-api-plugin-methods/patch.js +3 -6
  55. package/plugins/core/rest-api-plugin-methods/post-relationship.js +24 -14
  56. package/plugins/core/rest-api-plugin-methods/post.js +4 -7
  57. package/plugins/core/rest-api-plugin-methods/put.js +4 -7
  58. package/plugins/core/rest-api-plugin.js +6 -36
  59. package/plugins/core/rest-api-row-policy-plugin.js +157 -0
  60. package/plugins/storage/local-storage.js +44 -19
  61. package/plugins/storage/s3-storage.js +21 -55
  62. package/tests/README.md +1 -1
  63. package/tests/anyapi-cursor-pagination.test.js +7 -1
  64. package/tests/anyapi-custom-idproperty.test.js +163 -0
  65. package/tests/fastify-plugin.test.js +28 -0
  66. package/tests/field-setters.test.js +10 -10
  67. package/tests/file-handling.test.js +118 -0
  68. package/tests/fixtures/api-configs.js +383 -4
  69. package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
  70. package/tests/http-connectors-parity.test.js +165 -5
  71. package/tests/local-storage.test.js +96 -0
  72. package/tests/pagination-cursor-multifield.test.js +121 -0
  73. package/tests/pagination-enhanced.test.js +66 -15
  74. package/tests/post-commit-hooks.test.js +120 -0
  75. package/tests/public-exports.test.js +15 -0
  76. package/tests/put-create-parity.test.js +216 -0
  77. package/tests/relationship-endpoints.test.js +259 -0
  78. package/tests/return-record-settings.test.js +184 -0
  79. package/tests/row-policy.test.js +489 -0
  80. package/tests/s3-storage.test.js +38 -0
  81. package/tests/searchschema-merge.test.js +17 -0
  82. package/tests/socketio.test.js +62 -0
  83. package/tests/storage-mapping.test.js +1 -0
  84. 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
- * - buildWindowedIncludeQuery to validate before using window functions
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(value) &&
88
- !value.includes('T') &&
89
- !value.includes('Z') &&
90
- !value.includes('+') &&
91
- !value.includes('-', 10) // Don't match date separators
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(value.replace(' ', 'T') + 'Z')
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(value)) {
102
- return new Date(value + 'T00:00:00Z')
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: true,
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
- const searchEntry = {
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
- const searchEntry = {
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
- if (fieldName === 'id') {
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: identityTranslate,
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 === 'id') return getCanonicalResourceIdColumn(descriptor)
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
- if (!isRelationship) {
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) => getCanonicalFieldValue(record, descriptor, 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 || [] // null means empty array for many-to-many
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