json-rest-api 3.0.0-rc.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/docs/API.md +60 -27
- package/docs/GUIDE/07-polymorphic-relationships.md +4 -2
- package/docs/GUIDE/09-pagination-and-sorting.md +4 -2
- package/docs/GUIDE/13-hooks-and-lifecycle.md +6 -1
- package/docs/GUIDE/16-autofiltering.md +1 -1
- package/docs/GUIDE/17-row-policies.md +8 -6
- package/docs/GUIDE/21-schema-and-migrations.md +7 -1
- package/docs/GUIDE/27-many-row-writes.md +201 -28
- package/docs/GUIDE/30-backend-capabilities.md +16 -2
- package/docs/GUIDE/33-migrating-to-v2.md +2 -2
- package/docs/GUIDE/34-scoped-reads-and-locks.md +165 -26
- package/docs/GUIDE/35-migrating-to-v3.md +141 -10
- package/lib/rest-api-errors.js +6 -5
- package/package.json +2 -2
- package/plugins/core/connectors/lib/transport-http-helpers.js +31 -3
- package/plugins/core/file-handling-plugin.js +2 -1
- package/plugins/core/lib/anyapi/anyapi-registry.js +3 -2
- package/plugins/core/lib/anyapi/canonical-link-store.js +5 -2
- package/plugins/core/lib/anyapi/canonical-relationship-reader.js +56 -60
- package/plugins/core/lib/dbIntrospection.js +106 -6
- package/plugins/core/lib/querying/aggregate-query.js +12 -6
- package/plugins/core/lib/querying/include-query-helpers.js +11 -13
- package/plugins/core/lib/querying/include-to-one.js +54 -59
- package/plugins/core/lib/querying/knex-pagination-helpers.js +9 -2
- package/plugins/core/lib/querying/query-constraint.d.ts +11 -0
- package/plugins/core/lib/querying/query-constraint.js +45 -0
- package/plugins/core/lib/querying/read-only-query.js +50 -13
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
- package/plugins/core/lib/storage/canonical-data-helpers.js +72 -41
- package/plugins/core/lib/storage/many-row-data-helpers.js +409 -124
- package/plugins/core/lib/storage/ordinary-data-helpers.js +67 -81
- package/plugins/core/lib/storage/single-row-writes.js +71 -0
- package/plugins/core/lib/storage/storage-adapter.js +18 -8
- package/plugins/core/lib/storage/storage-mapping.js +4 -1
- package/plugins/core/lib/storage/storage-types.d.ts +12 -1
- package/plugins/core/lib/writing/many-to-many-manipulations.js +5 -2
- package/plugins/core/lib/writing/relationship-processor.js +33 -37
- package/plugins/core/rest-api-autofilter-plugin.js +30 -8
- package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
- package/plugins/core/rest-api-plugin-methods/common.js +117 -77
- package/plugins/core/rest-api-plugin-methods/delete-many.js +54 -28
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +22 -0
- package/plugins/core/rest-api-plugin-methods/get.js +48 -42
- package/plugins/core/rest-api-plugin-methods/many-row-common.js +51 -8
- package/plugins/core/rest-api-plugin-methods/patch-many.js +81 -34
- package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
- package/plugins/core/rest-api-plugin-methods/post-many.js +149 -21
- package/plugins/core/rest-api-plugin-methods/put.js +5 -1
- package/plugins/core/rest-api-plugin-methods/scoped-reads.js +72 -27
- package/plugins/core/rest-api-plugin.js +2 -1
- package/plugins/core/rest-api-positioning-plugin.js +1 -1
- package/types/data-access-methods.d.ts +32 -11
- package/types/errors.d.ts +5 -4
- package/types/hook-context.d.ts +2 -0
|
@@ -2,6 +2,7 @@ import { RestApiResourceError, RestApiValidationError } from '../../lib/rest-api
|
|
|
2
2
|
import { assertGenuineSystemFlag, isSystemContext } from '../../lib/system-context.js'
|
|
3
3
|
import { snapshotResourceConfiguration } from './lib/querying-writing/schema-helpers.js'
|
|
4
4
|
import { createStorageAdapterUtilities } from './lib/querying/storage-adapter-utils.js'
|
|
5
|
+
import { ANY_OWNER, recordWriteCondition } from './lib/querying/query-constraint.js'
|
|
5
6
|
|
|
6
7
|
const PUBLIC_PRESET_NAME = 'public'
|
|
7
8
|
// Queries that write or lock the rows they select: shared rows are never theirs.
|
|
@@ -286,6 +287,13 @@ function refuseSharedCreate ({ filter, resolvedValue, scopeName }) {
|
|
|
286
287
|
})
|
|
287
288
|
}
|
|
288
289
|
|
|
290
|
+
// An optional owner that did not resolve stamps nothing; the row must still not end up shared.
|
|
291
|
+
function refuseSharedResult ({ compiledConfig, record, scopeName }) {
|
|
292
|
+
for (const filter of compiledConfig.filters) {
|
|
293
|
+
if (filter.shared && sharedFieldValue(record, filter) == null) refuseSharedCreate({ filter, resolvedValue: null, scopeName })
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
289
297
|
function sharedFieldValue (record, filter) {
|
|
290
298
|
if (filter.relationshipName) {
|
|
291
299
|
const linkage = record?.relationships?.[filter.relationshipName]?.data
|
|
@@ -324,6 +332,7 @@ async function enforceScopedInput ({ compiledConfig, context, scopeName, injectM
|
|
|
324
332
|
injectMissing
|
|
325
333
|
})
|
|
326
334
|
}
|
|
335
|
+
if (injectMissing) refuseSharedResult({ compiledConfig, record: context.inputRecord?.data, scopeName })
|
|
327
336
|
}
|
|
328
337
|
|
|
329
338
|
export const AutoFilterPlugin = {
|
|
@@ -386,9 +395,18 @@ export const AutoFilterPlugin = {
|
|
|
386
395
|
log
|
|
387
396
|
})
|
|
388
397
|
|
|
389
|
-
if (resolvedValue === undefined) continue
|
|
390
|
-
|
|
391
398
|
const columnRef = adapterUtils.translateColumn(scopeName, filter.field, tableName)
|
|
399
|
+
// Every scoped read records the owner a later write of its rows must
|
|
400
|
+
// still match, so a single write that reads first carries it too.
|
|
401
|
+
if (resolvedValue === undefined) {
|
|
402
|
+
// An optional owner that did not resolve: reads are not narrowed, but
|
|
403
|
+
// shared rows stay out of writes and locks.
|
|
404
|
+
if (filter.shared) {
|
|
405
|
+
if (writes) query.whereNotNull(columnRef)
|
|
406
|
+
recordWriteCondition(context, scopeName, filter.field, ANY_OWNER)
|
|
407
|
+
}
|
|
408
|
+
continue
|
|
409
|
+
}
|
|
392
410
|
const translatedValue = adapterUtils.translateFilterValue(scopeName, filter.field, resolvedValue)
|
|
393
411
|
|
|
394
412
|
if (filter.shared) {
|
|
@@ -396,15 +414,15 @@ export const AutoFilterPlugin = {
|
|
|
396
414
|
if (translatedValue === null) {
|
|
397
415
|
if (writes) query.whereRaw('1 = 0')
|
|
398
416
|
else query.whereNull(columnRef)
|
|
399
|
-
} else if (writes) {
|
|
400
|
-
query.where(columnRef, translatedValue)
|
|
401
417
|
} else {
|
|
402
|
-
|
|
418
|
+
if (writes) query.where(columnRef, translatedValue)
|
|
419
|
+
else query.where(builder => builder.where(columnRef, translatedValue).orWhereNull(columnRef))
|
|
420
|
+
recordWriteCondition(context, scopeName, filter.field, translatedValue)
|
|
403
421
|
}
|
|
404
|
-
} else if (translatedValue === null) {
|
|
405
|
-
query.whereNull(columnRef)
|
|
406
422
|
} else {
|
|
407
|
-
query.
|
|
423
|
+
if (translatedValue === null) query.whereNull(columnRef)
|
|
424
|
+
else query.where(columnRef, translatedValue)
|
|
425
|
+
recordWriteCondition(context, scopeName, filter.field, translatedValue)
|
|
408
426
|
}
|
|
409
427
|
}
|
|
410
428
|
})
|
|
@@ -468,6 +486,7 @@ export const AutoFilterPlugin = {
|
|
|
468
486
|
applyResolvedInputValue({ context: { inputRecord }, filter, resolvedValue, injectMissing: true })
|
|
469
487
|
}
|
|
470
488
|
}
|
|
489
|
+
for (const inputRecord of context.inputRecords) refuseSharedResult({ compiledConfig, record: inputRecord?.data, scopeName })
|
|
471
490
|
})
|
|
472
491
|
|
|
473
492
|
// Shared rows are readable by every owner but writable only in a system context.
|
|
@@ -486,6 +505,9 @@ export const AutoFilterPlugin = {
|
|
|
486
505
|
addHook('beforeDataCallPatch', 'autofilter-shared-row-patch', { batch: true }, refuseSharedRowWrite)
|
|
487
506
|
addHook('beforeDataCallPut', 'autofilter-shared-row-put', { batch: true }, refuseSharedRowWrite)
|
|
488
507
|
addHook('beforeDataCallDelete', 'autofilter-shared-row-delete', { batch: true }, refuseSharedRowWrite)
|
|
508
|
+
// Links of a shared row belong to every owner that sees it.
|
|
509
|
+
addHook('beforeDataCallPostRelationship', 'autofilter-shared-row-post-relationship', {}, refuseSharedRowWrite)
|
|
510
|
+
addHook('beforeDataCallDeleteRelationship', 'autofilter-shared-row-delete-relationship', {}, refuseSharedRowWrite)
|
|
489
511
|
|
|
490
512
|
api.autofilter = {
|
|
491
513
|
getConfig: () => ({
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
|
|
2
2
|
import { getEffectiveSortableFields } from '../lib/querying/query-field-sort-helpers.js'
|
|
3
3
|
import { compileAggregateRequest } from '../lib/querying/aggregate-query.js'
|
|
4
|
+
import { isSystemContext } from '../../../lib/system-context.js'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Group a filtered collection and compute measures for each group.
|
|
@@ -18,7 +19,8 @@ export default async function aggregateMethod ({ params, context, vars, helpers,
|
|
|
18
19
|
schemaInfo: context.schemaInfo,
|
|
19
20
|
scopeName,
|
|
20
21
|
defaultLimit: vars.queryDefaultLimit,
|
|
21
|
-
maxLimit: vars.queryMaxLimit
|
|
22
|
+
maxLimit: vars.queryMaxLimit,
|
|
23
|
+
system: isSystemContext(context)
|
|
22
24
|
})
|
|
23
25
|
const requestContracts = getRequestContracts({
|
|
24
26
|
scopeName,
|
|
@@ -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,
|