json-rest-api 2.4.0 → 3.0.0
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 +7 -2
- package/docs/API.md +262 -54
- package/docs/GUIDE/01-getting-started.md +1 -1
- 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 +34 -6
- package/docs/GUIDE/14-custom-resource-methods.md +3 -2
- package/docs/GUIDE/16-autofiltering.md +54 -2
- package/docs/GUIDE/17-row-policies.md +15 -5
- package/docs/GUIDE/19-managed-transactions.md +8 -9
- package/docs/GUIDE/20-transaction-outcomes.md +3 -4
- package/docs/GUIDE/21-schema-and-migrations.md +7 -1
- package/docs/GUIDE/22-version-field-migrations.md +3 -3
- package/docs/GUIDE/27-many-row-writes.md +458 -0
- package/docs/GUIDE/28-socketio.md +10 -2
- package/docs/GUIDE/30-backend-capabilities.md +42 -2
- package/docs/GUIDE/31-positioning.md +3 -1
- package/docs/GUIDE/33-migrating-to-v2.md +2 -2
- package/docs/GUIDE/34-scoped-reads-and-locks.md +464 -0
- package/docs/GUIDE/35-migrating-to-v3.md +325 -0
- package/docs/GUIDE/index.md +5 -2
- package/docs/QUICKSTART.md +2 -2
- package/docs/contributing.md +1 -1
- package/index.d.ts +8 -1
- package/index.js +4 -0
- package/lib/rest-api-errors.js +79 -0
- package/lib/runtime/json-rest-api.js +18 -3
- package/lib/system-context.js +42 -0
- package/package.json +3 -2
- package/plugins/core/bulk-operations-plugin.js +54 -436
- package/plugins/core/connectors/lib/transport-http-helpers.js +34 -3
- package/plugins/core/file-handling-plugin.js +17 -2
- 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 +215 -22
- 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 +92 -0
- package/plugins/core/lib/querying-writing/compile-schemas.js +14 -0
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
- package/plugins/core/lib/storage/canonical-data-helpers.js +103 -39
- package/plugins/core/lib/storage/many-row-data-helpers.js +738 -0
- package/plugins/core/lib/storage/ordinary-data-helpers.js +63 -79
- 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 +21 -3
- package/plugins/core/lib/writing/many-to-many-manipulations.js +10 -5
- package/plugins/core/lib/writing/relationship-processor.js +33 -37
- package/plugins/core/rest-api-anyapi-knex-plugin.js +3 -0
- package/plugins/core/rest-api-autofilter-plugin.js +137 -10
- package/plugins/core/rest-api-knex-plugin.js +2 -0
- package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
- package/plugins/core/rest-api-plugin-methods/common.js +144 -84
- package/plugins/core/rest-api-plugin-methods/delete-many.js +132 -0
- 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 +274 -0
- package/plugins/core/rest-api-plugin-methods/patch-many.js +333 -0
- package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
- package/plugins/core/rest-api-plugin-methods/post-many.js +295 -0
- package/plugins/core/rest-api-plugin-methods/put.js +5 -1
- package/plugins/core/rest-api-plugin-methods/scoped-reads.js +221 -0
- package/plugins/core/rest-api-plugin.js +17 -0
- package/plugins/core/rest-api-positioning-plugin.js +22 -4
- package/plugins/core/rest-api-query-projections-plugin.js +9 -1
- package/plugins/core/rest-api-row-policy-plugin.js +4 -0
- package/plugins/core/socketio-plugin.js +80 -2
- package/types/autofilter.d.ts +5 -1
- package/types/data-access-methods.d.ts +141 -0
- package/types/errors.d.ts +45 -0
- package/types/hook-context.d.ts +21 -0
- package/types/plugin-options.d.ts +3 -1
- package/types/resource-methods.d.ts +27 -4
- package/types/row-policy.d.ts +3 -0
- package/types/runtime.d.ts +7 -1
- package/docs/GUIDE/27-bulk-operations.md +0 -277
- package/types/bulk-methods.d.ts +0 -63
|
@@ -188,11 +188,9 @@ export const applyScopeFiltersToIncludeQuery = async ({
|
|
|
188
188
|
}
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
export const filterVisibleIdentifiers = async ({ identifiers, scopes, knex, context }) => {
|
|
191
|
+
// One query and one row-policy run per linked type, however many ids are linked.
|
|
192
|
+
// With `scoped: false`, the identifiers whose records exist, visible or not.
|
|
193
|
+
export const filterVisibleIdentifiers = async ({ identifiers, scopes, knex, context, scoped = true }) => {
|
|
196
194
|
const idsByType = new Map()
|
|
197
195
|
for (const { type, id } of identifiers) {
|
|
198
196
|
if (!idsByType.has(type)) idsByType.set(type, new Set())
|
|
@@ -209,12 +207,12 @@ export const filterVisibleIdentifiers = async ({ identifiers, scopes, knex, cont
|
|
|
209
207
|
const visibleIds = new Set()
|
|
210
208
|
visible.set(type, visibleIds)
|
|
211
209
|
const values = [...ids].map(id => adapter.translateFilterValue('id', id))
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
210
|
+
// whereInIdentifiers binds a long id list as one parameter where the database limits parameters.
|
|
211
|
+
const query = adapter.buildBaseQuery({ transaction: context.transaction })
|
|
212
|
+
.modify(whereInIdentifiers, column, values)
|
|
213
|
+
.distinct({ [idColumn]: adapter.isCanonical() ? column : databaseIdentityExpression(knex, column) })
|
|
214
|
+
const state = scoped
|
|
215
|
+
? await applyScopeFiltersToIncludeQuery({
|
|
218
216
|
query,
|
|
219
217
|
scopes,
|
|
220
218
|
scopeName: type,
|
|
@@ -223,8 +221,8 @@ export const filterVisibleIdentifiers = async ({ identifiers, scopes, knex, cont
|
|
|
223
221
|
tableName,
|
|
224
222
|
queryPurpose: 'relationship-identifiers'
|
|
225
223
|
})
|
|
226
|
-
|
|
227
|
-
|
|
224
|
+
: { query: applyDatabaseReadOptions(query) }
|
|
225
|
+
for (const row of await state.query) visibleIds.add(String(row[idColumn]))
|
|
228
226
|
}
|
|
229
227
|
return identifiers.filter(({ type, id }) => visible.get(type)?.has(String(id)))
|
|
230
228
|
}
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
RELATIONSHIPS_KEY,
|
|
7
7
|
RELATIONSHIP_METADATA_KEY,
|
|
8
8
|
COMPUTED_DEPENDENCIES_KEY,
|
|
9
|
-
RELATIONSHIP_READ_BATCH_SIZE,
|
|
10
9
|
} from '../querying-writing/knex-constants.js'
|
|
11
10
|
import { getOperationDiagnosticContext, wrapUnexpectedError } from '../../../../lib/error-context.js'
|
|
12
11
|
import { getPolymorphicLinkage } from '../querying-writing/relationship-contracts.js'
|
|
@@ -109,36 +108,34 @@ export const loadBelongsTo = async (scope, deps) => {
|
|
|
109
108
|
|
|
110
109
|
// Load the target records
|
|
111
110
|
const targetRecords = []
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
...(requestContext || {}),
|
|
124
|
-
scopeName: targetScope,
|
|
125
|
-
schemaInfo: targetScopeObject.vars.schemaInfo,
|
|
126
|
-
storageAdapter,
|
|
127
|
-
},
|
|
128
|
-
scopeName: targetScope
|
|
129
|
-
})
|
|
130
|
-
query = selectionState.query
|
|
131
|
-
const scopedQueryState = await applyScopeFiltersToIncludeQuery({
|
|
132
|
-
query,
|
|
133
|
-
scopes,
|
|
111
|
+
let query = knex(targetTableName).modify(whereInIdentifiers, targetIdColumn, uniqueIds)
|
|
112
|
+
const selectionState = await applyFieldSelectionToQuery({
|
|
113
|
+
query,
|
|
114
|
+
scope: targetScopeObject,
|
|
115
|
+
fieldSelectionInfo,
|
|
116
|
+
tableName: targetTableName,
|
|
117
|
+
useTablePrefix: false,
|
|
118
|
+
storageAdapter,
|
|
119
|
+
db: knex,
|
|
120
|
+
context: {
|
|
121
|
+
...(requestContext || {}),
|
|
134
122
|
scopeName: targetScope,
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
123
|
+
schemaInfo: targetScopeObject.vars.schemaInfo,
|
|
124
|
+
storageAdapter,
|
|
125
|
+
},
|
|
126
|
+
scopeName: targetScope
|
|
127
|
+
})
|
|
128
|
+
query = selectionState.query
|
|
129
|
+
const scopedQueryState = await applyScopeFiltersToIncludeQuery({
|
|
130
|
+
query,
|
|
131
|
+
scopes,
|
|
132
|
+
scopeName: targetScope,
|
|
133
|
+
requestContext,
|
|
134
|
+
db: knex,
|
|
135
|
+
tableName: targetTableName
|
|
136
|
+
})
|
|
137
|
+
query = scopedQueryState.query
|
|
138
|
+
for (const record of await query) targetRecords.push(record)
|
|
142
139
|
|
|
143
140
|
// Load relationship metadata for all target records
|
|
144
141
|
await loadRelationshipMetadata(scopes, targetRecords, targetScope)
|
|
@@ -450,36 +447,34 @@ export const loadPolymorphicBelongsTo = async (scope, deps) => {
|
|
|
450
447
|
// Query for this type
|
|
451
448
|
const targetIdColumn = getIdColumnForScope(scopes, targetType)
|
|
452
449
|
const targetRecords = []
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
...(requestContext || {}),
|
|
465
|
-
scopeName: targetType,
|
|
466
|
-
schemaInfo: targetScopeObject.vars.schemaInfo,
|
|
467
|
-
storageAdapter,
|
|
468
|
-
},
|
|
469
|
-
scopeName: targetType
|
|
470
|
-
})
|
|
471
|
-
query = selectionState.query
|
|
472
|
-
const scopedQueryState = await applyScopeFiltersToIncludeQuery({
|
|
473
|
-
query,
|
|
474
|
-
scopes,
|
|
450
|
+
let query = knex(targetTable).modify(whereInIdentifiers, targetIdColumn, targetIds)
|
|
451
|
+
const selectionState = await applyFieldSelectionToQuery({
|
|
452
|
+
query,
|
|
453
|
+
scope: targetScopeObject,
|
|
454
|
+
fieldSelectionInfo,
|
|
455
|
+
tableName: targetTable,
|
|
456
|
+
useTablePrefix: false,
|
|
457
|
+
storageAdapter,
|
|
458
|
+
db: knex,
|
|
459
|
+
context: {
|
|
460
|
+
...(requestContext || {}),
|
|
475
461
|
scopeName: targetType,
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
462
|
+
schemaInfo: targetScopeObject.vars.schemaInfo,
|
|
463
|
+
storageAdapter,
|
|
464
|
+
},
|
|
465
|
+
scopeName: targetType
|
|
466
|
+
})
|
|
467
|
+
query = selectionState.query
|
|
468
|
+
const scopedQueryState = await applyScopeFiltersToIncludeQuery({
|
|
469
|
+
query,
|
|
470
|
+
scopes,
|
|
471
|
+
scopeName: targetType,
|
|
472
|
+
requestContext,
|
|
473
|
+
db: knex,
|
|
474
|
+
tableName: targetTable
|
|
475
|
+
})
|
|
476
|
+
query = scopedQueryState.query
|
|
477
|
+
for (const record of await query) targetRecords.push(record)
|
|
483
478
|
|
|
484
479
|
// Load relationship metadata for all target records
|
|
485
480
|
await loadRelationshipMetadata(scopes, targetRecords, targetType)
|
|
@@ -41,7 +41,9 @@ export const applyPaginationToQuery = ({ query, page = {}, vars, pageSizeLimit,
|
|
|
41
41
|
))
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
// One extra row tells whether another page follows: always for cursors, and
|
|
45
|
+
// for numbered pages when no count query answers it.
|
|
46
|
+
query.limit(pageSize + (mode === 'cursor' || (mode === 'offset' && !vars.enablePaginationCounts) ? 1 : 0))
|
|
45
47
|
if (mode === 'offset') query.offset((pageNumber - 1) * pageSize)
|
|
46
48
|
return { mode, page: pageNumber, pageSize, before }
|
|
47
49
|
}
|
|
@@ -89,7 +91,7 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
|
|
|
89
91
|
// Allow empty urlPrefix to generate relative links
|
|
90
92
|
// if (!urlPrefix) return null;
|
|
91
93
|
|
|
92
|
-
const { page, pageCount, pageSize } = paginationMeta
|
|
94
|
+
const { page, pageCount, pageSize, hasMore } = paginationMeta
|
|
93
95
|
const links = {}
|
|
94
96
|
|
|
95
97
|
const baseUrl = `${urlPrefix}/${scopeName}`
|
|
@@ -107,6 +109,11 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
|
|
|
107
109
|
if (page < pageCount) {
|
|
108
110
|
links.next = buildJsonApiLink(baseUrl, queryParams, { number: page + 1, size: pageSize })
|
|
109
111
|
}
|
|
112
|
+
} else if (hasMore !== undefined) {
|
|
113
|
+
// Without counts there is no last page, but the extra row tells about the next one.
|
|
114
|
+
links.first = buildJsonApiLink(baseUrl, queryParams, { number: 1, size: pageSize })
|
|
115
|
+
if (page > 1) links.prev = buildJsonApiLink(baseUrl, queryParams, { number: page - 1, size: pageSize })
|
|
116
|
+
if (hasMore) links.next = buildJsonApiLink(baseUrl, queryParams, { number: page + 1, size: pageSize })
|
|
110
117
|
}
|
|
111
118
|
|
|
112
119
|
return links
|
|
@@ -10,3 +10,14 @@ export declare function applyQueryConstraint(options: {
|
|
|
10
10
|
storageAdapter: StorageAdapter
|
|
11
11
|
tableName: string
|
|
12
12
|
}): void
|
|
13
|
+
|
|
14
|
+
export declare const writeConditions: unique symbol
|
|
15
|
+
|
|
16
|
+
/** Repeats the ownership conditions the scoped reads of `scopeName` recorded on `context`. */
|
|
17
|
+
export declare function applyWriteConditions<Query extends StorageQuery>(options: {
|
|
18
|
+
query: Query
|
|
19
|
+
context: { [writeConditions]?: Record<string, Record<string, unknown>> }
|
|
20
|
+
scopeName: string
|
|
21
|
+
storageAdapter: StorageAdapter
|
|
22
|
+
tableName: string
|
|
23
|
+
}): Query
|
|
@@ -25,3 +25,48 @@ export function applyQueryConstraint ({ query, context, scopeName, storageAdapte
|
|
|
25
25
|
})
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
|
|
29
|
+
// Ownership values a scope filter applied to a read, by resource and field: the
|
|
30
|
+
// owner a write of the rows read must still match. The by-id UPDATE, DELETE and
|
|
31
|
+
// lock statements, single or many-row, repeat them as plain column predicates,
|
|
32
|
+
// so a row that moved to another owner after the read is neither written nor
|
|
33
|
+
// locked.
|
|
34
|
+
export const writeConditions = Symbol('writeConditions')
|
|
35
|
+
// A condition value meaning "any owner but not the shared (NULL) rows".
|
|
36
|
+
export const ANY_OWNER = Symbol('anyOwner')
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {{ [writeConditions]?: Record<string, Record<string, unknown>> }} context
|
|
40
|
+
* @param {string} scopeName
|
|
41
|
+
* @param {string} field
|
|
42
|
+
* @param {unknown} value
|
|
43
|
+
*/
|
|
44
|
+
export function recordWriteCondition (context, scopeName, field, value) {
|
|
45
|
+
const byScope = context[writeConditions] ||= {}
|
|
46
|
+
byScope[scopeName] ||= {}
|
|
47
|
+
byScope[scopeName][field] = value
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {{ [writeConditions]?: Record<string, Record<string, unknown>> }} context
|
|
52
|
+
* @param {string} scopeName
|
|
53
|
+
*/
|
|
54
|
+
export const hasWriteConditions = (context, scopeName) => Object.keys(context[writeConditions]?.[scopeName] || {}).length > 0
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {object} options
|
|
58
|
+
* @param {StorageQuery} options.query
|
|
59
|
+
* @param {{ [writeConditions]?: Record<string, Record<string, unknown>> }} options.context
|
|
60
|
+
* @param {string} options.scopeName
|
|
61
|
+
* @param {StorageAdapter} options.storageAdapter
|
|
62
|
+
* @param {string} options.tableName
|
|
63
|
+
*/
|
|
64
|
+
export function applyWriteConditions ({ query, context, scopeName, storageAdapter, tableName }) {
|
|
65
|
+
for (const [field, value] of Object.entries(context[writeConditions]?.[scopeName] || {})) {
|
|
66
|
+
const column = `${tableName}.${storageAdapter.translateColumn(field)}`
|
|
67
|
+
if (value === ANY_OWNER) query.whereNotNull(column)
|
|
68
|
+
else if (value === null) query.whereNull(column)
|
|
69
|
+
else query.where(column, value)
|
|
70
|
+
}
|
|
71
|
+
return query
|
|
72
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { RestApiScopedQueryReadOnlyError } from '../../../../lib/rest-api-errors.js'
|
|
3
|
+
|
|
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).
|
|
6
|
+
const FORBIDDEN_METHODS = new Set([
|
|
7
|
+
'insert', 'update', 'del', 'delete', 'truncate', 'upsert', 'increment', 'decrement',
|
|
8
|
+
'onConflict', 'merge', 'ignore', 'returning',
|
|
9
|
+
'forUpdate', 'forShare', 'forNoKeyUpdate', 'forKeyShare', 'skipLocked', 'noWait',
|
|
10
|
+
'from', 'fromRaw', 'table', 'into', 'withSchema', 'clear', 'clearWhere',
|
|
11
|
+
'with', 'withRecursive', 'withMaterialized', 'withNotMaterialized', 'withWrapped',
|
|
12
|
+
'union', 'unionAll', 'intersect', 'except'
|
|
13
|
+
])
|
|
14
|
+
// The statement kinds a SELECT-only builder may run.
|
|
15
|
+
const READ_METHODS = new Set(['select', 'first', 'pluck'])
|
|
16
|
+
|
|
17
|
+
/** @param {unknown} value */
|
|
18
|
+
const isQueryBuilder = value => value !== null && typeof value === 'object' &&
|
|
19
|
+
typeof /** @type {{ toSQL?: unknown }} */ (value).toSQL === 'function' &&
|
|
20
|
+
typeof /** @type {{ where?: unknown }} */ (value).where === 'function'
|
|
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
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A SELECT-only view of a Knex query builder. Reads, joins, subqueries and
|
|
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.
|
|
47
|
+
* @template {object} Builder
|
|
48
|
+
* @param {Builder} builder
|
|
49
|
+
* @param {{ resourceType?: string }} [options]
|
|
50
|
+
* @returns {Builder}
|
|
51
|
+
*/
|
|
52
|
+
export function readOnlyQueryBuilder (builder, { resourceType = 'query' } = {}) {
|
|
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 }) }
|
|
56
|
+
/** @type {Builder} */
|
|
57
|
+
const proxy = new Proxy(builder, {
|
|
58
|
+
get (target, property, receiver) {
|
|
59
|
+
if (typeof property === 'string' && FORBIDDEN_METHODS.has(property)) return () => refuse(property)
|
|
60
|
+
if (property === 'client' && !insideBuilderMethod) refuse('client')
|
|
61
|
+
const value = Reflect.get(target, property, receiver)
|
|
62
|
+
if ((property === '_single' || property === '_statements') && !insideBuilderMethod) return stateCopy(value)
|
|
63
|
+
if (typeof value !== 'function' || property === 'constructor') return value
|
|
64
|
+
return /** @this {unknown} */ function (/** @type {unknown[]} */ ...args) {
|
|
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
|
+
}
|
|
73
|
+
return result !== receiver && result !== target && isQueryBuilder(result)
|
|
74
|
+
? readOnlyQueryBuilder(result, { resourceType })
|
|
75
|
+
: result
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
set (target, property, value) {
|
|
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)}`)
|
|
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)
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
return proxy
|
|
92
|
+
}
|
|
@@ -238,6 +238,19 @@ export async function compileSchemas (scope, deps) {
|
|
|
238
238
|
}
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
// An integer every library update advances by one, for compare-and-set writes.
|
|
242
|
+
const lockVersionField = scopeOptions.lockVersionField
|
|
243
|
+
if (lockVersionField !== undefined) {
|
|
244
|
+
const definition = typeof lockVersionField === 'string' && Object.hasOwn(schemaObject.structure, lockVersionField)
|
|
245
|
+
? schemaObject.structure[lockVersionField]
|
|
246
|
+
: undefined
|
|
247
|
+
if (!lockVersionField || lockVersionField === idProperty || lockVersionField === versionField || !definition ||
|
|
248
|
+
!['integer', 'number'].includes(definition.type) || definition.nullable === true ||
|
|
249
|
+
definition.computed || definition.virtual || definition.belongsTo || definition.getter || definition.setter || definition.storage?.serialize) {
|
|
250
|
+
throw new Error(`lockVersionField in '${scopeName}' must name a stored, non-nullable integer attribute without getters, setters, serializers or relationships`)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
241
254
|
// Cache everything
|
|
242
255
|
const schemaInfo = {
|
|
243
256
|
|
|
@@ -253,6 +266,7 @@ export async function compileSchemas (scope, deps) {
|
|
|
253
266
|
outputFields,
|
|
254
267
|
outputRelationships,
|
|
255
268
|
...(versionField === undefined ? {} : { versionField }),
|
|
269
|
+
...(lockVersionField === undefined ? {} : { lockVersionField }),
|
|
256
270
|
tableName: scopeOptions.tableName || scopeName,
|
|
257
271
|
idProperty,
|
|
258
272
|
storage,
|
|
@@ -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
|
|