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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Delete many records by id: one scoped read of the ids the caller may delete
|
|
2
|
+
// per chunk (or, with missing: 'skip', the scope inside the DELETE), then one
|
|
3
|
+
// DELETE per chunk and the pivot rows of the deleted records. deleteWhere
|
|
4
|
+
// deletes the rows its selection finds and answers with those it deleted.
|
|
5
|
+
import { beginWriteTransaction } from '../../../lib/error-context.js'
|
|
6
|
+
import { RestApiRecordsNotFoundError, RestApiValidationError } from '../../../lib/rest-api-errors.js'
|
|
7
|
+
import { rejectRemovedOptions, resolveFormat } from '../lib/querying-writing/response-options.js'
|
|
8
|
+
import { requireExistingResourceId } from '../lib/querying-writing/resource-id-normalization.js'
|
|
9
|
+
import { chunk, databaseFamily } from '../lib/storage/many-row-data-helpers.js'
|
|
10
|
+
import { commitOwnedTransaction, handleWriteMethodError } from './common.js'
|
|
11
|
+
import {
|
|
12
|
+
keepVisible,
|
|
13
|
+
refuseDuplicateIds,
|
|
14
|
+
refuseUnsupportedResource,
|
|
15
|
+
rejectBulkOptions,
|
|
16
|
+
requireWhereScope,
|
|
17
|
+
resolveChunkSize,
|
|
18
|
+
resolveManyRowReturning,
|
|
19
|
+
resolveMissing,
|
|
20
|
+
unsupported,
|
|
21
|
+
validateFilters
|
|
22
|
+
} from './many-row-common.js'
|
|
23
|
+
import { VISIBLE_READ_CHUNK, visibleIdsByFilters } from './patch-many.js'
|
|
24
|
+
|
|
25
|
+
function setup ({ params, context, scopes, scopeName }, method) {
|
|
26
|
+
context.method = method
|
|
27
|
+
rejectRemovedOptions(params)
|
|
28
|
+
rejectBulkOptions(params)
|
|
29
|
+
context.scopeName = scopeName
|
|
30
|
+
context.schemaInfo = scopes[scopeName].vars.schemaInfo
|
|
31
|
+
// Deletes return no records; a format is still checked like a single delete's.
|
|
32
|
+
if (params.format !== undefined) resolveFormat(params.format)
|
|
33
|
+
context.returning = resolveManyRowReturning(params.returning, { deletes: true })
|
|
34
|
+
context.params = params
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {any} args
|
|
39
|
+
* @param {{ operation: string, ids: unknown[], started?: boolean, selection?: { filters?: Record<string, unknown> } }} batch
|
|
40
|
+
*/
|
|
41
|
+
async function runDeleteMany (args, { operation, ids, started = false, selection }) {
|
|
42
|
+
const { params, context, helpers, scope, runHooks, hasHooks, perRecordHooks, scopeName, api } = args
|
|
43
|
+
const storageAdapter = helpers.getStorageAdapter(scopeName)
|
|
44
|
+
const chunkSize = resolveChunkSize(params.chunkSize)
|
|
45
|
+
const missing = selection ? 'skip' : resolveMissing(params.missing)
|
|
46
|
+
refuseUnsupportedResource({ api, helpers, perRecordHooks, scopeName, schemaInfo: context.schemaInfo, operation, kind: 'delete', storageHelper: 'dataDeleteByIds' })
|
|
47
|
+
refuseDuplicateIds(ids, storageAdapter, 'ids')
|
|
48
|
+
|
|
49
|
+
if (!started) await beginWriteTransaction(context, params.transaction, helpers.newTransaction, runHooks)
|
|
50
|
+
context.db = context.transaction
|
|
51
|
+
|
|
52
|
+
// With missing: 'skip' and no hook that needs the rows first, the DELETE
|
|
53
|
+
// decides which rows the caller may delete, carrying the scope (see patchMany,
|
|
54
|
+
// also for MySQL and selections).
|
|
55
|
+
const readFirst = !(missing === 'skip' && !hasHooks('checkPermissions') && !hasHooks('beforeDataCallDeleteMany') &&
|
|
56
|
+
(selection || databaseFamily(context.db) !== 'mysql'))
|
|
57
|
+
const scoped = Boolean(selection) || !readFirst
|
|
58
|
+
let kept = ids
|
|
59
|
+
if (readFirst) {
|
|
60
|
+
const visible = new Map()
|
|
61
|
+
for (const part of chunk(ids, VISIBLE_READ_CHUNK)) {
|
|
62
|
+
const records = await helpers.dataGetMinimal({ scopeName, context, runHooks, ids: part, filters: {}, queryPurpose: 'bulk-write' })
|
|
63
|
+
for (const record of records) visible.set(String(storageAdapter.translateFilterValue('id', record.id)), record)
|
|
64
|
+
}
|
|
65
|
+
kept = keepVisible({ requested: ids, visible, missing, scopeName, storageAdapter })
|
|
66
|
+
// bulkIndex is the id's position in the request.
|
|
67
|
+
const positions = new Map(ids.map((id, index) => [String(storageAdapter.translateFilterValue('id', id)), index]))
|
|
68
|
+
for (const id of kept) {
|
|
69
|
+
const key = String(storageAdapter.translateFilterValue('id', id))
|
|
70
|
+
const minimalRecord = visible.get(key)
|
|
71
|
+
const bulkIndex = positions.get(key)
|
|
72
|
+
await scope.checkPermissions({ method: 'delete', originalContext: { ...context, method: 'delete', id, minimalRecord, originalMinimalRecord: minimalRecord, bulkIndex } })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
context.ids = kept.map(String)
|
|
77
|
+
await runHooks('beforeDataCallDeleteMany')
|
|
78
|
+
const deleted = new Set((kept.length
|
|
79
|
+
? await helpers.dataDeleteByIds({ scopeName, context, runHooks, ids: kept, chunkSize, scoped, filters: selection?.filters })
|
|
80
|
+
: []).map(String))
|
|
81
|
+
// A row that left the caller's scope after the visibility read is not deleted.
|
|
82
|
+
if (deleted.size < kept.length && missing === 'throw') throw new RestApiRecordsNotFoundError({ resourceType: scopeName, missing: kept.length - deleted.size })
|
|
83
|
+
context.ids = kept.map(id => String(storageAdapter.translateFilterValue('id', id))).filter(id => deleted.has(id))
|
|
84
|
+
await runHooks('afterDataCallDeleteMany')
|
|
85
|
+
|
|
86
|
+
await commitOwnedTransaction(context)
|
|
87
|
+
return deleteResponse(context, scopeName, context.ids)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** @param {any} context @param {string} scopeName @param {string[]} ids */
|
|
91
|
+
function deleteResponse (context, scopeName, ids) {
|
|
92
|
+
if (context.returning === 'count') return { meta: { count: ids.length } }
|
|
93
|
+
return context.returning === 'none' ? undefined : { data: ids.map(id => ({ type: scopeName, id })) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @param {any} args */
|
|
97
|
+
export async function deleteManyMethod (args) {
|
|
98
|
+
const { params, context, scopeName, scopeOptions, vars, log } = args
|
|
99
|
+
try {
|
|
100
|
+
setup(args, 'deleteMany')
|
|
101
|
+
if (!Array.isArray(params.ids) || !params.ids.length) {
|
|
102
|
+
throw new RestApiValidationError('ids must be a non-empty array', { fields: ['ids'] })
|
|
103
|
+
}
|
|
104
|
+
const ids = params.ids.map(id => requireExistingResourceId(id, { scopeOptions, vars, scopeName }))
|
|
105
|
+
return await runDeleteMany(args, { operation: 'deleteMany', ids })
|
|
106
|
+
} catch (error) {
|
|
107
|
+
await handleWriteMethodError(error, context, 'DELETE_MANY', scopeName, log)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @param {any} args */
|
|
112
|
+
export async function deleteWhereMethod (args) {
|
|
113
|
+
const { params, context, scopeName, vars, helpers, log } = args
|
|
114
|
+
try {
|
|
115
|
+
setup(args, 'deleteWhere')
|
|
116
|
+
if (typeof helpers.dataVisibleIds !== 'function') {
|
|
117
|
+
throw unsupported(scopeName, 'deleteWhere', 'its storage plugin has no many-row support (AnyAPI storage takes single writes)')
|
|
118
|
+
}
|
|
119
|
+
const filters = validateFilters({ filters: params.filters, scopeName, schemaInfo: context.schemaInfo, vars })
|
|
120
|
+
requireWhereScope({ filters, all: params.all, missing: params.missing, operation: 'deleteWhere' })
|
|
121
|
+
await beginWriteTransaction(context, params.transaction, helpers.newTransaction, args.runHooks)
|
|
122
|
+
context.db = context.transaction
|
|
123
|
+
const ids = await visibleIdsByFilters(args, filters)
|
|
124
|
+
if (!ids.length) {
|
|
125
|
+
await commitOwnedTransaction(context)
|
|
126
|
+
return deleteResponse(context, scopeName, [])
|
|
127
|
+
}
|
|
128
|
+
return await runDeleteMany(args, { operation: 'deleteWhere', ids, started: true, selection: { filters } })
|
|
129
|
+
} catch (error) {
|
|
130
|
+
await handleWriteMethodError(error, context, 'DELETE_WHERE', scopeName, log)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -22,6 +22,27 @@ import { updateReverseRelationship } from '../lib/writing/reverse-relationship-m
|
|
|
22
22
|
import { invalidateRemovedPivotVersions } from '../lib/writing/many-to-many-manipulations.js'
|
|
23
23
|
import { getStorageColumn } from '../lib/storage/storage-mapping.js'
|
|
24
24
|
import { RELATIONSHIP_WRITE_BATCH_SIZE } from '../lib/querying-writing/knex-constants.js'
|
|
25
|
+
import { filterVisibleIdentifiers } from '../lib/querying/include-query-helpers.js'
|
|
26
|
+
import { isSystemContext } from '../../../lib/system-context.js'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The identifiers a many-to-many removal may unlink. Targets that ownership
|
|
30
|
+
* filters or a row policy scope are read for visibility (one read per type and
|
|
31
|
+
* 1,000 ids), and links to targets the caller cannot see stay, as links to
|
|
32
|
+
* missing ones always did. Other targets, and every target in a system context,
|
|
33
|
+
* cost no read. (Reverse hasMany removals check each child's visibility themselves.)
|
|
34
|
+
* @param {WriteRelationshipContext} context
|
|
35
|
+
* @param {Identifier[]} identifiers
|
|
36
|
+
* @param {any} api
|
|
37
|
+
*/
|
|
38
|
+
async function removableTargets (context, identifiers, api) {
|
|
39
|
+
if (isSystemContext(context)) return identifiers
|
|
40
|
+
const scoped = (/** @type {Identifier} */ { type }) => Boolean(api.resources[type]?.vars?.schemaInfo?.ownershipFields?.length || api.resources[type]?.vars?.rowPolicy)
|
|
41
|
+
const checked = identifiers.filter(scoped)
|
|
42
|
+
if (!checked.length) return identifiers
|
|
43
|
+
const visible = new Set(await filterVisibleIdentifiers({ identifiers: checked, scopes: api.resources, knex: context.db, context }))
|
|
44
|
+
return identifiers.filter(identifier => !scoped(identifier) || visible.has(identifier))
|
|
45
|
+
}
|
|
25
46
|
|
|
26
47
|
/**
|
|
27
48
|
* Remove the identifiers in params.relationshipData from a to-many relationship.
|
|
@@ -98,6 +119,7 @@ export default async function deleteRelationshipMethod ({ params, context: calle
|
|
|
98
119
|
|
|
99
120
|
// Remove relationships
|
|
100
121
|
if (relDef?.through) {
|
|
122
|
+
params.relationshipData = await removableTargets(context, params.relationshipData, api)
|
|
101
123
|
if (api.anyapi?.links?.removeMany) {
|
|
102
124
|
await api.anyapi.links.removeMany({
|
|
103
125
|
context,
|
|
@@ -78,52 +78,58 @@ export default async function getMethod ({
|
|
|
78
78
|
|
|
79
79
|
const identityContext = /** @type {IdentityContext} */ (context)
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
scopeName,
|
|
84
|
-
|
|
85
|
-
runHooks
|
|
81
|
+
const notFound = () => new RestApiResourceError('Resource not found', {
|
|
82
|
+
subtype: 'not_found',
|
|
83
|
+
resourceType: scopeName,
|
|
84
|
+
resourceId: context.id
|
|
86
85
|
})
|
|
87
86
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
87
|
+
// The minimal record serves the permission checks and the hooks before the
|
|
88
|
+
// data call. A resource without them reads once: the scoped full read also
|
|
89
|
+
// answers whether the row exists. Any permission hook keeps both reads.
|
|
90
|
+
const readOnce = ['checkPermissions', 'checkDataPermissions', 'checkDataPermissionsGet', 'beforeData', 'beforeDataGet'].some(event => hasHooks(event))
|
|
91
|
+
? undefined
|
|
92
|
+
: helpers.dataGetScoped
|
|
93
|
+
|
|
94
|
+
if (readOnce) {
|
|
95
|
+
try {
|
|
96
|
+
await validateRequestedFieldsets(context, scopes)
|
|
97
|
+
validateRequestedIncludes(context, scopes)
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// A missing row answers 404 before a bad fieldset, as with two reads.
|
|
100
|
+
if (!(await helpers.dataGetMinimal({ scopeName, context: identityContext, runHooks }))) throw notFound()
|
|
101
|
+
throw error
|
|
102
|
+
}
|
|
103
|
+
context.record = await readOnce({ scopeName, context: identityContext, runHooks })
|
|
104
|
+
} else {
|
|
105
|
+
// Fetch minimal record for authorization checks
|
|
106
|
+
const minimalRecord = await helpers.dataGetMinimal({
|
|
107
|
+
scopeName,
|
|
108
|
+
context: identityContext,
|
|
109
|
+
runHooks
|
|
110
|
+
})
|
|
111
|
+
if (!minimalRecord) throw notFound()
|
|
112
|
+
|
|
113
|
+
context.minimalRecord = minimalRecord
|
|
114
|
+
|
|
115
|
+
await scope.checkPermissions({
|
|
116
|
+
method: 'get',
|
|
117
|
+
originalContext: context,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
await validateRequestedFieldsets(context, scopes)
|
|
121
|
+
validateRequestedIncludes(context, scopes)
|
|
122
|
+
await runHooks('beforeData')
|
|
123
|
+
await runHooks('beforeDataGet')
|
|
124
|
+
|
|
125
|
+
context.record = await helpers.dataGet({
|
|
126
|
+
scopeName,
|
|
127
|
+
context: identityContext,
|
|
128
|
+
runHooks
|
|
129
|
+
})
|
|
97
130
|
}
|
|
98
131
|
|
|
99
|
-
context.
|
|
100
|
-
|
|
101
|
-
await scope.checkPermissions({
|
|
102
|
-
method: 'get',
|
|
103
|
-
originalContext: context,
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
await validateRequestedFieldsets(context, scopes)
|
|
107
|
-
validateRequestedIncludes(context, scopes)
|
|
108
|
-
await runHooks('beforeData')
|
|
109
|
-
await runHooks('beforeDataGet')
|
|
110
|
-
|
|
111
|
-
context.record = await helpers.dataGet({
|
|
112
|
-
scopeName,
|
|
113
|
-
context: identityContext,
|
|
114
|
-
runHooks
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
if (!context.record || !context.record.data) {
|
|
118
|
-
throw new RestApiResourceError(
|
|
119
|
-
'Resource not found',
|
|
120
|
-
{
|
|
121
|
-
subtype: 'not_found',
|
|
122
|
-
resourceType: scopeName,
|
|
123
|
-
resourceId: context.id
|
|
124
|
-
}
|
|
125
|
-
)
|
|
126
|
-
}
|
|
132
|
+
if (!context.record || !context.record.data) throw notFound()
|
|
127
133
|
|
|
128
134
|
context.record = normalizeRecordAttributes(context.record, scopes)
|
|
129
135
|
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// Shared steps of the many-row operations: input selection, refusals, chunking,
|
|
2
|
+
// the in-memory per-row pipeline and the response shape.
|
|
3
|
+
import {
|
|
4
|
+
RestApiBulkWriteUnsupportedError,
|
|
5
|
+
RestApiLockOutsideTransactionError,
|
|
6
|
+
RestApiRecordsNotFoundError,
|
|
7
|
+
RestApiValidationError
|
|
8
|
+
} from '../../../lib/rest-api-errors.js'
|
|
9
|
+
import { transformSimplifiedToJsonApi } from '../lib/querying-writing/simplified-helpers.js'
|
|
10
|
+
import { inverseVersionTypes } from '../lib/writing/resource-version.js'
|
|
11
|
+
import { isRawValue } from '../lib/storage/many-row-data-helpers.js'
|
|
12
|
+
import { queryConstraint } from '../lib/querying/query-constraint.js'
|
|
13
|
+
import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_CHUNK_SIZE = 500
|
|
16
|
+
const READ_CHUNK_SIZE = 1000
|
|
17
|
+
|
|
18
|
+
// Per-record events whose handlers many-row operations would skip. A handler
|
|
19
|
+
// must be registered with { batch: true } (its work done by a batch hook or not
|
|
20
|
+
// applying to batches), or the operation refuses the resource.
|
|
21
|
+
const PER_RECORD_EVENTS = {
|
|
22
|
+
post: ['beforeProcessing', 'beforeProcessingPost', 'beforeSchemaValidate', 'beforeSchemaValidatePost', 'afterSchemaValidate', 'afterSchemaValidatePost', 'beforeDataCall', 'beforeDataCallPost', 'afterDataCall', 'afterDataCallPost', 'finish', 'finishPost'],
|
|
23
|
+
patch: ['beforeProcessing', 'beforeProcessingPatch', 'beforeSchemaValidate', 'beforeSchemaValidatePatch', 'afterSchemaValidate', 'afterSchemaValidatePatch', 'beforeDataCall', 'beforeDataCallPatch', 'afterDataCall', 'afterDataCallPatch', 'finish', 'finishPatch'],
|
|
24
|
+
delete: ['beforeDataCall', 'beforeDataCallDelete', 'afterDataCall', 'afterDataCallDelete', 'finish', 'finishDelete']
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const noHooks = async () => {}
|
|
28
|
+
|
|
29
|
+
// Options of the 2.x bulk methods that many-row writes do not have.
|
|
30
|
+
const BULK_OPTIONS = {
|
|
31
|
+
atomic: 'Many-row writes are all-or-nothing; remove atomic.',
|
|
32
|
+
expectedVersion: 'Many-row writes refuse versioned resources; use a lockVersionField and onlyIf.',
|
|
33
|
+
expectedVersions: 'Many-row writes refuse versioned resources; use a lockVersionField and onlyIf.',
|
|
34
|
+
operations: 'Pass the rows as data: [{ id, ...attributes }] or document: { data: [...] }.'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @param {Record<string, unknown>} params */
|
|
38
|
+
export function rejectBulkOptions (params) {
|
|
39
|
+
const fields = Object.keys(BULK_OPTIONS).filter(name => Object.hasOwn(params, name))
|
|
40
|
+
if (fields.length) {
|
|
41
|
+
throw new RestApiValidationError(`Unsupported options: ${fields.join(', ')}. ${[...new Set(fields.map(name => BULK_OPTIONS[name]))].join(' ')}`, { fields })
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @param {string} resourceType @param {string} operation @param {string} reason */
|
|
46
|
+
export const unsupported = (resourceType, operation, reason) => new RestApiBulkWriteUnsupportedError({ resourceType, operation, reason })
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Refuses what a many-row write cannot do in one statement per chunk.
|
|
50
|
+
* @param {{ api: any, helpers: any, perRecordHooks: Function, scopeName: string, schemaInfo: any, operation: string, kind: 'post' | 'patch' | 'delete', storageHelper: string }} request
|
|
51
|
+
*/
|
|
52
|
+
export function refuseUnsupportedResource ({ api, helpers, perRecordHooks, scopeName, schemaInfo, operation, kind, storageHelper }) {
|
|
53
|
+
if (typeof helpers[storageHelper] !== 'function') {
|
|
54
|
+
throw unsupported(scopeName, operation, 'its storage plugin has no many-row support (AnyAPI storage takes single writes)')
|
|
55
|
+
}
|
|
56
|
+
if (schemaInfo.versionField) {
|
|
57
|
+
throw unsupported(scopeName, operation, `the resource is versioned by '${schemaInfo.versionField}'; use single writes with expectedVersion`)
|
|
58
|
+
}
|
|
59
|
+
const parents = [...inverseVersionTypes(api, scopeName, schemaInfo)]
|
|
60
|
+
if (parents.length) {
|
|
61
|
+
throw unsupported(scopeName, operation, `writes advance the version of '${parents.join("', '")}'`)
|
|
62
|
+
}
|
|
63
|
+
if (kind === 'delete') {
|
|
64
|
+
for (const [ownerType, owner] of Object.entries(api.resources)) {
|
|
65
|
+
if (!owner.vars.schemaInfo?.versionField) continue
|
|
66
|
+
const versioned = Object.values(owner.vars.schemaInfo.schemaRelationships || {})
|
|
67
|
+
.some(relationship => relationship.type === 'manyToMany' && relationship.target === scopeName)
|
|
68
|
+
if (versioned) throw unsupported(scopeName, operation, `deletes advance the version of '${ownerType}' through a many-to-many relationship`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const hooks = perRecordHooks(PER_RECORD_EVENTS[kind])
|
|
72
|
+
if (hooks.length) {
|
|
73
|
+
// The hook names stay in the error details, which HTTP responses leave out.
|
|
74
|
+
throw new RestApiBulkWriteUnsupportedError({
|
|
75
|
+
resourceType: scopeName,
|
|
76
|
+
operation,
|
|
77
|
+
reason: 'per-record hooks without a batch form run for it; register them with { batch: true } and a batch hook',
|
|
78
|
+
hooks: hooks.map(hook => `${hook.event}:${hook.name}`)
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @param {unknown} value */
|
|
84
|
+
export function resolveChunkSize (value) {
|
|
85
|
+
if (value === undefined) return DEFAULT_CHUNK_SIZE
|
|
86
|
+
if (!Number.isSafeInteger(value) || /** @type {number} */ (value) < 1) {
|
|
87
|
+
throw new RestApiValidationError('chunkSize must be a positive integer', { fields: ['chunkSize'] })
|
|
88
|
+
}
|
|
89
|
+
return /** @type {number} */ (value)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* What a many-row write returns: nothing (the default), identifiers, the
|
|
94
|
+
* records (not for deletes) or `{ meta: { count } }`, the number of rows it
|
|
95
|
+
* wrote.
|
|
96
|
+
* @param {unknown} value
|
|
97
|
+
* @param {{ deletes?: boolean }} [options]
|
|
98
|
+
* @returns {'none' | 'minimal' | 'full' | 'count'}
|
|
99
|
+
*/
|
|
100
|
+
export function resolveManyRowReturning (value, { deletes = false } = {}) {
|
|
101
|
+
const returning = value === undefined ? 'none' : value
|
|
102
|
+
if (returning === 'none' || returning === 'minimal' || returning === 'count' || (returning === 'full' && !deletes)) return returning
|
|
103
|
+
throw new RestApiValidationError(deletes ? "A delete returns 'none', 'minimal' or 'count'" : "returning must be 'none', 'minimal', 'full' or 'count'", { fields: ['returning'] })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @param {unknown} value */
|
|
107
|
+
export function resolveMissing (value) {
|
|
108
|
+
if (value === undefined) return 'throw'
|
|
109
|
+
if (value !== 'throw' && value !== 'skip') {
|
|
110
|
+
throw new RestApiValidationError("missing must be 'throw' or 'skip'", { fields: ['missing'] })
|
|
111
|
+
}
|
|
112
|
+
return value
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The records of a many-row write: `data: [...]` in the plain format, or
|
|
117
|
+
* `document: { data: [...] }` in JSON:API.
|
|
118
|
+
* @param {Record<string, any>} params
|
|
119
|
+
* @returns {{ inputKey: 'data' | 'document', records: any[] }}
|
|
120
|
+
*/
|
|
121
|
+
export function selectManyInput (params) {
|
|
122
|
+
const hasData = Object.hasOwn(params, 'data')
|
|
123
|
+
if (hasData === Object.hasOwn(params, 'document')) {
|
|
124
|
+
throw new RestApiValidationError('Supply exactly one of data or document', { fields: ['data', 'document'] })
|
|
125
|
+
}
|
|
126
|
+
const records = hasData ? params.data : params.document?.data
|
|
127
|
+
if (!Array.isArray(records) || records.length === 0) {
|
|
128
|
+
const field = hasData ? 'data' : 'document.data'
|
|
129
|
+
throw new RestApiValidationError(`${field} must be a non-empty array of records`, {
|
|
130
|
+
fields: [field], violations: [{ field, rule: 'required_array', message: 'Must be a non-empty array' }]
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
records.forEach((record, index) => {
|
|
134
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
|
135
|
+
throw new RestApiValidationError(`Record ${index} must be an object`, { fields: [`data.${index}`] })
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
return { inputKey: hasData ? 'data' : 'document', records }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* One record as a JSON:API document, with knex.raw values set aside for the
|
|
143
|
+
* operations that accept them.
|
|
144
|
+
* @param {{ record: any, inputKey: 'data' | 'document', scopeName: string, schemaInfo: any, allowRaw?: boolean }} request
|
|
145
|
+
*/
|
|
146
|
+
export function toDocument ({ record, inputKey, scopeName, schemaInfo, allowRaw = false }) {
|
|
147
|
+
const raw = {}
|
|
148
|
+
let attributes = inputKey === 'data' ? record : record.attributes
|
|
149
|
+
if (attributes && typeof attributes === 'object') {
|
|
150
|
+
for (const [field, value] of Object.entries(attributes)) {
|
|
151
|
+
if (!isRawValue(value)) {
|
|
152
|
+
// Knex would run any object carrying the flag as SQL; JSON input can carry the flag.
|
|
153
|
+
if (value && typeof value === 'object' && Object.hasOwn(value, 'isRawInstance')) {
|
|
154
|
+
throw new RestApiValidationError(`Field '${field}' holds an object that is not a Knex raw value`, { fields: [field] })
|
|
155
|
+
}
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
if (!allowRaw) throw new RestApiValidationError(`Field '${field}' cannot take a raw SQL value here`, { fields: [field] })
|
|
159
|
+
raw[field] = value
|
|
160
|
+
}
|
|
161
|
+
if (Object.keys(raw).length) attributes = Object.fromEntries(Object.entries(attributes).filter(([field]) => !Object.hasOwn(raw, field)))
|
|
162
|
+
}
|
|
163
|
+
const document = inputKey === 'data'
|
|
164
|
+
? transformSimplifiedToJsonApi({ inputRecord: attributes }, {
|
|
165
|
+
context: { scopeName, schemaStructure: schemaInfo.schemaInstance.structure, schemaRelationships: schemaInfo.schemaRelationships }
|
|
166
|
+
})
|
|
167
|
+
: { data: { ...record, ...(record.attributes ? { attributes } : {}) } }
|
|
168
|
+
return { document, raw }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Prefixes a row's validation paths with its position in the batch.
|
|
173
|
+
* @param {unknown} error @param {number} index
|
|
174
|
+
*/
|
|
175
|
+
export function atIndex (error, index) {
|
|
176
|
+
if (!(error instanceof RestApiValidationError)) return error
|
|
177
|
+
const prefix = path => typeof path === 'string' ? path.replace(/^(data\.)?/, `data.${index}.`) : path
|
|
178
|
+
const details = /** @type {any} */ (error.details || {})
|
|
179
|
+
if (Array.isArray(details.fields)) details.fields = details.fields.map(prefix)
|
|
180
|
+
if (Array.isArray(details.violations)) details.violations = details.violations.map(violation => ({ ...violation, field: prefix(violation.field) }))
|
|
181
|
+
details.index = index
|
|
182
|
+
error.details = details
|
|
183
|
+
return error
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Throws when a requested identifier is missing or outside the caller's scope.
|
|
188
|
+
* @param {{ requested: unknown[], visible: Map<string, unknown> | Set<string>, missing: 'throw' | 'skip', scopeName: string, storageAdapter: any }} request
|
|
189
|
+
* @returns {unknown[]} requested identifiers that are visible
|
|
190
|
+
*/
|
|
191
|
+
export function keepVisible ({ requested, visible, missing, scopeName, storageAdapter }) {
|
|
192
|
+
const kept = []
|
|
193
|
+
let absent = 0
|
|
194
|
+
for (const id of requested) {
|
|
195
|
+
if (visible.has(String(storageAdapter.translateFilterValue('id', id)))) kept.push(id)
|
|
196
|
+
else absent++
|
|
197
|
+
}
|
|
198
|
+
if (absent && missing === 'throw') throw new RestApiRecordsNotFoundError({ resourceType: scopeName, missing: absent })
|
|
199
|
+
return kept
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** @param {unknown[]} ids */
|
|
203
|
+
export function refuseDuplicateIds (ids, storageAdapter, field = 'data') {
|
|
204
|
+
const seen = new Set()
|
|
205
|
+
for (const id of ids) {
|
|
206
|
+
const key = String(storageAdapter.translateFilterValue('id', id))
|
|
207
|
+
if (seen.has(key)) throw new RestApiValidationError(`Identifier ${id} appears twice in the batch`, { fields: [field] })
|
|
208
|
+
seen.add(key)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** @param {{ transaction?: any, scopeName: string, operation: string }} request */
|
|
213
|
+
export function requireLockTransaction ({ transaction, scopeName, operation }) {
|
|
214
|
+
if (!transaction || typeof transaction !== 'function' || !transaction.isTransaction || transaction.isCompleted?.()) {
|
|
215
|
+
throw new RestApiLockOutsideTransactionError({ resourceType: scopeName, operation })
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* patchWhere and deleteWhere need filters, or `all: true` to act on every row
|
|
221
|
+
* the caller can see (in a system context, the whole table).
|
|
222
|
+
* @param {{ filters: Record<string, unknown> | undefined, all: unknown, missing?: unknown, operation: string }} request
|
|
223
|
+
*/
|
|
224
|
+
export function requireWhereScope ({ filters, all, missing, operation }) {
|
|
225
|
+
// A selection writes the rows it finds and answers with those; no row is missing.
|
|
226
|
+
if (missing !== undefined) throw new RestApiValidationError(`${operation} writes the rows its selection finds; missing applies to lists of ids`, { fields: ['missing'] })
|
|
227
|
+
if (all !== undefined && all !== true) throw new RestApiValidationError('all must be true when given', { fields: ['all'] })
|
|
228
|
+
const filtered = Boolean(filters && Object.keys(filters).length)
|
|
229
|
+
if (all && filtered) throw new RestApiValidationError(`${operation} takes filters or all: true, not both`, { fields: ['all'] })
|
|
230
|
+
if (!all && !filtered) {
|
|
231
|
+
throw new RestApiValidationError(`${operation} needs filters, or all: true to act on every row the caller can see`, { fields: ['filters'] })
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Validates filters as `query` would, so many-row operations accept the same
|
|
237
|
+
* search fields and values.
|
|
238
|
+
*/
|
|
239
|
+
export function validateFilters ({ filters, scopeName, schemaInfo, vars }) {
|
|
240
|
+
if (filters === undefined) return undefined
|
|
241
|
+
const contracts = getRequestContracts({ scopeName, schemaInfo, includeDepthLimit: vars.includeDepthLimit, sortableFields: vars.sortableFields })
|
|
242
|
+
return validateRequestContractOrThrow(contracts.query, { queryParams: { filters } }, 'Filters are invalid').queryParams?.filters
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The response of a many-row write: the count of written rows, identifiers,
|
|
247
|
+
* full records read through `query` in the write's transaction, or nothing.
|
|
248
|
+
* @param {{ api: any, scopeName: string, context: any, ids: unknown[], returning: string, format: string, queryParams?: any }} request
|
|
249
|
+
*/
|
|
250
|
+
export async function manyRowResponse ({ api, scopeName, context, ids, returning, format, queryParams = {} }) {
|
|
251
|
+
if (returning === 'none') return undefined
|
|
252
|
+
const present = ids.filter(id => id !== undefined && id !== null).map(String)
|
|
253
|
+
if (returning === 'count') return { meta: { count: present.length } }
|
|
254
|
+
if (returning === 'minimal') return { data: present.map(id => ({ type: scopeName, id })) }
|
|
255
|
+
|
|
256
|
+
const byId = new Map()
|
|
257
|
+
/** @type {Map<string, any>} */
|
|
258
|
+
const included = new Map()
|
|
259
|
+
for (let offset = 0; offset < present.length; offset += READ_CHUNK_SIZE) {
|
|
260
|
+
const part = present.slice(offset, offset + READ_CHUNK_SIZE)
|
|
261
|
+
const page = await api.resources[scopeName].query({
|
|
262
|
+
format,
|
|
263
|
+
transaction: context.transaction,
|
|
264
|
+
pageSizeLimit: part.length,
|
|
265
|
+
queryParams: { fields: queryParams.fields, include: queryParams.include, sort: ['id'], page: { size: part.length } },
|
|
266
|
+
[queryConstraint]: { scopeName, idsQuery: part }
|
|
267
|
+
}, { ...context, method: undefined, knexQuery: undefined })
|
|
268
|
+
for (const record of page.data) byId.set(String(record.id), record)
|
|
269
|
+
for (const resource of page.included || []) included.set(`${resource.type}:${resource.id}`, resource)
|
|
270
|
+
}
|
|
271
|
+
// Rows the caller's read scope hides are left out.
|
|
272
|
+
const data = present.filter(id => byId.has(id)).map(id => byId.get(id))
|
|
273
|
+
return format === 'jsonapi' && included.size ? { data, included: [...included.values()] } : { data }
|
|
274
|
+
}
|