json-rest-api 3.0.0-rc.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 +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 +195 -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 +47 -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 +8 -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 +47 -5
- 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 +139 -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
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
* Schema, WriteContext
|
|
7
7
|
* } from './lifecycle-types.js' */
|
|
8
8
|
/** @import { StorageRow } from '../lib/storage/storage-types.js' */
|
|
9
|
-
/** @import { DiagnosticWriter, EnhancedDiagnosticLogger } from '../../../lib/logger-types.js' */
|
|
10
9
|
/** @import { RuntimeLogger } from '../../../types/runtime.js' */
|
|
11
10
|
import { initializeResourceVersion } from '../lib/writing/resource-version.js'
|
|
12
11
|
import {
|
|
@@ -16,7 +15,6 @@ import {
|
|
|
16
15
|
} from '../../../lib/rest-api-errors.js'
|
|
17
16
|
import { transformSimplifiedToJsonApi } from '../lib/querying-writing/simplified-helpers.js'
|
|
18
17
|
import { normalizeRelationshipIdentifiers } from '../lib/querying-writing/resource-id-normalization.js'
|
|
19
|
-
import { createEnhancedLogger } from '../../../lib/enhanced-logger.js'
|
|
20
18
|
import { rejectRemovedOptions, resolveFormat, resolveReturning } from '../lib/querying-writing/response-options.js'
|
|
21
19
|
import {
|
|
22
20
|
getRequestContracts,
|
|
@@ -27,26 +25,11 @@ import { normalizeRecordAttributes } from '../lib/querying-writing/database-valu
|
|
|
27
25
|
import { beginWriteTransaction, commitTransaction, getOperationDiagnosticContext, isTransactionOwner, rollbackAfterError, wrapUnexpectedError } from '../../../lib/error-context.js'
|
|
28
26
|
import { filterVisibleIdentifiers } from '../lib/querying/include-query-helpers.js'
|
|
29
27
|
import { findRelationshipDefinition } from '../lib/querying-writing/relationship-contracts.js'
|
|
30
|
-
import { RELATIONSHIP_READ_BATCH_SIZE } from '../lib/querying-writing/knex-constants.js'
|
|
31
28
|
import { getResourceFieldset, parseFieldset } from '../lib/querying-writing/field-utils.js'
|
|
32
29
|
|
|
33
30
|
// A trusted connector callback, evaluated after normal write validation and authorization.
|
|
34
31
|
export const writePrecondition = Symbol('writePrecondition')
|
|
35
32
|
|
|
36
|
-
/**
|
|
37
|
-
* Gets an enhanced logger instance with full error details and stack traces
|
|
38
|
-
* @param {RuntimeLogger} log - The base logger instance
|
|
39
|
-
* @param {{ schemaInfo?: LifecycleSchema }} context - Request context carrying compiled field visibility
|
|
40
|
-
* @returns {EnhancedDiagnosticLogger & { error: DiagnosticWriter }} Enhanced logger instance
|
|
41
|
-
*/
|
|
42
|
-
export const getEnhancedLogger = (log, context = {}) => {
|
|
43
|
-
return createEnhancedLogger(log, {
|
|
44
|
-
logFullErrors: true,
|
|
45
|
-
includeStack: true,
|
|
46
|
-
schemaInfo: context.schemaInfo
|
|
47
|
-
})
|
|
48
|
-
}
|
|
49
|
-
|
|
50
33
|
/**
|
|
51
34
|
* Resolves write input, response options and the managed transaction.
|
|
52
35
|
* @param {Pick<LifecycleArguments, 'params' | 'vars' | 'scopes' | 'scopeName' | 'api' | 'helpers' | 'runHooks'> & { context: LifecycleContext<Resource | Resource[] | null, unknown> }} options
|
|
@@ -107,11 +90,18 @@ export const handleWriteMethodError = async (error, context, method, scopeName,
|
|
|
107
90
|
await rollbackAfterError(error, context, context.transaction)
|
|
108
91
|
const cleanupErrors = context.cleanupErrors || (context.cleanupErrors = [])
|
|
109
92
|
|
|
93
|
+
// The resource, the operation and error codes only: input, error messages and
|
|
94
|
+
// SQL can carry the values being written (payment state, ciphertext).
|
|
95
|
+
/** @param {unknown} value */
|
|
96
|
+
const codeOf = value => {
|
|
97
|
+
const { code, name } = /** @type {{ code?: unknown, name?: unknown }} */ (Object(value))
|
|
98
|
+
return typeof code === 'string' || typeof code === 'number' ? code : typeof name === 'string' ? name : null
|
|
99
|
+
}
|
|
110
100
|
try {
|
|
111
|
-
await
|
|
101
|
+
await log.error(`Error in ${method} method`, {
|
|
112
102
|
...getOperationDiagnosticContext(context, { phase: 'writeFailure', scopeName, method: context.method ?? method.toLowerCase() }),
|
|
113
|
-
|
|
114
|
-
cleanupErrors
|
|
103
|
+
code: codeOf(error),
|
|
104
|
+
cleanupErrors: cleanupErrors.map(({ phase, error }) => ({ phase, code: codeOf(error) }))
|
|
115
105
|
})
|
|
116
106
|
} catch (error) {
|
|
117
107
|
cleanupErrors.push({ phase: 'logging', error })
|
|
@@ -294,7 +284,11 @@ export const validateResourceAttributesBeforeWrite = async ({
|
|
|
294
284
|
...(hasLogicalIdField && resourceId !== undefined ? { id: resourceId } : {})
|
|
295
285
|
}
|
|
296
286
|
|
|
297
|
-
// Physical foreign keys
|
|
287
|
+
// Physical foreign keys, and the type and id fields behind a polymorphic
|
|
288
|
+
// belongs-to, may enter storage only through relationship planning.
|
|
289
|
+
/** @type {Map<string, string>} */
|
|
290
|
+
const polymorphicFields = new Map(Object.entries(context.schemaInfo.schemaRelationships || {}).flatMap(([name, relationship]) =>
|
|
291
|
+
relationship.belongsToPolymorphic ? [[relationship.belongsToPolymorphic.typeField, name], [relationship.belongsToPolymorphic.idField, name]] : []))
|
|
298
292
|
/** @type {StorageRow} */
|
|
299
293
|
const attributesForValidation = { ...attributesToValidate }
|
|
300
294
|
const foreignKeyViolations = []
|
|
@@ -308,13 +302,14 @@ export const validateResourceAttributesBeforeWrite = async ({
|
|
|
308
302
|
message: `Field '${field}' is retired and accepts no new values`
|
|
309
303
|
})
|
|
310
304
|
}
|
|
311
|
-
|
|
305
|
+
const relationshipName = definition?.belongsTo && definition.as ? definition.as : polymorphicFields.get(field)
|
|
306
|
+
if (!relationshipName || Object.hasOwn(belongsToUpdates, field)) continue
|
|
312
307
|
delete attributesForValidation[field]
|
|
313
308
|
if (value === undefined) continue
|
|
314
309
|
foreignKeyViolations.push({
|
|
315
310
|
field: `data.attributes.${field}`,
|
|
316
311
|
rule: 'foreign_key_in_attributes',
|
|
317
|
-
message: `Foreign key field '${field}' should not be in attributes. Use 'data.relationships.${
|
|
312
|
+
message: `Foreign key field '${field}' should not be in attributes. Use 'data.relationships.${relationshipName}' instead.`
|
|
318
313
|
})
|
|
319
314
|
}
|
|
320
315
|
const [firstRetired] = retiredViolations
|
|
@@ -420,66 +415,65 @@ export const collectRelationshipTargets = (inputRecord, api, unique) => {
|
|
|
420
415
|
|
|
421
416
|
/**
|
|
422
417
|
* Checks that every target is visible to the caller, with one scoped read per
|
|
423
|
-
* related type
|
|
418
|
+
* related type, however many targets there are.
|
|
424
419
|
* @param {ResourceContext} context
|
|
425
420
|
* @param {Identifier[]} targets
|
|
426
421
|
* @param {LifecycleHelpers} helpers
|
|
427
422
|
* @param {LifecycleApi} api
|
|
428
423
|
*/
|
|
429
424
|
export const validateRelationshipTargets = async (context, targets, helpers, api) => {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
425
|
+
/** @type {Map<string, Identifier[]>} */
|
|
426
|
+
const byType = new Map()
|
|
427
|
+
for (const item of targets) {
|
|
428
|
+
const items = byType.get(item.type)
|
|
429
|
+
if (items) items.push(item)
|
|
430
|
+
else byType.set(item.type, [item])
|
|
431
|
+
}
|
|
432
|
+
const lookups = new Map()
|
|
433
|
+
for (const [type, items] of byType) {
|
|
434
|
+
const relatedScope = api.resources[type]
|
|
435
|
+
if (!relatedScope) throw new Error(`Unknown resource type: ${type}`)
|
|
436
|
+
const getContext = {
|
|
437
|
+
...context,
|
|
438
|
+
id: undefined,
|
|
439
|
+
minimalRecord: undefined,
|
|
440
|
+
schemaInfo: relatedScope.vars.schemaInfo,
|
|
441
|
+
scopeName: type,
|
|
442
|
+
queryParams: {},
|
|
443
|
+
method: 'get',
|
|
444
|
+
isUpdate: false
|
|
438
445
|
}
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
...
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
scopeName: type,
|
|
449
|
-
queryParams: {},
|
|
450
|
-
method: 'get',
|
|
451
|
-
isUpdate: false
|
|
452
|
-
}
|
|
453
|
-
const lookup = {
|
|
454
|
-
scopeName: type,
|
|
455
|
-
context: getContext,
|
|
456
|
-
/** @param {import('../lib/storage/storage-types.js').QueryFilteringState} filterParams */
|
|
457
|
-
applyQueryFilters: filterParams => relatedScope.applyQueryFilters({
|
|
458
|
-
...filterParams, schemaInfo: getContext.schemaInfo
|
|
459
|
-
}, getContext),
|
|
460
|
-
filters: {},
|
|
461
|
-
queryPurpose: 'relationship-validation'
|
|
462
|
-
}
|
|
463
|
-
const records = await helpers.dataGetMinimal({ ...lookup, ids: items.map(item => item.id) })
|
|
464
|
-
lookups.set(type, { relatedScope, lookup, records: new Map(records.map(record => [String(record.id), record])) })
|
|
446
|
+
const lookup = {
|
|
447
|
+
scopeName: type,
|
|
448
|
+
context: getContext,
|
|
449
|
+
/** @param {import('../lib/storage/storage-types.js').QueryFilteringState} filterParams */
|
|
450
|
+
applyQueryFilters: filterParams => relatedScope.applyQueryFilters({
|
|
451
|
+
...filterParams, schemaInfo: getContext.schemaInfo
|
|
452
|
+
}, getContext),
|
|
453
|
+
filters: {},
|
|
454
|
+
queryPurpose: 'relationship-validation'
|
|
465
455
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
456
|
+
const records = new Map()
|
|
457
|
+
for (const record of await helpers.dataGetMinimal({ ...lookup, ids: items.map(item => item.id) })) records.set(String(record.id), record)
|
|
458
|
+
lookups.set(type, { relatedScope, lookup, records })
|
|
459
|
+
}
|
|
460
|
+
// In the order given, so the first missing or forbidden target is the one reported.
|
|
461
|
+
for (const item of targets) {
|
|
462
|
+
const { relatedScope, lookup, records } = lookups.get(item.type)
|
|
463
|
+
// A database collation can equate IDs with different spellings.
|
|
464
|
+
const record = records.get(String(item.id)) || await helpers.dataGetMinimal({
|
|
465
|
+
...lookup, context: { ...lookup.context, id: item.id }
|
|
466
|
+
})
|
|
467
|
+
if (!record) {
|
|
468
|
+
throw new RestApiResourceError(
|
|
469
|
+
`Cannot create relationship to non-existent ${item.type} with id ${item.id}`,
|
|
470
|
+
{ subtype: 'not_found', resourceType: item.type, resourceId: item.id }
|
|
471
|
+
)
|
|
482
472
|
}
|
|
473
|
+
await relatedScope.checkPermissions({
|
|
474
|
+
method: 'get',
|
|
475
|
+
originalContext: { ...lookup.context, id: item.id, minimalRecord: record }
|
|
476
|
+
})
|
|
483
477
|
}
|
|
484
478
|
}
|
|
485
479
|
|
|
@@ -657,15 +651,61 @@ export const filterToOneResponseLinkage = async (context, scopes) => {
|
|
|
657
651
|
if ((definition?.belongsTo || definition?.belongsToPolymorphic) && relationship.data) linkages.push(relationship)
|
|
658
652
|
}
|
|
659
653
|
}
|
|
660
|
-
|
|
654
|
+
// A target the response already includes is no secret, so it needs no read.
|
|
655
|
+
const key = (/** @type {Identifier} */ { type, id }) => `${type}:${id}`
|
|
656
|
+
const included = new Set((document?.included || []).map(key))
|
|
657
|
+
// The links collected above are to-one links with a target.
|
|
658
|
+
const unchecked = linkages.filter(relationship => !included.has(key(/** @type {Identifier} */ (relationship.data))))
|
|
659
|
+
if (unchecked.length === 0) return
|
|
661
660
|
const visible = new Set(await filterVisibleIdentifiers({
|
|
662
|
-
identifiers:
|
|
661
|
+
identifiers: unchecked.map(relationship => relationship.data), scopes, knex: context.db, context
|
|
663
662
|
}))
|
|
664
|
-
for (const relationship of
|
|
663
|
+
for (const relationship of unchecked) {
|
|
665
664
|
if (!visible.has(relationship.data)) relationship.data = null
|
|
666
665
|
}
|
|
667
666
|
}
|
|
668
667
|
|
|
668
|
+
/**
|
|
669
|
+
* A read shows a link whose target its reader cannot see as null (above), so a
|
|
670
|
+
* reader that writes the record back sends that null for a link it never saw.
|
|
671
|
+
* The write keeps such a link: a belongs-to that `updates` sets to null, whose
|
|
672
|
+
* stored target exists but is hidden from the writer, is dropped from
|
|
673
|
+
* `updates` and from the input, and stays as stored. A link to
|
|
674
|
+
* a visible target, or to one that no longer exists, is cleared as asked.
|
|
675
|
+
* @param {{ context: ResourceContext, scopes: Record<string, LifecycleResource>, schemaInfo: any, rows: Array<{ updates: Record<string, unknown>, stored: any, inputRecord?: any }> }} request
|
|
676
|
+
*/
|
|
677
|
+
export const keepHiddenLinks = async ({ context, scopes, schemaInfo, rows }) => {
|
|
678
|
+
const links = [
|
|
679
|
+
...Object.entries(schemaInfo.schemaStructure)
|
|
680
|
+
.filter(([, field]) => field.belongsTo && field.as)
|
|
681
|
+
.map(([name, field]) => ({ name: field.as, fields: [name] })),
|
|
682
|
+
...Object.entries(schemaInfo.schemaRelationships || {})
|
|
683
|
+
.filter(([, relationship]) => relationship.belongsToPolymorphic)
|
|
684
|
+
.map(([name, { belongsToPolymorphic: { typeField, idField } }]) => ({ name, fields: [typeField, idField] }))
|
|
685
|
+
]
|
|
686
|
+
const cleared = []
|
|
687
|
+
for (const row of rows) {
|
|
688
|
+
for (const link of links) {
|
|
689
|
+
const data = row.stored?.relationships?.[link.name]?.data
|
|
690
|
+
if (data && link.fields.every(field => Object.hasOwn(row.updates, field) && row.updates[field] === null)) cleared.push({ row, link, data })
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
if (!cleared.length) return
|
|
694
|
+
const visible = new Set(await filterVisibleIdentifiers({ identifiers: cleared.map(entry => entry.data), scopes, knex: context.db, context }))
|
|
695
|
+
const hidden = cleared.filter(entry => !visible.has(entry.data))
|
|
696
|
+
if (!hidden.length) return
|
|
697
|
+
const existing = new Set(await filterVisibleIdentifiers({ identifiers: hidden.map(entry => entry.data), scopes, knex: context.db, context, scoped: false }))
|
|
698
|
+
for (const { row, link, data } of hidden) {
|
|
699
|
+
if (!existing.has(data)) continue
|
|
700
|
+
for (const field of link.fields) {
|
|
701
|
+
delete row.updates[field]
|
|
702
|
+
// Validation has merged the belongs-to updates into the input attributes.
|
|
703
|
+
delete row.inputRecord?.data?.attributes?.[field]
|
|
704
|
+
}
|
|
705
|
+
delete row.inputRecord?.data?.relationships?.[link.name]
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
669
709
|
/** @param {ResourceContext} context @param {Record<string, LifecycleResource>} scopes @param {string[]} [resourceTypes] */
|
|
670
710
|
export const validateRequestedIncludes = (context, scopes, resourceTypes = [context.scopeName]) => {
|
|
671
711
|
const paths = context.queryParams?.include || []
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
// Delete many records by id: one scoped read of the ids the caller may delete
|
|
2
|
-
// per chunk,
|
|
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.
|
|
3
5
|
import { beginWriteTransaction } from '../../../lib/error-context.js'
|
|
4
|
-
import { RestApiValidationError } from '../../../lib/rest-api-errors.js'
|
|
5
|
-
import { rejectRemovedOptions, resolveFormat
|
|
6
|
+
import { RestApiRecordsNotFoundError, RestApiValidationError } from '../../../lib/rest-api-errors.js'
|
|
7
|
+
import { rejectRemovedOptions, resolveFormat } from '../lib/querying-writing/response-options.js'
|
|
6
8
|
import { requireExistingResourceId } from '../lib/querying-writing/resource-id-normalization.js'
|
|
7
|
-
import { chunk } from '../lib/storage/many-row-data-helpers.js'
|
|
9
|
+
import { chunk, databaseFamily } from '../lib/storage/many-row-data-helpers.js'
|
|
8
10
|
import { commitOwnedTransaction, handleWriteMethodError } from './common.js'
|
|
9
11
|
import {
|
|
10
12
|
keepVisible,
|
|
11
13
|
refuseDuplicateIds,
|
|
12
14
|
refuseUnsupportedResource,
|
|
13
15
|
rejectBulkOptions,
|
|
16
|
+
requireWhereScope,
|
|
14
17
|
resolveChunkSize,
|
|
18
|
+
resolveManyRowReturning,
|
|
15
19
|
resolveMissing,
|
|
16
20
|
unsupported,
|
|
17
21
|
validateFilters
|
|
@@ -26,46 +30,67 @@ function setup ({ params, context, scopes, scopeName }, method) {
|
|
|
26
30
|
context.schemaInfo = scopes[scopeName].vars.schemaInfo
|
|
27
31
|
// Deletes return no records; a format is still checked like a single delete's.
|
|
28
32
|
if (params.format !== undefined) resolveFormat(params.format)
|
|
29
|
-
context.returning =
|
|
30
|
-
if (context.returning === 'full') {
|
|
31
|
-
throw new RestApiValidationError("A delete returns 'none' or 'minimal'", { fields: ['returning'] })
|
|
32
|
-
}
|
|
33
|
+
context.returning = resolveManyRowReturning(params.returning, { deletes: true })
|
|
33
34
|
context.params = params
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
38
43
|
const storageAdapter = helpers.getStorageAdapter(scopeName)
|
|
39
44
|
const chunkSize = resolveChunkSize(params.chunkSize)
|
|
40
|
-
const missing = resolveMissing(params.missing)
|
|
45
|
+
const missing = selection ? 'skip' : resolveMissing(params.missing)
|
|
41
46
|
refuseUnsupportedResource({ api, helpers, perRecordHooks, scopeName, schemaInfo: context.schemaInfo, operation, kind: 'delete', storageHelper: 'dataDeleteByIds' })
|
|
42
47
|
refuseDuplicateIds(ids, storageAdapter, 'ids')
|
|
43
48
|
|
|
44
49
|
if (!started) await beginWriteTransaction(context, params.transaction, helpers.newTransaction, runHooks)
|
|
45
50
|
context.db = context.transaction
|
|
46
51
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
}
|
|
60
74
|
}
|
|
61
75
|
|
|
62
76
|
context.ids = kept.map(String)
|
|
63
77
|
await runHooks('beforeDataCallDeleteMany')
|
|
64
|
-
|
|
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))
|
|
65
84
|
await runHooks('afterDataCallDeleteMany')
|
|
66
85
|
|
|
67
86
|
await commitOwnedTransaction(context)
|
|
68
|
-
return 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 })) }
|
|
69
94
|
}
|
|
70
95
|
|
|
71
96
|
/** @param {any} args */
|
|
@@ -92,14 +117,15 @@ export async function deleteWhereMethod (args) {
|
|
|
92
117
|
throw unsupported(scopeName, 'deleteWhere', 'its storage plugin has no many-row support (AnyAPI storage takes single writes)')
|
|
93
118
|
}
|
|
94
119
|
const filters = validateFilters({ filters: params.filters, scopeName, schemaInfo: context.schemaInfo, vars })
|
|
120
|
+
requireWhereScope({ filters, all: params.all, missing: params.missing, operation: 'deleteWhere' })
|
|
95
121
|
await beginWriteTransaction(context, params.transaction, helpers.newTransaction, args.runHooks)
|
|
96
122
|
context.db = context.transaction
|
|
97
123
|
const ids = await visibleIdsByFilters(args, filters)
|
|
98
124
|
if (!ids.length) {
|
|
99
125
|
await commitOwnedTransaction(context)
|
|
100
|
-
return context
|
|
126
|
+
return deleteResponse(context, scopeName, [])
|
|
101
127
|
}
|
|
102
|
-
return await runDeleteMany(args, { operation: 'deleteWhere', ids, started: true })
|
|
128
|
+
return await runDeleteMany(args, { operation: 'deleteWhere', ids, started: true, selection: { filters } })
|
|
103
129
|
} catch (error) {
|
|
104
130
|
await handleWriteMethodError(error, context, 'DELETE_WHERE', scopeName, log)
|
|
105
131
|
}
|
|
@@ -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
|
|
|
@@ -70,8 +70,13 @@ export function refuseUnsupportedResource ({ api, helpers, perRecordHooks, scope
|
|
|
70
70
|
}
|
|
71
71
|
const hooks = perRecordHooks(PER_RECORD_EVENTS[kind])
|
|
72
72
|
if (hooks.length) {
|
|
73
|
-
|
|
74
|
-
throw
|
|
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
|
+
})
|
|
75
80
|
}
|
|
76
81
|
}
|
|
77
82
|
|
|
@@ -84,6 +89,20 @@ export function resolveChunkSize (value) {
|
|
|
84
89
|
return /** @type {number} */ (value)
|
|
85
90
|
}
|
|
86
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
|
+
|
|
87
106
|
/** @param {unknown} value */
|
|
88
107
|
export function resolveMissing (value) {
|
|
89
108
|
if (value === undefined) return 'throw'
|
|
@@ -129,7 +148,13 @@ export function toDocument ({ record, inputKey, scopeName, schemaInfo, allowRaw
|
|
|
129
148
|
let attributes = inputKey === 'data' ? record : record.attributes
|
|
130
149
|
if (attributes && typeof attributes === 'object') {
|
|
131
150
|
for (const [field, value] of Object.entries(attributes)) {
|
|
132
|
-
if (!isRawValue(value))
|
|
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
|
+
}
|
|
133
158
|
if (!allowRaw) throw new RestApiValidationError(`Field '${field}' cannot take a raw SQL value here`, { fields: [field] })
|
|
134
159
|
raw[field] = value
|
|
135
160
|
}
|
|
@@ -191,6 +216,22 @@ export function requireLockTransaction ({ transaction, scopeName, operation }) {
|
|
|
191
216
|
}
|
|
192
217
|
}
|
|
193
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
|
+
|
|
194
235
|
/**
|
|
195
236
|
* Validates filters as `query` would, so many-row operations accept the same
|
|
196
237
|
* search fields and values.
|
|
@@ -202,13 +243,14 @@ export function validateFilters ({ filters, scopeName, schemaInfo, vars }) {
|
|
|
202
243
|
}
|
|
203
244
|
|
|
204
245
|
/**
|
|
205
|
-
* The response of a many-row write:
|
|
206
|
-
* `query` in the write's transaction, or nothing.
|
|
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.
|
|
207
248
|
* @param {{ api: any, scopeName: string, context: any, ids: unknown[], returning: string, format: string, queryParams?: any }} request
|
|
208
249
|
*/
|
|
209
250
|
export async function manyRowResponse ({ api, scopeName, context, ids, returning, format, queryParams = {} }) {
|
|
210
251
|
if (returning === 'none') return undefined
|
|
211
252
|
const present = ids.filter(id => id !== undefined && id !== null).map(String)
|
|
253
|
+
if (returning === 'count') return { meta: { count: present.length } }
|
|
212
254
|
if (returning === 'minimal') return { data: present.map(id => ({ type: scopeName, id })) }
|
|
213
255
|
|
|
214
256
|
const byId = new Map()
|