json-rest-api 3.0.0-rc.0 → 3.0.1
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 -2
- package/docs/API.md +60 -27
- package/docs/GUIDE/07-polymorphic-relationships.md +4 -2
- package/docs/GUIDE/09-pagination-and-sorting.md +4 -2
- package/docs/GUIDE/13-hooks-and-lifecycle.md +6 -1
- package/docs/GUIDE/16-autofiltering.md +1 -1
- package/docs/GUIDE/17-row-policies.md +8 -6
- package/docs/GUIDE/21-schema-and-migrations.md +7 -1
- package/docs/GUIDE/27-many-row-writes.md +201 -28
- package/docs/GUIDE/30-backend-capabilities.md +16 -2
- package/docs/GUIDE/33-migrating-to-v2.md +2 -2
- package/docs/GUIDE/34-scoped-reads-and-locks.md +165 -26
- package/docs/GUIDE/35-migrating-to-v3.md +141 -10
- package/lib/rest-api-errors.js +6 -5
- package/package.json +2 -2
- package/plugins/core/connectors/lib/transport-http-helpers.js +31 -3
- package/plugins/core/file-handling-plugin.js +2 -1
- package/plugins/core/lib/anyapi/anyapi-registry.js +3 -2
- package/plugins/core/lib/anyapi/canonical-link-store.js +5 -2
- package/plugins/core/lib/anyapi/canonical-relationship-reader.js +56 -60
- package/plugins/core/lib/dbIntrospection.js +106 -6
- package/plugins/core/lib/querying/aggregate-query.js +12 -6
- package/plugins/core/lib/querying/include-query-helpers.js +11 -13
- package/plugins/core/lib/querying/include-to-one.js +54 -59
- package/plugins/core/lib/querying/knex-pagination-helpers.js +9 -2
- package/plugins/core/lib/querying/query-constraint.d.ts +11 -0
- package/plugins/core/lib/querying/query-constraint.js +45 -0
- package/plugins/core/lib/querying/read-only-query.js +50 -13
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
- package/plugins/core/lib/storage/canonical-data-helpers.js +72 -41
- package/plugins/core/lib/storage/many-row-data-helpers.js +409 -124
- package/plugins/core/lib/storage/ordinary-data-helpers.js +67 -81
- package/plugins/core/lib/storage/single-row-writes.js +71 -0
- package/plugins/core/lib/storage/storage-adapter.js +18 -8
- package/plugins/core/lib/storage/storage-mapping.js +4 -1
- package/plugins/core/lib/storage/storage-types.d.ts +12 -1
- package/plugins/core/lib/writing/many-to-many-manipulations.js +5 -2
- package/plugins/core/lib/writing/relationship-processor.js +33 -37
- package/plugins/core/rest-api-autofilter-plugin.js +30 -8
- package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
- package/plugins/core/rest-api-plugin-methods/common.js +117 -77
- package/plugins/core/rest-api-plugin-methods/delete-many.js +54 -28
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +22 -0
- package/plugins/core/rest-api-plugin-methods/get.js +48 -42
- package/plugins/core/rest-api-plugin-methods/many-row-common.js +51 -8
- package/plugins/core/rest-api-plugin-methods/patch-many.js +81 -34
- package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
- package/plugins/core/rest-api-plugin-methods/post-many.js +149 -21
- package/plugins/core/rest-api-plugin-methods/put.js +5 -1
- package/plugins/core/rest-api-plugin-methods/scoped-reads.js +72 -27
- package/plugins/core/rest-api-plugin.js +2 -1
- package/plugins/core/rest-api-positioning-plugin.js +1 -1
- package/types/data-access-methods.d.ts +32 -11
- package/types/errors.d.ts +5 -4
- package/types/hook-context.d.ts +2 -0
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { RestApiScopedQueryReadOnlyError } from '../../../../lib/rest-api-errors.js'
|
|
3
3
|
|
|
4
|
-
// Calls that write, lock, change the source table
|
|
4
|
+
// Calls that write, lock, change the source table, clear its predicates, or
|
|
5
|
+
// add rows from outside the scope (common table expressions and set operations).
|
|
5
6
|
const FORBIDDEN_METHODS = new Set([
|
|
6
7
|
'insert', 'update', 'del', 'delete', 'truncate', 'upsert', 'increment', 'decrement',
|
|
7
8
|
'onConflict', 'merge', 'ignore', 'returning',
|
|
8
9
|
'forUpdate', 'forShare', 'forNoKeyUpdate', 'forKeyShare', 'skipLocked', 'noWait',
|
|
9
|
-
'from', 'table', 'into', 'withSchema', 'clear', 'clearWhere'
|
|
10
|
+
'from', 'fromRaw', 'table', 'into', 'withSchema', 'clear', 'clearWhere',
|
|
11
|
+
'with', 'withRecursive', 'withMaterialized', 'withNotMaterialized', 'withWrapped',
|
|
12
|
+
'union', 'unionAll', 'intersect', 'except'
|
|
10
13
|
])
|
|
11
14
|
// The statement kinds a SELECT-only builder may run.
|
|
12
15
|
const READ_METHODS = new Set(['select', 'first', 'pluck'])
|
|
@@ -16,11 +19,31 @@ const isQueryBuilder = value => value !== null && typeof value === 'object' &&
|
|
|
16
19
|
typeof /** @type {{ toSQL?: unknown }} */ (value).toSQL === 'function' &&
|
|
17
20
|
typeof /** @type {{ where?: unknown }} */ (value).where === 'function'
|
|
18
21
|
|
|
22
|
+
// Knex's own methods reach the client and replace the statement lists through
|
|
23
|
+
// `this`; code outside them may not. One counter serves every builder because a
|
|
24
|
+
// method of one builder can compile another (a subquery) through its client.
|
|
25
|
+
let insideBuilderMethod = 0
|
|
26
|
+
// The compiled query's state, which only Knex's own methods may replace.
|
|
27
|
+
const BUILDER_STATE = new Set(['client', '_single', '_statements'])
|
|
28
|
+
|
|
29
|
+
// Outside Knex's methods the statement lists read as frozen copies, so code
|
|
30
|
+
// cannot push, splice or edit a predicate in place. The single values (table,
|
|
31
|
+
// limit, offset and the like) read as a plain copy: Knex's SQLite compiler
|
|
32
|
+
// rewrites the copy's limit when it compiles the builder as a subquery, and a
|
|
33
|
+
// change to the copy reaches no query.
|
|
34
|
+
/** @param {unknown} value */
|
|
35
|
+
const stateCopy = value => {
|
|
36
|
+
if (Array.isArray(value)) return Object.freeze(value.map(entry => entry && typeof entry === 'object' ? Object.freeze({ ...entry }) : entry))
|
|
37
|
+
return value && typeof value === 'object' ? { ...value } : value
|
|
38
|
+
}
|
|
39
|
+
|
|
19
40
|
/**
|
|
20
41
|
* A SELECT-only view of a Knex query builder. Reads, joins, subqueries and
|
|
21
|
-
* execution work as usual; writes, row locks
|
|
22
|
-
* table or clear its predicates
|
|
23
|
-
*
|
|
42
|
+
* execution work as usual; writes, row locks, set operations, common table
|
|
43
|
+
* expressions, calls that change the source table or clear its predicates, and
|
|
44
|
+
* access to the Knex client throw RestApiScopedQueryReadOnlyError. Builders it
|
|
45
|
+
* returns, including clones, are read-only too. It guards against mistakes, not
|
|
46
|
+
* hostile code: code that means to bypass it can use Knex directly.
|
|
24
47
|
* @template {object} Builder
|
|
25
48
|
* @param {Builder} builder
|
|
26
49
|
* @param {{ resourceType?: string }} [options]
|
|
@@ -28,27 +51,41 @@ const isQueryBuilder = value => value !== null && typeof value === 'object' &&
|
|
|
28
51
|
*/
|
|
29
52
|
export function readOnlyQueryBuilder (builder, { resourceType = 'query' } = {}) {
|
|
30
53
|
if (!isQueryBuilder(builder)) throw new TypeError('readOnlyQueryBuilder needs a Knex query builder')
|
|
54
|
+
/** @param {string} method @returns {never} */
|
|
55
|
+
const refuse = method => { throw new RestApiScopedQueryReadOnlyError({ resourceType, method }) }
|
|
31
56
|
/** @type {Builder} */
|
|
32
57
|
const proxy = new Proxy(builder, {
|
|
33
58
|
get (target, property, receiver) {
|
|
34
|
-
if (typeof property === 'string' && FORBIDDEN_METHODS.has(property))
|
|
35
|
-
|
|
36
|
-
}
|
|
59
|
+
if (typeof property === 'string' && FORBIDDEN_METHODS.has(property)) return () => refuse(property)
|
|
60
|
+
if (property === 'client' && !insideBuilderMethod) refuse('client')
|
|
37
61
|
const value = Reflect.get(target, property, receiver)
|
|
62
|
+
if ((property === '_single' || property === '_statements') && !insideBuilderMethod) return stateCopy(value)
|
|
38
63
|
if (typeof value !== 'function' || property === 'constructor') return value
|
|
39
64
|
return /** @this {unknown} */ function (/** @type {unknown[]} */ ...args) {
|
|
40
|
-
|
|
41
|
-
|
|
65
|
+
insideBuilderMethod++
|
|
66
|
+
let result
|
|
67
|
+
try {
|
|
68
|
+
// Knex methods return `this`, which is the proxy; new builders get wrapped.
|
|
69
|
+
result = value.apply(this === undefined ? receiver : this, args)
|
|
70
|
+
} finally {
|
|
71
|
+
insideBuilderMethod--
|
|
72
|
+
}
|
|
42
73
|
return result !== receiver && result !== target && isQueryBuilder(result)
|
|
43
74
|
? readOnlyQueryBuilder(result, { resourceType })
|
|
44
75
|
: result
|
|
45
76
|
}
|
|
46
77
|
},
|
|
47
78
|
set (target, property, value) {
|
|
48
|
-
if (property === '_method' && !READ_METHODS.has(value))
|
|
49
|
-
|
|
50
|
-
}
|
|
79
|
+
if (property === '_method' && !READ_METHODS.has(value)) refuse(String(value))
|
|
80
|
+
if (property === 'client' || (BUILDER_STATE.has(String(property)) && !insideBuilderMethod)) refuse(`set ${String(property)}`)
|
|
51
81
|
return Reflect.set(target, property, value)
|
|
82
|
+
},
|
|
83
|
+
defineProperty (_target, property) { return refuse(`defineProperty ${String(property)}`) },
|
|
84
|
+
// Deletes follow the rules of set: lodash's isPlainObject, which Knex's MySQL
|
|
85
|
+
// compiler runs on a subquery value, sets and then deletes Symbol.toStringTag.
|
|
86
|
+
deleteProperty (target, property) {
|
|
87
|
+
if (property === 'client' || property === '_method' || (BUILDER_STATE.has(String(property)) && !insideBuilderMethod)) refuse(`delete ${String(property)}`)
|
|
88
|
+
return Reflect.deleteProperty(target, property)
|
|
52
89
|
}
|
|
53
90
|
})
|
|
54
91
|
return proxy
|
|
@@ -207,6 +207,25 @@ export function normalizeDateValue (value, type, {
|
|
|
207
207
|
})
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Refuses an object or array as the value of a single column. Drivers do not
|
|
212
|
+
* store one as written, and MySQL drivers that format queries on the client
|
|
213
|
+
* (mysql2 before 3.17, mysql) expand a plain object into `key` = value pairs,
|
|
214
|
+
* which rewrites the statement: a value for one column could set others.
|
|
215
|
+
* Dates and byte arrays (Buffer, Uint8Array) are single column values.
|
|
216
|
+
* @param {unknown} value
|
|
217
|
+
* @param {{ fieldName?: string, parameter?: string }} [options] `parameter` is the input path the error names
|
|
218
|
+
* @returns {void}
|
|
219
|
+
*/
|
|
220
|
+
export function assertSingleColumnValue (value, { fieldName, parameter = `data.attributes.${fieldName}` } = {}) {
|
|
221
|
+
if (value === null || typeof value !== 'object' || value instanceof Date || value instanceof Uint8Array) return
|
|
222
|
+
const message = `Field '${fieldName}' takes a single value, not ${Array.isArray(value) ? 'an array' : 'an object'}. Declare it with type 'object' or 'array', or give it a storage serializer.`
|
|
223
|
+
throw new RestApiValidationError(message, {
|
|
224
|
+
fields: [parameter],
|
|
225
|
+
violations: [{ field: parameter, rule: 'single_value', message }]
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
210
229
|
/**
|
|
211
230
|
* Converts structured JSON and temporal values to the values expected by
|
|
212
231
|
* database drivers. JSON schema validation keeps date and dateTime values as
|
|
@@ -343,6 +362,19 @@ export function normalizeStructuredValue (value, type, { fieldName, resourceType
|
|
|
343
362
|
}
|
|
344
363
|
}
|
|
345
364
|
|
|
365
|
+
// Drivers return BIGINT and some other numeric columns as strings. A value a
|
|
366
|
+
// JavaScript number holds exactly, so that it prints back as the same digits
|
|
367
|
+
// (trailing fraction zeros aside), becomes one; any other, and any integer past
|
|
368
|
+
// 2^53 even when it prints back, keeps its digits as a string. Decimals
|
|
369
|
+
// (precision and scale) keep theirs, to avoid rounding.
|
|
370
|
+
const isDecimalField = definition => definition.precision !== undefined && definition.scale !== undefined
|
|
371
|
+
/** @param {string} value */
|
|
372
|
+
const storedNumber = value => {
|
|
373
|
+
const number = Number(value)
|
|
374
|
+
const digits = (value.includes('.') ? value.replace(/\.?0+$/, '') : value).replace(/^-0$/, '0')
|
|
375
|
+
return Number.isFinite(number) && (!Number.isInteger(number) || Number.isSafeInteger(number)) && String(number) === digits ? number : value
|
|
376
|
+
}
|
|
377
|
+
|
|
346
378
|
/**
|
|
347
379
|
* Normalizes database values in an attributes object
|
|
348
380
|
*
|
|
@@ -386,6 +418,13 @@ export function normalizeAttributes (attributes, schemaStructure, {
|
|
|
386
418
|
}
|
|
387
419
|
}
|
|
388
420
|
|
|
421
|
+
// Stored values only: an id stays a string, and a getter, computed or
|
|
422
|
+
// virtual field keeps the value its code made.
|
|
423
|
+
if (['number', 'integer'].includes(fieldDef.type) && typeof value === 'string' && fieldName !== 'id' && !isDecimalField(fieldDef) &&
|
|
424
|
+
typeof fieldDef.getter !== 'function' && fieldDef.computed !== true && fieldDef.virtual !== true) {
|
|
425
|
+
normalized[fieldName] = storedNumber(value)
|
|
426
|
+
}
|
|
427
|
+
|
|
389
428
|
// Normalize boolean values
|
|
390
429
|
if (fieldDef.type === 'boolean') {
|
|
391
430
|
if (value === 1 || value === '1') {
|
|
@@ -426,9 +465,7 @@ export function normalizeAttributes (attributes, schemaStructure, {
|
|
|
426
465
|
export function normalizeStoredFieldValue (value, definition, { fieldName, resourceType }) {
|
|
427
466
|
if (value == null) return null
|
|
428
467
|
if (definition.type === 'id') return String(value)
|
|
429
|
-
|
|
430
|
-
// Drivers return some numeric columns as strings; decimals keep theirs to avoid rounding.
|
|
431
|
-
if (['number', 'integer'].includes(definition.type) && typeof value === 'string' && !decimal) return Number(value)
|
|
468
|
+
if (['number', 'integer'].includes(definition.type) && typeof value === 'string' && !isDecimalField(definition)) return storedNumber(value)
|
|
432
469
|
return normalizeAttributes({ [fieldName]: value }, { [fieldName]: definition }, { resourceType })[fieldName]
|
|
433
470
|
}
|
|
434
471
|
|
|
@@ -14,6 +14,8 @@ import { translateCanonicalAttributesForStorage, getCanonicalResourceId, getCano
|
|
|
14
14
|
import { unwrapQueryBuilderState, withQueryFilteringContext } from '../querying/query-builder-utils.js'
|
|
15
15
|
import { applyInsertReturning } from '../querying-writing/database-capabilities.js'
|
|
16
16
|
import { serializeJsonApiQuery } from '../querying-writing/connectors-query-parser.js'
|
|
17
|
+
import { deleteFoundRow, foundRow, lockFoundRow, updateFoundRow } from './single-row-writes.js'
|
|
18
|
+
import { whereInIdentifiers } from '../querying/identifier-query.js'
|
|
17
19
|
|
|
18
20
|
/** @param {StorageRow} row @param {CanonicalDescriptor} descriptor */
|
|
19
21
|
function requireCanonicalResourceId (row, descriptor) {
|
|
@@ -91,6 +93,25 @@ export function createCanonicalDataHelpers ({
|
|
|
91
93
|
return descriptors
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
// The canonical row of the record a single write found with its scoped read.
|
|
97
|
+
/** @param {string} scopeName @param {{ db: import('./storage-types.js').StorageDatabase, id: string | number }} context */
|
|
98
|
+
const foundRecord = (scopeName, context) => {
|
|
99
|
+
const storageAdapter = getScopeStorageAdapter(scopeName)
|
|
100
|
+
if (!storageAdapter) throw new Error(`Storage adapter for resource '${scopeName}' not found`)
|
|
101
|
+
const descriptor = getDescriptor(scopeName)
|
|
102
|
+
const { canonical } = descriptor
|
|
103
|
+
return foundRow({
|
|
104
|
+
byId: () => context.db(canonical.tableName)
|
|
105
|
+
.where(getCanonicalResourceIdColumn(descriptor), context.id)
|
|
106
|
+
.where(canonical.resourceColumn, descriptor.resource)
|
|
107
|
+
.where(canonical.tenantColumn, descriptor.tenant),
|
|
108
|
+
context,
|
|
109
|
+
scopeName,
|
|
110
|
+
storageAdapter,
|
|
111
|
+
tableName: canonical.tableName
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
94
115
|
/** @type {CanonicalDataWriteHelpers['dataExists']} */
|
|
95
116
|
const dataExists = async ({ scopeName, context }) => {
|
|
96
117
|
const storageAdapter = getScopeStorageAdapter(scopeName)
|
|
@@ -188,13 +209,7 @@ export function createCanonicalDataHelpers ({
|
|
|
188
209
|
return 1
|
|
189
210
|
}
|
|
190
211
|
|
|
191
|
-
|
|
192
|
-
.where(logicalIdColumn, id)
|
|
193
|
-
.where(canonical.resourceColumn, descriptor.resource)
|
|
194
|
-
.where(canonical.tenantColumn, descriptor.tenant)
|
|
195
|
-
.update(writeRow)
|
|
196
|
-
|
|
197
|
-
return result
|
|
212
|
+
return updateFoundRow(foundRecord(scopeName, context), { db: context.db, scopeName, id, idColumn: logicalIdColumn, values: writeRow })
|
|
198
213
|
}
|
|
199
214
|
|
|
200
215
|
/** @type {CanonicalDataWriteHelpers['dataPatch']} */
|
|
@@ -204,7 +219,6 @@ export function createCanonicalDataHelpers ({
|
|
|
204
219
|
context.storageAdapter = storageAdapter
|
|
205
220
|
}
|
|
206
221
|
const descriptor = getDescriptor(scopeName)
|
|
207
|
-
const { canonical } = descriptor
|
|
208
222
|
const id = context.id
|
|
209
223
|
const logicalIdColumn = getCanonicalResourceIdColumn(descriptor)
|
|
210
224
|
const attributes = context.inputRecord?.data?.attributes || {}
|
|
@@ -216,17 +230,7 @@ export function createCanonicalDataHelpers ({
|
|
|
216
230
|
Object.entries(row).filter(([, value]) => value !== undefined)
|
|
217
231
|
)
|
|
218
232
|
|
|
219
|
-
|
|
220
|
-
return 0
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const result = await context.db(canonical.tableName)
|
|
224
|
-
.where(logicalIdColumn, id)
|
|
225
|
-
.where(canonical.resourceColumn, descriptor.resource)
|
|
226
|
-
.where(canonical.tenantColumn, descriptor.tenant)
|
|
227
|
-
.update(updateRow)
|
|
228
|
-
|
|
229
|
-
return result
|
|
233
|
+
return updateFoundRow(foundRecord(scopeName, context), { db: context.db, scopeName, id, idColumn: logicalIdColumn, values: updateRow })
|
|
230
234
|
}
|
|
231
235
|
|
|
232
236
|
/** @type {CanonicalDataWriteHelpers['dataDelete']} */
|
|
@@ -236,23 +240,18 @@ export function createCanonicalDataHelpers ({
|
|
|
236
240
|
context.storageAdapter = storageAdapter
|
|
237
241
|
}
|
|
238
242
|
const descriptor = getDescriptor(scopeName)
|
|
239
|
-
const { canonical } = descriptor
|
|
240
243
|
const id = context.id
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
await linkStore.invalidateDeletedLinkTargets(scopeName, context)
|
|
244
|
-
const result = await context.db(canonical.tableName)
|
|
245
|
-
.where(logicalIdColumn, id)
|
|
246
|
-
.where(canonical.resourceColumn, descriptor.resource)
|
|
247
|
-
.where(canonical.tenantColumn, descriptor.tenant)
|
|
248
|
-
.delete()
|
|
244
|
+
const row = foundRecord(scopeName, context)
|
|
249
245
|
|
|
246
|
+
const idColumn = getCanonicalResourceIdColumn(descriptor)
|
|
247
|
+
await linkStore.invalidateDeletedLinkTargets(scopeName, context, () => lockFoundRow(row, { scopeName, id, idColumn }))
|
|
248
|
+
const deleted = await deleteFoundRow(row, { scopeName, id })
|
|
250
249
|
await linkStore.deleteResourceLinks({ descriptor, scopeName, id, db: context.db })
|
|
251
250
|
|
|
252
|
-
return
|
|
251
|
+
return deleted
|
|
253
252
|
}
|
|
254
253
|
|
|
255
|
-
// Supplying ids returns minimal records for
|
|
254
|
+
// Supplying ids returns minimal records for all of them, in one statement.
|
|
256
255
|
/** @overload @param {DataMinimalRequest & { ids: Array<string | number> }} request @returns {Promise<DataResource[]>} */
|
|
257
256
|
/** @overload @param {DataMinimalRequest & { ids?: undefined, context: DataReadContext & {id: string | number} }} request @returns {Promise<DataResource | null>} */
|
|
258
257
|
/** @param {DataMinimalRequest & { ids?: Array<string | number> }} request */
|
|
@@ -279,8 +278,7 @@ export function createCanonicalDataHelpers ({
|
|
|
279
278
|
.where(canonical.tenantColumn, descriptor.tenant)
|
|
280
279
|
if (ids === undefined) query.where(logicalIdColumn, id)
|
|
281
280
|
else {
|
|
282
|
-
|
|
283
|
-
query.whereIn(logicalIdColumn, /** @type {import('knex').Knex.Value[]} */ (ids.map(value => storageAdapter.translateFilterValue('id', value))))
|
|
281
|
+
query.modify(whereInIdentifiers, logicalIdColumn, ids.map(value => storageAdapter.translateFilterValue('id', value)))
|
|
284
282
|
.distinct(`${canonical.tableName}.${logicalIdColumn}`)
|
|
285
283
|
}
|
|
286
284
|
|
|
@@ -332,7 +330,7 @@ export function createCanonicalDataHelpers ({
|
|
|
332
330
|
return minimal
|
|
333
331
|
}
|
|
334
332
|
if (ids !== undefined) {
|
|
335
|
-
// Filtering owns predicates; the
|
|
333
|
+
// Filtering owns predicates; the id list owns its identity projection.
|
|
336
334
|
query.clearSelect().distinct(`${canonical.tableName}.${logicalIdColumn}`)
|
|
337
335
|
/** @type {StorageRow[]} */
|
|
338
336
|
const rows = await applyDatabaseReadOptions(storageAdapter.buildBaseQuery({ transaction: context.db }).whereIn(logicalIdColumn, query))
|
|
@@ -343,7 +341,7 @@ export function createCanonicalDataHelpers ({
|
|
|
343
341
|
}
|
|
344
342
|
|
|
345
343
|
/** @type {CanonicalDataReadHelpers['dataGet']} */
|
|
346
|
-
const dataGet = async ({ scopeName, context }) => {
|
|
344
|
+
const dataGet = async ({ scopeName, context, runHooks, scoped = false }) => {
|
|
347
345
|
const storageAdapter = getScopeStorageAdapter(scopeName)
|
|
348
346
|
if (!storageAdapter) throw new Error(`Storage adapter for resource '${scopeName}' not found`)
|
|
349
347
|
context.storageAdapter = storageAdapter
|
|
@@ -357,17 +355,40 @@ export function createCanonicalDataHelpers ({
|
|
|
357
355
|
const fieldSelectionInfo = await buildFieldSelection(scope, { context })
|
|
358
356
|
context.computedDependencies = fieldSelectionInfo.computedDependencies
|
|
359
357
|
|
|
358
|
+
// The preceding minimal read has already applied permission and row filters,
|
|
359
|
+
// unless the read is `scoped`: then it applies them itself, as the only read,
|
|
360
|
+
// and names its columns' table, since a row policy may join tables.
|
|
361
|
+
const column = (/** @type {string} */ name) => scoped ? `${canonical.tableName}.${name}` : name
|
|
360
362
|
let query = context.db(canonical.tableName)
|
|
361
|
-
.where(logicalIdColumn, id)
|
|
362
|
-
.where(canonical.resourceColumn, descriptor.resource)
|
|
363
|
-
.where(canonical.tenantColumn, descriptor.tenant)
|
|
363
|
+
.where(column(logicalIdColumn), id)
|
|
364
|
+
.where(column(canonical.resourceColumn), descriptor.resource)
|
|
365
|
+
.where(column(canonical.tenantColumn), descriptor.tenant)
|
|
366
|
+
if (scoped) {
|
|
367
|
+
const filterHooks = runHooks
|
|
368
|
+
if (!filterHooks) throw new Error('A scoped read needs runHooks')
|
|
369
|
+
query = (await withQueryFilteringContext(context, {
|
|
370
|
+
query,
|
|
371
|
+
filters: context.queryParams?.filters,
|
|
372
|
+
schemaInfo: context.schemaInfo,
|
|
373
|
+
scopeName,
|
|
374
|
+
tableName: canonical.tableName,
|
|
375
|
+
db: context.db,
|
|
376
|
+
queryPurpose: 'single',
|
|
377
|
+
isAnyApi: true,
|
|
378
|
+
adapter: storageAdapter,
|
|
379
|
+
storageAdapter
|
|
380
|
+
}, async () => {
|
|
381
|
+
await applyBuiltInAnyApiQueryFilters(context)
|
|
382
|
+
await filterHooks('knexQueryFiltering')
|
|
383
|
+
})).query
|
|
384
|
+
}
|
|
364
385
|
|
|
365
386
|
const selectionState = await applyFieldSelectionToQuery({
|
|
366
387
|
query,
|
|
367
388
|
scope,
|
|
368
389
|
fieldSelectionInfo,
|
|
369
390
|
tableName: canonical.tableName,
|
|
370
|
-
useTablePrefix:
|
|
391
|
+
useTablePrefix: scoped,
|
|
371
392
|
storageAdapter,
|
|
372
393
|
db: context.db,
|
|
373
394
|
context,
|
|
@@ -512,6 +533,11 @@ export function createCanonicalDataHelpers ({
|
|
|
512
533
|
let rows = await applyDatabaseReadOptions(queryBuilder)
|
|
513
534
|
let cursorRecords = null
|
|
514
535
|
let hasMore = false
|
|
536
|
+
// Without a count, a numbered page's one extra row answers hasMore.
|
|
537
|
+
if (paginationInfo.mode === 'offset' && !scope.vars.enablePaginationCounts) {
|
|
538
|
+
hasMore = rows.length > paginationInfo.pageSize
|
|
539
|
+
if (hasMore) rows = rows.slice(0, paginationInfo.pageSize)
|
|
540
|
+
}
|
|
515
541
|
|
|
516
542
|
if (paginationInfo.mode === 'cursor') {
|
|
517
543
|
const { pageSize } = paginationInfo
|
|
@@ -615,7 +641,7 @@ export function createCanonicalDataHelpers ({
|
|
|
615
641
|
const total = Number(countResult?.count ?? countResult?.total ?? 0)
|
|
616
642
|
paginationMeta = calculatePaginationMeta(total, page, pageSize)
|
|
617
643
|
} else {
|
|
618
|
-
paginationMeta = { page, pageSize }
|
|
644
|
+
paginationMeta = { page, pageSize, hasMore }
|
|
619
645
|
}
|
|
620
646
|
|
|
621
647
|
context.returnMeta.paginationMeta = paginationMeta
|
|
@@ -702,7 +728,7 @@ export function createCanonicalDataHelpers ({
|
|
|
702
728
|
/**
|
|
703
729
|
* The canonical rows of one resource with ownership and row policy applied.
|
|
704
730
|
* Many-row writes and row locks are table-storage features; AnyAPI resources
|
|
705
|
-
* get the read-only builder only.
|
|
731
|
+
* get the read-only builder only. Resolves to `{ query, alias }`.
|
|
706
732
|
* @param {{ scopeName: string, context: DataReadContext, runHooks: (name: 'knexQueryFiltering') => unknown, alias?: string, purpose: string, filters?: Record<string, unknown> }} request
|
|
707
733
|
*/
|
|
708
734
|
const dataScopedQuery = async ({ scopeName, context, runHooks, alias, purpose, filters }) => {
|
|
@@ -728,9 +754,13 @@ export function createCanonicalDataHelpers ({
|
|
|
728
754
|
await runHooks('knexQueryFiltering')
|
|
729
755
|
})
|
|
730
756
|
// Wrapped: a builder is thenable, so an async function cannot return it bare.
|
|
731
|
-
return { query: filteredState.query }
|
|
757
|
+
return { query: filteredState.query, alias: tableAlias }
|
|
732
758
|
}
|
|
733
759
|
|
|
760
|
+
// A get with no reader of the minimal record reads once, scoped.
|
|
761
|
+
/** @type {NonNullable<CanonicalDataReadHelpers['dataGetScoped']>} */
|
|
762
|
+
const dataGetScoped = ({ scopeName, context, runHooks }) => dataGet({ scopeName, context, runHooks, scoped: true })
|
|
763
|
+
|
|
734
764
|
return {
|
|
735
765
|
dataScopedQuery,
|
|
736
766
|
dataExists,
|
|
@@ -739,6 +769,7 @@ export function createCanonicalDataHelpers ({
|
|
|
739
769
|
dataPatch,
|
|
740
770
|
dataDelete,
|
|
741
771
|
dataGet,
|
|
772
|
+
dataGetScoped,
|
|
742
773
|
dataQuery,
|
|
743
774
|
dataAggregate,
|
|
744
775
|
dataGetMinimal
|