json-rest-api 1.0.27 → 1.0.29

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 (39) hide show
  1. package/docs/API.md +2 -0
  2. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +11 -2
  3. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +3 -3
  4. package/docs/GUIDE/GUIDE_Y_Appendices.md +57 -29
  5. package/index.js +1 -0
  6. package/lib/rest-api-errors.js +35 -0
  7. package/package.json +2 -2
  8. package/plugins/core/connectors/lib/transport-http-helpers.js +12 -1
  9. package/plugins/core/file-handling-plugin.js +3 -0
  10. package/plugins/core/lib/anyapi/schema-utils.js +2 -1
  11. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +4 -2
  12. package/plugins/core/lib/dbTablesOperations.js +20 -10
  13. package/plugins/core/lib/querying/knex-pagination-helpers.js +29 -13
  14. package/plugins/core/lib/querying/knex-relationship-includes.js +18 -7
  15. package/plugins/core/lib/querying/query-field-sort-helpers.js +93 -15
  16. package/plugins/core/lib/querying/sort-helpers.js +8 -1
  17. package/plugins/core/lib/querying-writing/database-value-normalizers.js +297 -143
  18. package/plugins/core/lib/querying-writing/knex-field-helpers.js +15 -1
  19. package/plugins/core/lib/querying-writing/request-contracts.js +16 -6
  20. package/plugins/core/lib/storage/canonical-storage-mapping.js +11 -1
  21. package/plugins/core/lib/storage/storage-adapter.js +19 -3
  22. package/plugins/core/lib/storage/storage-mapping.js +7 -1
  23. package/plugins/core/rest-api-anyapi-knex-plugin.js +36 -60
  24. package/plugins/core/rest-api-knex-plugin.js +28 -20
  25. package/plugins/core/rest-api-plugin-methods/common.js +15 -0
  26. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +3 -0
  27. package/plugins/core/rest-api-plugin-methods/get.js +8 -1
  28. package/plugins/core/rest-api-plugin-methods/query.js +8 -1
  29. package/tests/anyapi-cursor-pagination.test.js +1 -3
  30. package/tests/computed-fields.test.js +21 -0
  31. package/tests/db-table-operations.test.js +13 -1
  32. package/tests/file-handling.test.js +37 -0
  33. package/tests/fixtures/api-configs.js +140 -3
  34. package/tests/pagination-boundaries.test.js +154 -0
  35. package/tests/pagination-contract.test.js +122 -0
  36. package/tests/pagination-cursor-multifield.test.js +5 -3
  37. package/tests/relationship-include-storage-adapter.test.js +56 -0
  38. package/tests/storage-mapping.test.js +112 -10
  39. package/tests/temporal-boundaries.test.js +208 -0
@@ -90,9 +90,20 @@ import {
90
90
  getIdColumn as getStorageIdColumn,
91
91
  getStorageColumn as getMappedStorageColumn,
92
92
  } from '../storage/storage-mapping.js'
93
+ import { createStorageAdapter } from '../storage/storage-adapter.js'
93
94
 
94
95
  const getScopeStorageAdapter = (scopes, scopeName) => scopes[scopeName]?.vars?.storageAdapter || null
95
96
 
97
+ const resolveScopeStorageAdapter = (scopes, scopeName, knex) => {
98
+ const configuredAdapter = getScopeStorageAdapter(scopes, scopeName)
99
+ if (configuredAdapter) return configuredAdapter
100
+
101
+ const schemaInfo = scopes[scopeName]?.vars?.schemaInfo
102
+ if (!schemaInfo) return null
103
+
104
+ return createStorageAdapter({ knex, schemaInfo })
105
+ }
106
+
96
107
  const getIdColumnForScope = (scopes, scopeName) => {
97
108
  const adapter = getScopeStorageAdapter(scopes, scopeName)
98
109
  if (adapter?.getIdColumn) {
@@ -162,7 +173,7 @@ const applyScopeFiltersToIncludeQuery = async ({
162
173
  return { query }
163
174
  }
164
175
 
165
- const storageAdapter = scopeObject.vars.storageAdapter
176
+ const storageAdapter = resolveScopeStorageAdapter(scopes, scopeName, db)
166
177
 
167
178
  const queryState = await scopeObject.applyQueryFilters({
168
179
  query,
@@ -503,7 +514,7 @@ export const loadBelongsTo = async (scope, deps) => {
503
514
  const targetIdColumn = getIdColumnForScope(scopes, targetScope)
504
515
  // Build field selection for sparse fieldsets
505
516
  const targetScopeObject = scopes[targetScope]
506
- const storageAdapter = targetScopeObject.vars.storageAdapter
517
+ const storageAdapter = resolveScopeStorageAdapter(scopes, targetScope, knex)
507
518
  const fieldSelectionInfo = fields?.[targetScope]
508
519
  ? await buildFieldSelection(targetScopeObject, {
509
520
  context: {
@@ -745,7 +756,7 @@ export const loadHasMany = async (scope, deps) => {
745
756
 
746
757
  // Step 3: Build field selection for sparse fieldsets
747
758
  const targetScopeObject = scopes[targetScope]
748
- const storageAdapter = targetScopeObject.vars.storageAdapter
759
+ const storageAdapter = resolveScopeStorageAdapter(scopes, targetScope, knex)
749
760
  const targetIdColumn = getIdColumnForScope(scopes, targetScope)
750
761
  const fieldSelectionInfo = fields?.[targetScope]
751
762
  ? await buildFieldSelection(targetScopeObject, {
@@ -974,7 +985,7 @@ export const loadHasMany = async (scope, deps) => {
974
985
 
975
986
  // Build field selection for sparse fieldsets
976
987
  const targetScopeObject = scopes[targetScope]
977
- const storageAdapter = targetScopeObject.vars.storageAdapter
988
+ const storageAdapter = resolveScopeStorageAdapter(scopes, targetScope, knex)
978
989
  const fieldSelectionInfo = fields?.[targetScope]
979
990
  ? await buildFieldSelection(targetScopeObject, {
980
991
  context: {
@@ -1224,7 +1235,7 @@ export const loadHasOne = async (scope, deps) => {
1224
1235
 
1225
1236
  // Build query for hasOne - expects single result per parent
1226
1237
  const targetScopeObject = scopes[targetScope]
1227
- const storageAdapter = targetScopeObject.vars.storageAdapter
1238
+ const storageAdapter = resolveScopeStorageAdapter(scopes, targetScope, knex)
1228
1239
 
1229
1240
  // Build field selection for sparse fieldsets
1230
1241
  const fieldSelectionInfo = fields?.[targetScope]
@@ -1396,7 +1407,7 @@ export const loadPolymorphicBelongsTo = async (scope, deps) => {
1396
1407
 
1397
1408
  // Build field selection for sparse fieldsets
1398
1409
  const targetScopeObject = scopes[targetType]
1399
- const storageAdapter = targetScopeObject.vars.storageAdapter
1410
+ const storageAdapter = resolveScopeStorageAdapter(scopes, targetType, knex)
1400
1411
  const fieldSelectionInfo = fields?.[targetType]
1401
1412
  ? await buildFieldSelection(targetScopeObject, {
1402
1413
  context: {
@@ -1624,7 +1635,7 @@ export const loadReversePolymorphic = async (scope, deps) => {
1624
1635
 
1625
1636
  // Build field selection for sparse fieldsets
1626
1637
  const targetScopeObject = scopes[targetScope]
1627
- const storageAdapter = targetScopeObject.vars.storageAdapter
1638
+ const storageAdapter = resolveScopeStorageAdapter(scopes, targetScope, knex)
1628
1639
  const targetIdColumn = getIdColumnForScope(scopes, targetScope)
1629
1640
  const fieldSelectionInfo = fields?.[targetScope]
1630
1641
  ? await buildFieldSelection(targetScopeObject, {
@@ -1,9 +1,55 @@
1
1
  import { normalizeStableSort, parseSortEntry } from './sort-helpers.js'
2
+ import { RestApiValidationError } from '../../../../lib/rest-api-errors.js'
3
+ import { createSchema } from 'json-rest-schema'
4
+ import { normalizeValueForDatabaseStorage } from '../querying-writing/database-value-normalizers.js'
5
+
6
+ export const validateCursorValues = (descriptors, cursorValues, parameter) => {
7
+ const validated = Object.create(null)
8
+ for (const { field, definition = {}, isRelationship } of descriptors) {
9
+ if (!Object.hasOwn(cursorValues, field)) continue
10
+ const value = cursorValues[field]
11
+ // Public resource IDs may be opaque strings, even when the storage schema
12
+ // uses the numeric id type for ordinary attributes.
13
+ const type = field === 'id' || isRelationship || definition?.type === 'id'
14
+ ? 'string'
15
+ : definition?.type
16
+ if (!type) {
17
+ validated[field] = value
18
+ continue
19
+ }
20
+ const contract = createSchema({
21
+ value: {
22
+ type,
23
+ noTrim: true,
24
+ nullable: field !== 'id',
25
+ ...(definition?.temporalPrecision !== undefined
26
+ ? { temporalPrecision: definition.temporalPrecision }
27
+ : {})
28
+ }
29
+ })
30
+ const { validatedObject, errors } = contract.patch({ value })
31
+ if (Object.keys(errors).length > 0 || (field === 'id' && !String(value ?? '').trim())) {
32
+ const path = `page.${parameter}`
33
+ throw new RestApiValidationError(`Invalid cursor value for sort field '${field}'.`, {
34
+ fields: [path],
35
+ violations: [{ field: path, rule: 'invalid_cursor_value', message: `Invalid ${type} value for '${field}'.` }]
36
+ })
37
+ }
38
+ validated[field] = validatedObject.value
39
+ }
40
+ return validated
41
+ }
2
42
 
3
43
  export const getEffectiveSortableFields = (vars = {}) => {
4
- const baseSortableFields = Array.isArray(vars.sortableFields)
44
+ const schema = vars.schemaInfo?.schemaStructure || {}
45
+ const baseSortableFields = Array.isArray(vars.sortableFields) && vars.sortableFields.length > 0
5
46
  ? vars.sortableFields
6
- : []
47
+ : ['id', ...Object.keys(schema).filter((field) => {
48
+ const definition = schema[field]
49
+ return !definition.hidden && !definition.virtual && !definition.computed && !definition.belongsToPolymorphic
50
+ }), ...Object.entries(vars.schemaInfo?.searchSchemaStructure || {})
51
+ .filter(([, definition]) => definition.isRelationship && schema[definition.actualField]?.hidden !== true)
52
+ .map(([field]) => field)]
7
53
  const queryFieldSortableFields = Object.entries(vars.queryFields || {})
8
54
  .filter(([, fieldDef]) => fieldDef?.sortable === true)
9
55
  .map(([fieldName]) => fieldName)
@@ -29,15 +75,37 @@ export const buildEffectiveSortList = (sort, { defaultSort, idField = 'id' } = {
29
75
  return normalizeStableSort(sortList.length > 0 ? sortList : [idField], { idField })
30
76
  }
31
77
 
32
- export const applyQueryFieldOrder = (query, queryFieldRuntime, direction) => {
78
+ export const applyQueryFieldOrder = (query, queryFieldRuntime, direction, nulls = 'last') => {
79
+ query.orderByRaw(`(${queryFieldRuntime.sql}) IS NULL ${nulls === 'last' ? 'ASC' : 'DESC'}`, queryFieldRuntime.bindings)
33
80
  query.orderByRaw(`(${queryFieldRuntime.sql}) ${direction}`, queryFieldRuntime.bindings)
34
81
  }
35
82
 
36
83
  export const applyQueryFieldPredicate = (builder, queryFieldRuntime, operator, value) => {
37
- builder.whereRaw(`(${queryFieldRuntime.sql}) ${operator} ?`, [...queryFieldRuntime.bindings, value])
84
+ const definition = queryFieldRuntime.definition || {}
85
+ const normalizedValue = normalizeValueForDatabaseStorage(value, definition.type, {
86
+ temporalPrecision: definition.temporalPrecision
87
+ })
88
+ builder.whereRaw(`(${queryFieldRuntime.sql}) ${operator} ?`, [...queryFieldRuntime.bindings, normalizedValue])
38
89
  }
39
90
 
40
- export const applySortDescriptorPredicate = (builder, descriptor, operator, value, applyPlainPredicate) => {
91
+ export const applySortDescriptorPredicate = (builder, descriptor, operator, value, applyPlainPredicate, includeNull = false) => {
92
+ if (includeNull) {
93
+ return builder.where(function () {
94
+ applySortDescriptorPredicate(this, descriptor, operator, value, applyPlainPredicate)
95
+ if (descriptor.queryFieldRuntime) {
96
+ this.orWhereRaw(`(${descriptor.queryFieldRuntime.sql}) IS NULL`, descriptor.queryFieldRuntime.bindings)
97
+ } else {
98
+ this.orWhereNull(descriptor.column)
99
+ }
100
+ })
101
+ }
102
+ if (value === null) {
103
+ const isNotNull = operator === 'IS NOT NULL'
104
+ if (descriptor.queryFieldRuntime) {
105
+ return builder.whereRaw(`(${descriptor.queryFieldRuntime.sql}) IS ${isNotNull ? 'NOT ' : ''}NULL`, descriptor.queryFieldRuntime.bindings)
106
+ }
107
+ return isNotNull ? builder.whereNotNull(descriptor.column) : builder.whereNull(descriptor.column)
108
+ }
41
109
  if (descriptor?.queryFieldRuntime) {
42
110
  return applyQueryFieldPredicate(builder, descriptor.queryFieldRuntime, operator, value)
43
111
  }
@@ -62,19 +130,27 @@ export const buildCursorPredicateChains = (
62
130
 
63
131
  if (cursorValue === undefined) {
64
132
  onMissingValue?.(descriptor.field)
65
- return
133
+ throw new RestApiValidationError(`Cursor is missing a value for sort field '${descriptor.field}'.`, {
134
+ fields: [descriptor.field],
135
+ violations: [{
136
+ field: descriptor.field,
137
+ rule: 'missing_cursor_value',
138
+ message: 'Cursor must include every sort field.'
139
+ }]
140
+ })
66
141
  }
67
142
 
143
+ const operator = operatorSelector(descriptor.direction)
144
+ const forward = operator === (descriptor.direction.toUpperCase() === 'DESC' ? '<' : '>')
145
+ // Nulls sort last. There are no values after null at this sort position,
146
+ // but later tie breakers can still advance within the null group.
147
+ if (cursorValue === null && forward) return
148
+
68
149
  const chain = []
69
150
  for (let i = 0; i < index; i += 1) {
70
151
  const previousDescriptor = descriptors[i]
71
152
  const previousValue = cursorValues?.[previousDescriptor.field]
72
153
 
73
- if (previousValue === undefined) {
74
- onMissingValue?.(previousDescriptor.field)
75
- return
76
- }
77
-
78
154
  chain.push({
79
155
  descriptor: previousDescriptor,
80
156
  operator: '=',
@@ -84,8 +160,9 @@ export const buildCursorPredicateChains = (
84
160
 
85
161
  chain.push({
86
162
  descriptor,
87
- operator: operatorSelector(descriptor.direction),
88
- value: cursorValue
163
+ operator: cursorValue === null ? 'IS NOT NULL' : operator,
164
+ value: cursorValue,
165
+ includeNull: cursorValue !== null && forward
89
166
  })
90
167
 
91
168
  predicateChains.push(chain)
@@ -116,13 +193,14 @@ export const applyCursorPredicate = (
116
193
  query.where(function () {
117
194
  predicateChains.forEach((chain) => {
118
195
  this.orWhere(function () {
119
- chain.forEach(({ descriptor, operator, value }) => {
196
+ chain.forEach(({ descriptor, operator, value, includeNull }) => {
120
197
  applySortDescriptorPredicate(
121
198
  this,
122
199
  descriptor,
123
200
  operator,
124
201
  value,
125
- applyPlainPredicate
202
+ applyPlainPredicate,
203
+ includeNull
126
204
  )
127
205
  })
128
206
  })
@@ -1,6 +1,13 @@
1
1
  export function normalizeStableSort (sort = [], { idField = 'id' } = {}) {
2
+ const seenFields = new Set()
2
3
  const sortList = Array.isArray(sort)
3
- ? sort.filter((entry) => typeof entry === 'string' && entry.trim())
4
+ ? sort.filter((entry) => {
5
+ if (typeof entry !== 'string' || !entry.trim()) return false
6
+ const field = entry.startsWith('-') ? entry.slice(1) : entry
7
+ if (seenFields.has(field)) return false
8
+ seenFields.add(field)
9
+ return true
10
+ })
4
11
  : []
5
12
 
6
13
  if (sortList.length === 0) {