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
|
@@ -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,15 @@ 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.
|
|
207
|
-
*
|
|
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
|
+
* `locked`: the transaction holds row locks on the ids, so they are read as locked.
|
|
249
|
+
* @param {{ api: any, scopeName: string, context: any, ids: unknown[], returning: string, format: string, queryParams?: any, locked?: boolean }} request
|
|
208
250
|
*/
|
|
209
|
-
export async function manyRowResponse ({ api, scopeName, context, ids, returning, format, queryParams = {} }) {
|
|
251
|
+
export async function manyRowResponse ({ api, scopeName, context, ids, returning, format, queryParams = {}, locked = false }) {
|
|
210
252
|
if (returning === 'none') return undefined
|
|
211
253
|
const present = ids.filter(id => id !== undefined && id !== null).map(String)
|
|
254
|
+
if (returning === 'count') return { meta: { count: present.length } }
|
|
212
255
|
if (returning === 'minimal') return { data: present.map(id => ({ type: scopeName, id })) }
|
|
213
256
|
|
|
214
257
|
const byId = new Map()
|
|
@@ -221,7 +264,7 @@ export async function manyRowResponse ({ api, scopeName, context, ids, returning
|
|
|
221
264
|
transaction: context.transaction,
|
|
222
265
|
pageSizeLimit: part.length,
|
|
223
266
|
queryParams: { fields: queryParams.fields, include: queryParams.include, sort: ['id'], page: { size: part.length } },
|
|
224
|
-
[queryConstraint]: { scopeName, idsQuery: part }
|
|
267
|
+
[queryConstraint]: { scopeName, idsQuery: part, locked }
|
|
225
268
|
}, { ...context, method: undefined, knexQuery: undefined })
|
|
226
269
|
for (const record of page.data) byId.set(String(record.id), record)
|
|
227
270
|
for (const resource of page.included || []) included.set(`${resource.type}:${resource.id}`, resource)
|
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
// ids the caller may change (one scoped read per chunk), then run one UPDATE per
|
|
3
3
|
// chunk with CASE expressions. patchWhereIds and patchWhere reuse the same path.
|
|
4
4
|
import { lockRelationshipTargets, processRelationships } from '../lib/writing/relationship-processor.js'
|
|
5
|
-
import { rejectRemovedOptions, resolveFormat
|
|
5
|
+
import { rejectRemovedOptions, resolveFormat } from '../lib/querying-writing/response-options.js'
|
|
6
6
|
import { requireExistingResourceId } from '../lib/querying-writing/resource-id-normalization.js'
|
|
7
7
|
import { validateUpdateRequest } from '../lib/querying-writing/request-contracts.js'
|
|
8
8
|
import { beginWriteTransaction } from '../../../lib/error-context.js'
|
|
9
|
-
import { RestApiValidationError } from '../../../lib/rest-api-errors.js'
|
|
10
|
-
import { chunk } from '../lib/storage/many-row-data-helpers.js'
|
|
9
|
+
import { RestApiRecordsNotFoundError, RestApiValidationError } from '../../../lib/rest-api-errors.js'
|
|
10
|
+
import { chunk, databaseFamily } from '../lib/storage/many-row-data-helpers.js'
|
|
11
11
|
import {
|
|
12
12
|
applyFieldSetters,
|
|
13
13
|
collectRelationshipTargets,
|
|
14
14
|
commitOwnedTransaction,
|
|
15
15
|
handleWriteMethodError,
|
|
16
|
+
keepHiddenLinks,
|
|
16
17
|
validateRelationshipTargets,
|
|
17
18
|
validateResourceAttributesBeforeWrite
|
|
18
19
|
} from './common.js'
|
|
@@ -24,7 +25,9 @@ import {
|
|
|
24
25
|
refuseDuplicateIds,
|
|
25
26
|
refuseUnsupportedResource,
|
|
26
27
|
rejectBulkOptions,
|
|
28
|
+
requireWhereScope,
|
|
27
29
|
resolveChunkSize,
|
|
30
|
+
resolveManyRowReturning,
|
|
28
31
|
resolveMissing,
|
|
29
32
|
selectManyInput,
|
|
30
33
|
toDocument,
|
|
@@ -50,12 +53,16 @@ function protectedFields (schemaInfo) {
|
|
|
50
53
|
return { fields, relationships }
|
|
51
54
|
}
|
|
52
55
|
|
|
53
|
-
function
|
|
54
|
-
const { fields
|
|
55
|
-
const
|
|
56
|
-
for (const field of [...Object.keys(attributes), ...Object.keys(raw)]) {
|
|
56
|
+
function refuseProtectedFields (names, schemaInfo, index) {
|
|
57
|
+
const { fields } = protectedFields(schemaInfo)
|
|
58
|
+
for (const field of names) {
|
|
57
59
|
if (fields.has(field)) throw atIndex(invalid(`data.attributes.${field}`, `'${field}' cannot be changed by a many-row patch`), index)
|
|
58
60
|
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function refuseProtectedInput ({ document, raw, schemaInfo, index }) {
|
|
64
|
+
const { relationships } = protectedFields(schemaInfo)
|
|
65
|
+
refuseProtectedFields([...Object.keys(document.data.attributes || {}), ...Object.keys(raw)], schemaInfo, index)
|
|
59
66
|
for (const name of Object.keys(document.data.relationships || {})) {
|
|
60
67
|
if (relationships.has(name)) throw atIndex(invalid(`data.relationships.${name}`, `'${name}' sets ownership and cannot be changed by a many-row patch`), index)
|
|
61
68
|
}
|
|
@@ -83,16 +90,18 @@ function resolveOnlyIf (onlyIf, { schemaInfo, storageAdapter }) {
|
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
/**
|
|
86
|
-
* The shared path: `entries` are { id, document, raw } in input order.
|
|
93
|
+
* The shared path: `entries` are { id, document, raw } in input order. A
|
|
94
|
+
* `selection` (patchWhere) gives the filters that found the ids: its rows are
|
|
95
|
+
* never missing, and the write answers with the rows it changed.
|
|
87
96
|
* @param {any} args
|
|
88
|
-
* @param {{ operation: string, entries: Array<{ id: unknown, document: any, raw: Record<string, unknown> }>, started?: boolean }} batch
|
|
97
|
+
* @param {{ operation: string, entries: Array<{ id: unknown, document: any, raw: Record<string, unknown> }>, started?: boolean, selection?: { filters?: Record<string, unknown> } }} batch
|
|
89
98
|
*/
|
|
90
|
-
async function runPatchMany (args, { operation, entries, started = false }) {
|
|
91
|
-
const { params, context, vars, helpers, scope, runHooks, perRecordHooks, scopeOptions, scopeName, api } = args
|
|
99
|
+
async function runPatchMany (args, { operation, entries, started = false, selection }) {
|
|
100
|
+
const { params, context, vars, helpers, scope, runHooks, hasHooks, perRecordHooks, scopeOptions, scopeName, api } = args
|
|
92
101
|
const schemaInfo = context.schemaInfo
|
|
93
102
|
const storageAdapter = helpers.getStorageAdapter(scopeName)
|
|
94
103
|
const chunkSize = resolveChunkSize(params.chunkSize)
|
|
95
|
-
const missing = resolveMissing(params.missing)
|
|
104
|
+
const missing = selection ? 'skip' : resolveMissing(params.missing)
|
|
96
105
|
refuseUnsupportedResource({ api, helpers, perRecordHooks, scopeName, schemaInfo, operation, kind: 'patch', storageHelper: 'dataUpdateByIds' })
|
|
97
106
|
const onlyIf = resolveOnlyIf(params.onlyIf, { schemaInfo, storageAdapter })
|
|
98
107
|
entries.forEach((entry, index) => refuseProtectedInput({ ...entry, schemaInfo, index }))
|
|
@@ -100,18 +109,21 @@ async function runPatchMany (args, { operation, entries, started = false }) {
|
|
|
100
109
|
if (!started) await beginWriteTransaction(context, params.transaction, helpers.newTransaction, runHooks)
|
|
101
110
|
context.db = context.transaction
|
|
102
111
|
context.inputRecords = entries.map(entry => entry.document)
|
|
112
|
+
// Fields set by raw SQL values, which plugins that refuse fields check too.
|
|
113
|
+
context.rawFields = [...new Set(entries.flatMap(entry => Object.keys(entry.raw)))]
|
|
103
114
|
await runHooks('beforeProcessingPatchMany')
|
|
104
115
|
|
|
105
116
|
const targets = new Map()
|
|
106
117
|
const rows = entries.map((entry, index) => {
|
|
107
118
|
const rowContext = { ...context, method: 'patch', inputRecord: entry.document, originalInputAttributes: undefined, bulkIndex: index }
|
|
108
|
-
// A row of raw values alone
|
|
109
|
-
|
|
119
|
+
// A row of raw values alone, or one that only advances a lock version,
|
|
120
|
+
// still carries an (empty) attributes object.
|
|
121
|
+
if (Object.keys(entry.raw).length || schemaInfo.lockVersionField) entry.document.data.attributes ||= {}
|
|
110
122
|
try {
|
|
111
123
|
validateUpdateRequest({ method: 'patch', params: {}, context: rowContext, vars, scopeOptions, scopeName })
|
|
112
124
|
} catch (error) { throw atIndex(error, index) }
|
|
113
125
|
collectRelationshipTargets(rowContext.inputRecord, api, targets)
|
|
114
|
-
return { rowContext, raw: entry.raw, index }
|
|
126
|
+
return { rowContext, raw: entry.raw, index, clearsLink: false }
|
|
115
127
|
})
|
|
116
128
|
refuseDuplicateIds(rows.map(row => row.rowContext.id), storageAdapter)
|
|
117
129
|
await validateRelationshipTargets(context, [...targets.values()], helpers, api)
|
|
@@ -126,26 +138,52 @@ async function runPatchMany (args, { operation, entries, started = false }) {
|
|
|
126
138
|
}
|
|
127
139
|
await validateResourceAttributesBeforeWrite({ context: rowContext, schema: schemaInfo.schemaInstance, belongsToUpdates, runHooks: noHooks, isPartialValidation: true })
|
|
128
140
|
rowContext.inputRecord.data.attributes = { ...rowContext.inputRecord.data.attributes, ...belongsToUpdates }
|
|
129
|
-
|
|
141
|
+
row.clearsLink = Object.values(belongsToUpdates).includes(null)
|
|
142
|
+
// A polymorphic relationship writes its own type and id columns, which may be ownership fields.
|
|
143
|
+
refuseProtectedFields(Object.keys(rowContext.inputRecord.data.attributes), schemaInfo, index)
|
|
144
|
+
// A row with no fields still advances a lock version.
|
|
145
|
+
if (!Object.keys(rowContext.inputRecord.data.attributes).length && !Object.keys(row.raw).length && !schemaInfo.lockVersionField) {
|
|
130
146
|
throw invalid('data', 'A patch row must change at least one field')
|
|
131
147
|
}
|
|
132
148
|
parents.push(...belongsToTargets)
|
|
133
149
|
} catch (error) { throw atIndex(error, index) }
|
|
134
150
|
}
|
|
135
151
|
|
|
136
|
-
// Which of the requested rows the caller may change
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
152
|
+
// Which of the requested rows the caller may change. With missing: 'skip' and
|
|
153
|
+
// no hook that needs the rows first, the UPDATE decides it, carrying the
|
|
154
|
+
// scope, and its changed rows answer. Otherwise one scoped read per chunk
|
|
155
|
+
// gives each row's minimal record to the permission check. On MySQL a
|
|
156
|
+
// locking read locks every listed row before it checks the scope, so ids
|
|
157
|
+
// from the caller always get that read first; a selection's ids come from
|
|
158
|
+
// its own scoped read. A selection's UPDATE carries the scope and its filters
|
|
159
|
+
// either way, so it changes only rows that still match.
|
|
160
|
+
// A row that clears a link reads first too, to keep a link to a hidden target.
|
|
161
|
+
const readFirst = rows.some(row => row.clearsLink) || !(missing === 'skip' && !hasHooks('checkPermissions') && !hasHooks('beforeDataCallPatchMany') &&
|
|
162
|
+
(selection || databaseFamily(context.db) !== 'mysql'))
|
|
163
|
+
const scoped = Boolean(selection) || !readFirst
|
|
164
|
+
let keptRows = rows
|
|
165
|
+
if (readFirst) {
|
|
166
|
+
const visible = new Map()
|
|
167
|
+
for (const part of chunk(rows.map(row => row.rowContext.id), VISIBLE_READ_CHUNK)) {
|
|
168
|
+
const records = await helpers.dataGetMinimal({ scopeName, context, runHooks, ids: part, filters: {}, queryPurpose: 'bulk-write' })
|
|
169
|
+
for (const record of records) visible.set(String(storageAdapter.translateFilterValue('id', record.id)), record)
|
|
170
|
+
}
|
|
171
|
+
const kept = keepVisible({ requested: rows.map(row => row.rowContext.id), visible, missing, scopeName, storageAdapter })
|
|
172
|
+
keptRows = rows.filter(row => kept.includes(row.rowContext.id))
|
|
173
|
+
for (const { rowContext } of keptRows) {
|
|
174
|
+
rowContext.minimalRecord = visible.get(String(storageAdapter.translateFilterValue('id', rowContext.id)))
|
|
175
|
+
}
|
|
176
|
+
await keepHiddenLinks({
|
|
177
|
+
context,
|
|
178
|
+
scopes: api.resources,
|
|
179
|
+
schemaInfo,
|
|
180
|
+
rows: keptRows.filter(row => row.clearsLink).map(({ rowContext }) => ({ updates: rowContext.inputRecord.data.attributes, stored: rowContext.minimalRecord, inputRecord: rowContext.inputRecord }))
|
|
181
|
+
})
|
|
182
|
+
for (const { rowContext, index } of keptRows) {
|
|
183
|
+
try {
|
|
184
|
+
await scope.checkPermissions({ method: 'patch', originalContext: rowContext })
|
|
185
|
+
} catch (error) { throw atIndex(error, index) }
|
|
186
|
+
}
|
|
149
187
|
}
|
|
150
188
|
|
|
151
189
|
context.ids = keptRows.map(row => String(row.rowContext.id))
|
|
@@ -164,8 +202,16 @@ async function runPatchMany (args, { operation, entries, started = false }) {
|
|
|
164
202
|
updates.push({ id: rowContext.id, row })
|
|
165
203
|
}
|
|
166
204
|
|
|
167
|
-
const changed = updates.length
|
|
168
|
-
|
|
205
|
+
const changed = new Set((updates.length
|
|
206
|
+
? await helpers.dataUpdateByIds({ scopeName, context, runHooks, updates, onlyIf, chunkSize, scoped, filters: selection?.filters })
|
|
207
|
+
: []).map(String))
|
|
208
|
+
// A row that left the caller's scope after the visibility read is not written.
|
|
209
|
+
const unchanged = updates.length - changed.size
|
|
210
|
+
if (unchanged && !onlyIf && missing === 'throw') throw new RestApiRecordsNotFoundError({ resourceType: scopeName, missing: unchanged })
|
|
211
|
+
// After-hooks see the changed rows' ids and inputs, paired by position.
|
|
212
|
+
const written = keptRows.filter(row => changed.has(String(storageAdapter.translateFilterValue('id', row.rowContext.id))))
|
|
213
|
+
context.ids = written.map(row => String(storageAdapter.translateFilterValue('id', row.rowContext.id)))
|
|
214
|
+
context.inputRecords = written.map(row => row.rowContext.inputRecord)
|
|
169
215
|
await runHooks('afterDataCallPatchMany')
|
|
170
216
|
|
|
171
217
|
const response = await manyRowResponse({
|
|
@@ -183,7 +229,7 @@ function setupManyWrite ({ params, context, vars, scopes, scopeName }, method) {
|
|
|
183
229
|
context.schemaInfo = scopes[scopeName].vars.schemaInfo
|
|
184
230
|
context.format = resolveFormat(params.format, vars.format)
|
|
185
231
|
context.simplified = context.format === 'plain'
|
|
186
|
-
context.returning =
|
|
232
|
+
context.returning = resolveManyRowReturning(params.returning)
|
|
187
233
|
context.params = params
|
|
188
234
|
}
|
|
189
235
|
|
|
@@ -269,16 +315,17 @@ export async function patchWhereMethod (args) {
|
|
|
269
315
|
throw unsupported(scopeName, 'patchWhere', 'its storage plugin has no many-row support (AnyAPI storage takes single writes)')
|
|
270
316
|
}
|
|
271
317
|
const filters = validateFilters({ filters: params.filters, scopeName, schemaInfo: context.schemaInfo, vars })
|
|
318
|
+
requireWhereScope({ filters, all: params.all, missing: params.missing, operation: 'patchWhere' })
|
|
272
319
|
// The visible ids are read in the write's transaction, so open it first.
|
|
273
320
|
await beginWriteTransaction(context, params.transaction, helpers.newTransaction, args.runHooks)
|
|
274
321
|
context.db = context.transaction
|
|
275
322
|
const ids = await visibleIdsByFilters(args, filters)
|
|
276
323
|
if (!ids.length) {
|
|
277
324
|
await commitOwnedTransaction(context)
|
|
278
|
-
return context.returning === 'none' ? undefined : { data: [] }
|
|
325
|
+
return context.returning === 'count' ? { meta: { count: 0 } } : context.returning === 'none' ? undefined : { data: [] }
|
|
279
326
|
}
|
|
280
327
|
return await runPatchMany(args, {
|
|
281
|
-
operation: 'patchWhere', entries: entriesFor(ids, shared, { scopeName, schemaInfo: context.schemaInfo }), started: true
|
|
328
|
+
operation: 'patchWhere', entries: entriesFor(ids, shared, { scopeName, schemaInfo: context.schemaInfo }), started: true, selection: { filters }
|
|
282
329
|
})
|
|
283
330
|
} catch (error) {
|
|
284
331
|
await handleWriteMethodError(error, context, 'PATCH_WHERE', scopeName, log)
|
|
@@ -18,7 +18,8 @@ import {
|
|
|
18
18
|
validatePivotResource,
|
|
19
19
|
handleRecordReturnAfterWrite,
|
|
20
20
|
handleWriteMethodError,
|
|
21
|
-
commitOwnedTransaction
|
|
21
|
+
commitOwnedTransaction,
|
|
22
|
+
keepHiddenLinks
|
|
22
23
|
} from './common.js'
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -90,6 +91,7 @@ export default async function patchMethod ({
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
context.minimalRecord = minimalRecord
|
|
94
|
+
await keepHiddenLinks({ context, scopes, schemaInfo: context.schemaInfo, rows: [{ updates: belongsToUpdates, stored: minimalRecord, inputRecord: context.inputRecord }] })
|
|
93
95
|
|
|
94
96
|
await scope.checkPermissions({
|
|
95
97
|
method: 'patch',
|