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.
Files changed (106) hide show
  1. package/README.md +24 -2
  2. package/docs/API.md +157 -33
  3. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +88 -6
  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_Knex_Schema_And_Migrations.md +122 -2
  10. package/docs/GUIDE/GUIDE_X_Row_Policies.md +603 -0
  11. package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
  12. package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
  13. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
  14. package/docs/GUIDE/index.md +4 -1
  15. package/fixing_plan.md +112 -0
  16. package/index.js +2 -0
  17. package/lib/enhanced-logger.js +1 -1
  18. package/package.json +6 -2
  19. package/plugins/core/bulk-operations-plugin.js +31 -38
  20. package/plugins/core/connectors/express-plugin.js +6 -7
  21. package/plugins/core/connectors/fastify-plugin.js +5 -1
  22. package/plugins/core/connectors/lib/busboy-detector.js +4 -4
  23. package/plugins/core/connectors/lib/connector-core.js +17 -11
  24. package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
  25. package/plugins/core/file-handling-plugin.js +126 -96
  26. package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
  27. package/plugins/core/lib/anyapi/schema-utils.js +44 -4
  28. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +11 -2
  29. package/plugins/core/lib/dbIntrospection.js +1 -1
  30. package/plugins/core/lib/dbTablesOperations.js +10 -3
  31. package/plugins/core/lib/querying/knex-cross-table-search.js +0 -7
  32. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -16
  33. package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
  34. package/plugins/core/lib/querying/knex-query-helpers.js +5 -6
  35. package/plugins/core/lib/querying/knex-relationship-includes.js +40 -46
  36. package/plugins/core/lib/querying/knex-window-queries.js +10 -77
  37. package/plugins/core/lib/querying/url-helpers.js +9 -21
  38. package/plugins/core/lib/querying-writing/compile-schemas.js +10 -6
  39. package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
  40. package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
  41. package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
  42. package/plugins/core/lib/querying-writing/knex-field-helpers.js +7 -3
  43. package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
  44. package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
  45. package/plugins/core/lib/querying-writing/resource-id-normalization.js +131 -0
  46. package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
  47. package/plugins/core/lib/querying-writing/schema-helpers.js +34 -43
  48. package/plugins/core/lib/querying-writing/simplified-helpers.js +4 -11
  49. package/plugins/core/lib/storage/canonical-storage-mapping.js +24 -3
  50. package/plugins/core/lib/storage/storage-adapter.js +76 -14
  51. package/plugins/core/lib/storage/storage-mapping.js +86 -16
  52. package/plugins/core/lib/writing/many-to-many-manipulations.js +8 -5
  53. package/plugins/core/lib/writing/relationship-processor.js +10 -1
  54. package/plugins/core/rest-api-anyapi-knex-plugin.js +402 -197
  55. package/plugins/core/rest-api-knex-plugin.js +93 -76
  56. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +9 -2
  57. package/plugins/core/rest-api-plugin-methods/common.js +112 -72
  58. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +34 -15
  59. package/plugins/core/rest-api-plugin-methods/delete.js +8 -7
  60. package/plugins/core/rest-api-plugin-methods/get-related.js +22 -15
  61. package/plugins/core/rest-api-plugin-methods/get-relationship.js +8 -3
  62. package/plugins/core/rest-api-plugin-methods/get.js +7 -1
  63. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +26 -8
  64. package/plugins/core/rest-api-plugin-methods/patch.js +34 -9
  65. package/plugins/core/rest-api-plugin-methods/post-relationship.js +35 -16
  66. package/plugins/core/rest-api-plugin-methods/post.js +12 -7
  67. package/plugins/core/rest-api-plugin-methods/put.js +35 -10
  68. package/plugins/core/rest-api-plugin-methods/release.js +0 -2
  69. package/plugins/core/rest-api-plugin.js +11 -37
  70. package/plugins/core/rest-api-positioning-plugin.js +1 -4
  71. package/plugins/core/rest-api-row-policy-plugin.js +157 -0
  72. package/plugins/core/socketio-plugin.js +0 -1
  73. package/plugins/storage/local-storage.js +45 -20
  74. package/plugins/storage/s3-storage.js +23 -54
  75. package/quickTest.js +1 -0
  76. package/scripts/run-docs.js +80 -84
  77. package/test-debug.js +1 -1
  78. package/tests/README.md +8 -1
  79. package/tests/anyapi-basic.test.js +3 -0
  80. package/tests/anyapi-cursor-pagination.test.js +7 -1
  81. package/tests/anyapi-custom-idproperty.test.js +163 -0
  82. package/tests/custom-id-mysql-returning-fallback.test.js +128 -0
  83. package/tests/db-introspection.test.js +4 -1
  84. package/tests/db-table-operations.test.js +25 -20
  85. package/tests/fastify-plugin.test.js +49 -6
  86. package/tests/field-setters.test.js +10 -10
  87. package/tests/file-handling.test.js +118 -0
  88. package/tests/fixtures/api-configs.js +495 -4
  89. package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
  90. package/tests/http-connectors-parity.test.js +165 -5
  91. package/tests/id-normalization.test.js +444 -0
  92. package/tests/local-storage.test.js +96 -0
  93. package/tests/logical-id-write-validation.test.js +151 -0
  94. package/tests/pagination-cursor-multifield.test.js +132 -11
  95. package/tests/pagination-enhanced.test.js +66 -15
  96. package/tests/post-commit-hooks.test.js +120 -0
  97. package/tests/public-exports.test.js +15 -0
  98. package/tests/put-create-parity.test.js +216 -0
  99. package/tests/relationship-endpoints.test.js +259 -0
  100. package/tests/return-record-settings.test.js +184 -0
  101. package/tests/row-policy.test.js +433 -0
  102. package/tests/s3-storage.test.js +38 -0
  103. package/tests/searchschema-merge.test.js +17 -0
  104. package/tests/socketio.test.js +62 -0
  105. package/tests/storage-mapping.test.js +56 -6
  106. 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: 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,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
- const searchEntry = {
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
- const searchEntry = {
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 [fieldName, fieldDef] = schemaEntry
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 consumedColumns = new Set(['id'])
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
- if (fieldName === 'id') {
100
- return row.id
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: identityTranslate,
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 === 'id') return 'id'
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
- if (!isRelationship) {
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) => getCanonicalFieldValue(record, descriptor, 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: 'id',
279
+ idColumn: getCanonicalResourceIdColumn(descriptor),
218
280
  translateColumn,
219
281
  translateFilterValue,
220
282
  applyResourceScope,