json-rest-api 2.4.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/docs/API.md +262 -54
- package/docs/GUIDE/01-getting-started.md +1 -1
- package/docs/GUIDE/07-polymorphic-relationships.md +4 -2
- package/docs/GUIDE/09-pagination-and-sorting.md +4 -2
- package/docs/GUIDE/13-hooks-and-lifecycle.md +34 -6
- package/docs/GUIDE/14-custom-resource-methods.md +3 -2
- package/docs/GUIDE/16-autofiltering.md +54 -2
- package/docs/GUIDE/17-row-policies.md +15 -5
- package/docs/GUIDE/19-managed-transactions.md +8 -9
- package/docs/GUIDE/20-transaction-outcomes.md +3 -4
- package/docs/GUIDE/21-schema-and-migrations.md +7 -1
- package/docs/GUIDE/22-version-field-migrations.md +3 -3
- package/docs/GUIDE/27-many-row-writes.md +458 -0
- package/docs/GUIDE/28-socketio.md +10 -2
- package/docs/GUIDE/30-backend-capabilities.md +42 -2
- package/docs/GUIDE/31-positioning.md +3 -1
- package/docs/GUIDE/33-migrating-to-v2.md +2 -2
- package/docs/GUIDE/34-scoped-reads-and-locks.md +464 -0
- package/docs/GUIDE/35-migrating-to-v3.md +325 -0
- package/docs/GUIDE/index.md +5 -2
- package/docs/QUICKSTART.md +2 -2
- package/docs/contributing.md +1 -1
- package/index.d.ts +8 -1
- package/index.js +4 -0
- package/lib/rest-api-errors.js +79 -0
- package/lib/runtime/json-rest-api.js +18 -3
- package/lib/system-context.js +42 -0
- package/package.json +3 -2
- package/plugins/core/bulk-operations-plugin.js +54 -436
- package/plugins/core/connectors/lib/transport-http-helpers.js +34 -3
- package/plugins/core/file-handling-plugin.js +17 -2
- package/plugins/core/lib/anyapi/anyapi-registry.js +3 -2
- package/plugins/core/lib/anyapi/canonical-link-store.js +5 -2
- package/plugins/core/lib/anyapi/canonical-relationship-reader.js +56 -60
- package/plugins/core/lib/dbIntrospection.js +106 -6
- package/plugins/core/lib/querying/aggregate-query.js +215 -22
- package/plugins/core/lib/querying/include-query-helpers.js +11 -13
- package/plugins/core/lib/querying/include-to-one.js +54 -59
- package/plugins/core/lib/querying/knex-pagination-helpers.js +9 -2
- package/plugins/core/lib/querying/query-constraint.d.ts +11 -0
- package/plugins/core/lib/querying/query-constraint.js +45 -0
- package/plugins/core/lib/querying/read-only-query.js +92 -0
- package/plugins/core/lib/querying-writing/compile-schemas.js +14 -0
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
- package/plugins/core/lib/storage/canonical-data-helpers.js +103 -39
- package/plugins/core/lib/storage/many-row-data-helpers.js +738 -0
- package/plugins/core/lib/storage/ordinary-data-helpers.js +63 -79
- package/plugins/core/lib/storage/single-row-writes.js +71 -0
- package/plugins/core/lib/storage/storage-adapter.js +18 -8
- package/plugins/core/lib/storage/storage-mapping.js +4 -1
- package/plugins/core/lib/storage/storage-types.d.ts +21 -3
- package/plugins/core/lib/writing/many-to-many-manipulations.js +10 -5
- package/plugins/core/lib/writing/relationship-processor.js +33 -37
- package/plugins/core/rest-api-anyapi-knex-plugin.js +3 -0
- package/plugins/core/rest-api-autofilter-plugin.js +137 -10
- package/plugins/core/rest-api-knex-plugin.js +2 -0
- package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
- package/plugins/core/rest-api-plugin-methods/common.js +144 -84
- package/plugins/core/rest-api-plugin-methods/delete-many.js +132 -0
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +22 -0
- package/plugins/core/rest-api-plugin-methods/get.js +48 -42
- package/plugins/core/rest-api-plugin-methods/many-row-common.js +274 -0
- package/plugins/core/rest-api-plugin-methods/patch-many.js +333 -0
- package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
- package/plugins/core/rest-api-plugin-methods/post-many.js +295 -0
- package/plugins/core/rest-api-plugin-methods/put.js +5 -1
- package/plugins/core/rest-api-plugin-methods/scoped-reads.js +221 -0
- package/plugins/core/rest-api-plugin.js +17 -0
- package/plugins/core/rest-api-positioning-plugin.js +22 -4
- package/plugins/core/rest-api-query-projections-plugin.js +9 -1
- package/plugins/core/rest-api-row-policy-plugin.js +4 -0
- package/plugins/core/socketio-plugin.js +80 -2
- package/types/autofilter.d.ts +5 -1
- package/types/data-access-methods.d.ts +141 -0
- package/types/errors.d.ts +45 -0
- package/types/hook-context.d.ts +21 -0
- package/types/plugin-options.d.ts +3 -1
- package/types/resource-methods.d.ts +27 -4
- package/types/row-policy.d.ts +3 -0
- package/types/runtime.d.ts +7 -1
- package/docs/GUIDE/27-bulk-operations.md +0 -277
- package/types/bulk-methods.d.ts +0 -63
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// Change many records by id: validate every row in memory, read which of the
|
|
2
|
+
// ids the caller may change (one scoped read per chunk), then run one UPDATE per
|
|
3
|
+
// chunk with CASE expressions. patchWhereIds and patchWhere reuse the same path.
|
|
4
|
+
import { lockRelationshipTargets, processRelationships } from '../lib/writing/relationship-processor.js'
|
|
5
|
+
import { rejectRemovedOptions, resolveFormat } from '../lib/querying-writing/response-options.js'
|
|
6
|
+
import { requireExistingResourceId } from '../lib/querying-writing/resource-id-normalization.js'
|
|
7
|
+
import { validateUpdateRequest } from '../lib/querying-writing/request-contracts.js'
|
|
8
|
+
import { beginWriteTransaction } from '../../../lib/error-context.js'
|
|
9
|
+
import { RestApiRecordsNotFoundError, RestApiValidationError } from '../../../lib/rest-api-errors.js'
|
|
10
|
+
import { chunk, databaseFamily } from '../lib/storage/many-row-data-helpers.js'
|
|
11
|
+
import {
|
|
12
|
+
applyFieldSetters,
|
|
13
|
+
collectRelationshipTargets,
|
|
14
|
+
commitOwnedTransaction,
|
|
15
|
+
handleWriteMethodError,
|
|
16
|
+
keepHiddenLinks,
|
|
17
|
+
validateRelationshipTargets,
|
|
18
|
+
validateResourceAttributesBeforeWrite
|
|
19
|
+
} from './common.js'
|
|
20
|
+
import {
|
|
21
|
+
atIndex,
|
|
22
|
+
keepVisible,
|
|
23
|
+
manyRowResponse,
|
|
24
|
+
noHooks,
|
|
25
|
+
refuseDuplicateIds,
|
|
26
|
+
refuseUnsupportedResource,
|
|
27
|
+
rejectBulkOptions,
|
|
28
|
+
requireWhereScope,
|
|
29
|
+
resolveChunkSize,
|
|
30
|
+
resolveManyRowReturning,
|
|
31
|
+
resolveMissing,
|
|
32
|
+
selectManyInput,
|
|
33
|
+
toDocument,
|
|
34
|
+
unsupported,
|
|
35
|
+
validateFilters
|
|
36
|
+
} from './many-row-common.js'
|
|
37
|
+
|
|
38
|
+
// Ids read per visibility statement; the write chunks are sized separately.
|
|
39
|
+
export const VISIBLE_READ_CHUNK = 1000
|
|
40
|
+
export const VISIBLE_PAGE_SIZE = 10000
|
|
41
|
+
|
|
42
|
+
const invalid = (field, message) => new RestApiValidationError(message, { fields: [field], violations: [{ field, rule: 'many_row_input', message }] })
|
|
43
|
+
|
|
44
|
+
// Fields a many-row patch may never change: identity, ownership and the lock version.
|
|
45
|
+
function protectedFields (schemaInfo) {
|
|
46
|
+
const fields = new Set(['id', ...(schemaInfo.ownershipFields || [])])
|
|
47
|
+
if (schemaInfo.lockVersionField) fields.add(schemaInfo.lockVersionField)
|
|
48
|
+
const relationships = new Set()
|
|
49
|
+
for (const field of schemaInfo.ownershipFields || []) {
|
|
50
|
+
const alias = schemaInfo.schemaStructure[field]?.as
|
|
51
|
+
if (alias) relationships.add(alias)
|
|
52
|
+
}
|
|
53
|
+
return { fields, relationships }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function refuseProtectedFields (names, schemaInfo, index) {
|
|
57
|
+
const { fields } = protectedFields(schemaInfo)
|
|
58
|
+
for (const field of names) {
|
|
59
|
+
if (fields.has(field)) throw atIndex(invalid(`data.attributes.${field}`, `'${field}' cannot be changed by a many-row patch`), index)
|
|
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)
|
|
66
|
+
for (const name of Object.keys(document.data.relationships || {})) {
|
|
67
|
+
if (relationships.has(name)) throw atIndex(invalid(`data.relationships.${name}`, `'${name}' sets ownership and cannot be changed by a many-row patch`), index)
|
|
68
|
+
}
|
|
69
|
+
for (const field of Object.keys(raw)) {
|
|
70
|
+
const definition = schemaInfo.schemaStructure[field]
|
|
71
|
+
if (!definition || definition.virtual || definition.computed || definition.belongsTo || definition.belongsToPolymorphic) {
|
|
72
|
+
throw atIndex(invalid(`data.attributes.${field}`, `'${field}' cannot take a raw SQL value: it must be a stored, non-relationship field`), index)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveOnlyIf (onlyIf, { schemaInfo, storageAdapter }) {
|
|
78
|
+
if (onlyIf === undefined) return undefined
|
|
79
|
+
if (!onlyIf || typeof onlyIf !== 'object' || Array.isArray(onlyIf) || !Object.keys(onlyIf).length) {
|
|
80
|
+
throw invalid('onlyIf', 'onlyIf must be an object of field values')
|
|
81
|
+
}
|
|
82
|
+
const conditions = {}
|
|
83
|
+
for (const [field, value] of Object.entries(onlyIf)) {
|
|
84
|
+
const definition = schemaInfo.schemaStructure[field]
|
|
85
|
+
if (!definition || definition.virtual || definition.computed || field === 'id') throw invalid(`onlyIf.${field}`, `'${field}' is not a stored field`)
|
|
86
|
+
if (value !== null && (typeof value === 'object' && !(value instanceof Date))) throw invalid(`onlyIf.${field}`, 'onlyIf values must be scalars or null')
|
|
87
|
+
conditions[storageAdapter.translateColumn(field)] = value === null ? null : storageAdapter.translateFilterValue(field, value)
|
|
88
|
+
}
|
|
89
|
+
return conditions
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
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.
|
|
96
|
+
* @param {any} args
|
|
97
|
+
* @param {{ operation: string, entries: Array<{ id: unknown, document: any, raw: Record<string, unknown> }>, started?: boolean, selection?: { filters?: Record<string, unknown> } }} batch
|
|
98
|
+
*/
|
|
99
|
+
async function runPatchMany (args, { operation, entries, started = false, selection }) {
|
|
100
|
+
const { params, context, vars, helpers, scope, runHooks, hasHooks, perRecordHooks, scopeOptions, scopeName, api } = args
|
|
101
|
+
const schemaInfo = context.schemaInfo
|
|
102
|
+
const storageAdapter = helpers.getStorageAdapter(scopeName)
|
|
103
|
+
const chunkSize = resolveChunkSize(params.chunkSize)
|
|
104
|
+
const missing = selection ? 'skip' : resolveMissing(params.missing)
|
|
105
|
+
refuseUnsupportedResource({ api, helpers, perRecordHooks, scopeName, schemaInfo, operation, kind: 'patch', storageHelper: 'dataUpdateByIds' })
|
|
106
|
+
const onlyIf = resolveOnlyIf(params.onlyIf, { schemaInfo, storageAdapter })
|
|
107
|
+
entries.forEach((entry, index) => refuseProtectedInput({ ...entry, schemaInfo, index }))
|
|
108
|
+
|
|
109
|
+
if (!started) await beginWriteTransaction(context, params.transaction, helpers.newTransaction, runHooks)
|
|
110
|
+
context.db = context.transaction
|
|
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)))]
|
|
114
|
+
await runHooks('beforeProcessingPatchMany')
|
|
115
|
+
|
|
116
|
+
const targets = new Map()
|
|
117
|
+
const rows = entries.map((entry, index) => {
|
|
118
|
+
const rowContext = { ...context, method: 'patch', inputRecord: entry.document, originalInputAttributes: undefined, bulkIndex: index }
|
|
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 ||= {}
|
|
122
|
+
try {
|
|
123
|
+
validateUpdateRequest({ method: 'patch', params: {}, context: rowContext, vars, scopeOptions, scopeName })
|
|
124
|
+
} catch (error) { throw atIndex(error, index) }
|
|
125
|
+
collectRelationshipTargets(rowContext.inputRecord, api, targets)
|
|
126
|
+
return { rowContext, raw: entry.raw, index, clearsLink: false }
|
|
127
|
+
})
|
|
128
|
+
refuseDuplicateIds(rows.map(row => row.rowContext.id), storageAdapter)
|
|
129
|
+
await validateRelationshipTargets(context, [...targets.values()], helpers, api)
|
|
130
|
+
|
|
131
|
+
const parents = []
|
|
132
|
+
for (const row of rows) {
|
|
133
|
+
const { rowContext, index } = row
|
|
134
|
+
try {
|
|
135
|
+
const { belongsToUpdates, belongsToTargets, manyToManyRelationships, reverseRelationships } = processRelationships(scope, { context: rowContext })
|
|
136
|
+
if (manyToManyRelationships.length || reverseRelationships.length) {
|
|
137
|
+
throw unsupported(scopeName, operation, 'rows set many-to-many or reverse relationships; change those links with single writes')
|
|
138
|
+
}
|
|
139
|
+
await validateResourceAttributesBeforeWrite({ context: rowContext, schema: schemaInfo.schemaInstance, belongsToUpdates, runHooks: noHooks, isPartialValidation: true })
|
|
140
|
+
rowContext.inputRecord.data.attributes = { ...rowContext.inputRecord.data.attributes, ...belongsToUpdates }
|
|
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) {
|
|
146
|
+
throw invalid('data', 'A patch row must change at least one field')
|
|
147
|
+
}
|
|
148
|
+
parents.push(...belongsToTargets)
|
|
149
|
+
} catch (error) { throw atIndex(error, index) }
|
|
150
|
+
}
|
|
151
|
+
|
|
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
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
context.ids = keptRows.map(row => String(row.rowContext.id))
|
|
190
|
+
context.inputRecords = keptRows.map(row => row.rowContext.inputRecord)
|
|
191
|
+
await runHooks('beforeDataCallPatchMany')
|
|
192
|
+
await lockRelationshipTargets(api, context.transaction, parents)
|
|
193
|
+
|
|
194
|
+
const updates = []
|
|
195
|
+
for (const { rowContext, raw, index } of keptRows) {
|
|
196
|
+
try {
|
|
197
|
+
await applyFieldSetters(rowContext, api, helpers)
|
|
198
|
+
} catch (error) { throw atIndex(error, index) }
|
|
199
|
+
const row = storageAdapter.toStorageRow(rowContext.inputRecord.data.attributes || {}, { context: rowContext, operation: 'patch' })
|
|
200
|
+
delete row[storageAdapter.getIdColumn()]
|
|
201
|
+
for (const [field, value] of Object.entries(raw)) row[storageAdapter.translateColumn(field)] = value
|
|
202
|
+
updates.push({ id: rowContext.id, row })
|
|
203
|
+
}
|
|
204
|
+
|
|
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)
|
|
215
|
+
await runHooks('afterDataCallPatchMany')
|
|
216
|
+
|
|
217
|
+
const response = await manyRowResponse({
|
|
218
|
+
api, scopeName, context, ids: context.ids, returning: context.returning, format: context.format, queryParams: params.queryParams
|
|
219
|
+
})
|
|
220
|
+
await commitOwnedTransaction(context)
|
|
221
|
+
return response
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function setupManyWrite ({ params, context, vars, scopes, scopeName }, method) {
|
|
225
|
+
context.method = method
|
|
226
|
+
rejectRemovedOptions(params)
|
|
227
|
+
rejectBulkOptions(params)
|
|
228
|
+
context.scopeName = scopeName
|
|
229
|
+
context.schemaInfo = scopes[scopeName].vars.schemaInfo
|
|
230
|
+
context.format = resolveFormat(params.format, vars.format)
|
|
231
|
+
context.simplified = context.format === 'plain'
|
|
232
|
+
context.returning = resolveManyRowReturning(params.returning)
|
|
233
|
+
context.params = params
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// One record of attributes for every id: `data: {…}` or `document: { data: {…} }`.
|
|
237
|
+
function selectSharedRecord (params, { scopeName, schemaInfo }) {
|
|
238
|
+
const hasData = Object.hasOwn(params, 'data')
|
|
239
|
+
if (hasData === Object.hasOwn(params, 'document')) throw invalid('data', 'Supply exactly one of data or document')
|
|
240
|
+
const record = hasData ? params.data : params.document?.data
|
|
241
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) throw invalid(hasData ? 'data' : 'document.data', 'The patch must be one record object')
|
|
242
|
+
if (record.id !== undefined) throw invalid(hasData ? 'data.id' : 'document.data.id', 'The patch applies to the listed ids; it cannot carry an id')
|
|
243
|
+
return { inputKey: hasData ? 'data' : 'document', record }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function entriesFor (ids, shared, { scopeName, schemaInfo }) {
|
|
247
|
+
return ids.map(id => {
|
|
248
|
+
const record = shared.inputKey === 'data'
|
|
249
|
+
? { ...shared.record, id }
|
|
250
|
+
: { ...shared.record, type: shared.record.type ?? scopeName, id: String(id) }
|
|
251
|
+
const { document, raw } = toDocument({ record, inputKey: shared.inputKey, scopeName, schemaInfo, allowRaw: true })
|
|
252
|
+
return { id, document, raw }
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function requireIds (ids, { scopeOptions, vars, scopeName }) {
|
|
257
|
+
if (!Array.isArray(ids) || !ids.length) throw invalid('ids', 'ids must be a non-empty array')
|
|
258
|
+
return ids.map(id => requireExistingResourceId(id, { scopeOptions, vars, scopeName }))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Reads every visible identifier the filters select, in keyset pages.
|
|
263
|
+
* @param {any} args
|
|
264
|
+
* @param {Record<string, unknown> | undefined} filters
|
|
265
|
+
*/
|
|
266
|
+
export async function visibleIdsByFilters ({ helpers, context, runHooks, scopeName }, filters) {
|
|
267
|
+
const ids = []
|
|
268
|
+
let after
|
|
269
|
+
for (;;) {
|
|
270
|
+
const page = await helpers.dataVisibleIds({ scopeName, context, runHooks, filters, queryPurpose: 'bulk-write', after, limit: VISIBLE_PAGE_SIZE })
|
|
271
|
+
ids.push(...page)
|
|
272
|
+
if (page.length < VISIBLE_PAGE_SIZE) return ids
|
|
273
|
+
after = page[page.length - 1]
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** @param {any} args */
|
|
278
|
+
export async function patchManyMethod (args) {
|
|
279
|
+
const { params, context, scopeName, log } = args
|
|
280
|
+
try {
|
|
281
|
+
setupManyWrite(args, 'patchMany')
|
|
282
|
+
const { inputKey, records } = selectManyInput(params)
|
|
283
|
+
const entries = records.map((record, index) => {
|
|
284
|
+
if (record.id === undefined || record.id === null) throw atIndex(invalid('data.id', 'Every patch row needs an id'), index)
|
|
285
|
+
try {
|
|
286
|
+
return { id: record.id, ...toDocument({ record, inputKey, scopeName, schemaInfo: context.schemaInfo, allowRaw: true }) }
|
|
287
|
+
} catch (error) { throw atIndex(error, index) }
|
|
288
|
+
})
|
|
289
|
+
return await runPatchMany(args, { operation: 'patchMany', entries })
|
|
290
|
+
} catch (error) {
|
|
291
|
+
await handleWriteMethodError(error, context, 'PATCH_MANY', scopeName, log)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** @param {any} args */
|
|
296
|
+
export async function patchWhereIdsMethod (args) {
|
|
297
|
+
const { params, context, scopeName, log } = args
|
|
298
|
+
try {
|
|
299
|
+
setupManyWrite(args, 'patchWhereIds')
|
|
300
|
+
const shared = selectSharedRecord(params, { scopeName, schemaInfo: context.schemaInfo })
|
|
301
|
+
const ids = requireIds(params.ids, args)
|
|
302
|
+
return await runPatchMany(args, { operation: 'patchWhereIds', entries: entriesFor(ids, shared, { scopeName, schemaInfo: context.schemaInfo }) })
|
|
303
|
+
} catch (error) {
|
|
304
|
+
await handleWriteMethodError(error, context, 'PATCH_WHERE_IDS', scopeName, log)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** @param {any} args */
|
|
309
|
+
export async function patchWhereMethod (args) {
|
|
310
|
+
const { params, context, scopeName, vars, helpers, log } = args
|
|
311
|
+
try {
|
|
312
|
+
setupManyWrite(args, 'patchWhere')
|
|
313
|
+
const shared = selectSharedRecord(params, { scopeName, schemaInfo: context.schemaInfo })
|
|
314
|
+
if (typeof helpers.dataVisibleIds !== 'function') {
|
|
315
|
+
throw unsupported(scopeName, 'patchWhere', 'its storage plugin has no many-row support (AnyAPI storage takes single writes)')
|
|
316
|
+
}
|
|
317
|
+
const filters = validateFilters({ filters: params.filters, scopeName, schemaInfo: context.schemaInfo, vars })
|
|
318
|
+
requireWhereScope({ filters, all: params.all, missing: params.missing, operation: 'patchWhere' })
|
|
319
|
+
// The visible ids are read in the write's transaction, so open it first.
|
|
320
|
+
await beginWriteTransaction(context, params.transaction, helpers.newTransaction, args.runHooks)
|
|
321
|
+
context.db = context.transaction
|
|
322
|
+
const ids = await visibleIdsByFilters(args, filters)
|
|
323
|
+
if (!ids.length) {
|
|
324
|
+
await commitOwnedTransaction(context)
|
|
325
|
+
return context.returning === 'count' ? { meta: { count: 0 } } : context.returning === 'none' ? undefined : { data: [] }
|
|
326
|
+
}
|
|
327
|
+
return await runPatchMany(args, {
|
|
328
|
+
operation: 'patchWhere', entries: entriesFor(ids, shared, { scopeName, schemaInfo: context.schemaInfo }), started: true, selection: { filters }
|
|
329
|
+
})
|
|
330
|
+
} catch (error) {
|
|
331
|
+
await handleWriteMethodError(error, context, 'PATCH_WHERE', scopeName, log)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -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',
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// Create many records: each row goes through validation, relationship access,
|
|
2
|
+
// ownership stamping, setters and permissions in memory; then one INSERT runs
|
|
3
|
+
// per chunk. Statement counts stay flat as the batch grows.
|
|
4
|
+
import { lockRelationshipTargets, processRelationships } from '../lib/writing/relationship-processor.js'
|
|
5
|
+
import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
|
|
6
|
+
import { requireDocumentResourceId } from '../lib/querying-writing/resource-id-normalization.js'
|
|
7
|
+
import { rejectRemovedOptions, resolveFormat } from '../lib/querying-writing/response-options.js'
|
|
8
|
+
import { beginWriteTransaction } from '../../../lib/error-context.js'
|
|
9
|
+
import { RestApiResourceError, RestApiValidationError } from '../../../lib/rest-api-errors.js'
|
|
10
|
+
import { chunk } from '../lib/storage/many-row-data-helpers.js'
|
|
11
|
+
import {
|
|
12
|
+
applyFieldSetters,
|
|
13
|
+
collectRelationshipTargets,
|
|
14
|
+
commitOwnedTransaction,
|
|
15
|
+
handleWriteMethodError,
|
|
16
|
+
keepHiddenLinks,
|
|
17
|
+
validateRelationshipTargets,
|
|
18
|
+
validateResourceAttributesBeforeWrite
|
|
19
|
+
} from './common.js'
|
|
20
|
+
import {
|
|
21
|
+
atIndex,
|
|
22
|
+
manyRowResponse,
|
|
23
|
+
noHooks,
|
|
24
|
+
refuseUnsupportedResource,
|
|
25
|
+
rejectBulkOptions,
|
|
26
|
+
resolveChunkSize,
|
|
27
|
+
resolveManyRowReturning,
|
|
28
|
+
selectManyInput,
|
|
29
|
+
toDocument,
|
|
30
|
+
unsupported
|
|
31
|
+
} from './many-row-common.js'
|
|
32
|
+
import { VISIBLE_READ_CHUNK } from './patch-many.js'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Upserts may only match the caller's own rows: the conflict keys must hold
|
|
36
|
+
* every ownership field, and merge cannot run under a row policy it cannot see.
|
|
37
|
+
* The id (by its own name or the idProperty's) may be the only key on a
|
|
38
|
+
* resource with neither, where no other owner's row can match.
|
|
39
|
+
*/
|
|
40
|
+
function resolveOnConflict (onConflict, { scopeName, schemaInfo, scopes }) {
|
|
41
|
+
if (onConflict === undefined) return undefined
|
|
42
|
+
const fail = message => { throw new RestApiValidationError(message, { fields: ['onConflict'] }) }
|
|
43
|
+
if (!onConflict || typeof onConflict !== 'object' || Array.isArray(onConflict)) fail('onConflict must be an object')
|
|
44
|
+
const { keys: requestedKeys, action, merge, ...unknown } = onConflict
|
|
45
|
+
let keys = requestedKeys
|
|
46
|
+
if (Object.keys(unknown).length) fail(`Unknown onConflict options: ${Object.keys(unknown).join(', ')}`)
|
|
47
|
+
if (action !== 'ignore' && action !== 'merge') fail("onConflict.action must be 'ignore' or 'merge'")
|
|
48
|
+
const fields = schemaInfo.schemaStructure
|
|
49
|
+
const stored = field => typeof field === 'string' && Object.hasOwn(fields, field) && !fields[field].virtual && !fields[field].computed
|
|
50
|
+
const ownership = schemaInfo.ownershipFields || []
|
|
51
|
+
const rowPolicy = scopes[scopeName]?.vars?.rowPolicy
|
|
52
|
+
const idKey = Array.isArray(keys) && keys.some(field => field === 'id' || field === schemaInfo.idProperty)
|
|
53
|
+
if (idKey) {
|
|
54
|
+
if (keys.length !== 1) fail('onConflict on the id takes the id as its only key')
|
|
55
|
+
if (ownership.length || rowPolicy) {
|
|
56
|
+
throw unsupported(scopeName, 'postMany', 'onConflict on the id needs a resource without ownership fields or a row policy, or a conflict could match a row the caller cannot see; use keys that include the ownership field(s)')
|
|
57
|
+
}
|
|
58
|
+
keys = ['id']
|
|
59
|
+
} else if (!Array.isArray(keys) || !keys.length || !keys.every(stored)) fail('onConflict.keys must list stored fields of the resource')
|
|
60
|
+
|
|
61
|
+
const missingOwners = ownership.filter(field => !keys.includes(field))
|
|
62
|
+
if (missingOwners.length) {
|
|
63
|
+
throw unsupported(scopeName, 'postMany', `onConflict.keys must include the ownership field(s) ${missingOwners.join(', ')}, or a conflict could match another owner's row`)
|
|
64
|
+
}
|
|
65
|
+
if (action === 'merge' && rowPolicy) {
|
|
66
|
+
throw unsupported(scopeName, 'postMany', 'onConflict merge cannot see the row policy; insert with ignore, then patchMany the rows you may change')
|
|
67
|
+
}
|
|
68
|
+
const protectedFields = new Set(['id', ...keys, ...ownership, schemaInfo.lockVersionField].filter(Boolean))
|
|
69
|
+
if (action === 'merge' && merge !== undefined) {
|
|
70
|
+
if (!Array.isArray(merge) || !merge.length || !merge.every(stored)) fail('onConflict.merge must list stored fields of the resource')
|
|
71
|
+
const forbidden = merge.filter(field => protectedFields.has(field))
|
|
72
|
+
if (forbidden.length) fail(`onConflict.merge cannot change ${forbidden.join(', ')}`)
|
|
73
|
+
} else if (merge !== undefined) {
|
|
74
|
+
fail('onConflict.merge applies only to the merge action')
|
|
75
|
+
}
|
|
76
|
+
// Without a list, merge updates every stored field the rows supply.
|
|
77
|
+
const mergeFields = supplied => (merge ?? [...new Set(supplied.flatMap(fields => [...fields]))])
|
|
78
|
+
.filter(field => stored(field) && !protectedFields.has(field))
|
|
79
|
+
return { keys, action, mergeFields }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The fields a row supplies: those the caller sent (with belongs-to links),
|
|
84
|
+
* and those a setter added. Schema defaults are not supplied, even when a
|
|
85
|
+
* setter rewrites them, so a merge never resets a stored value to a default.
|
|
86
|
+
* @param {any} row
|
|
87
|
+
* @param {Record<string, unknown>} beforeSetters
|
|
88
|
+
* @param {Record<string, unknown>} afterSetters
|
|
89
|
+
*/
|
|
90
|
+
function suppliedFields (row, beforeSetters, afterSetters) {
|
|
91
|
+
const fields = new Set(Object.keys(row.originalInputAttributes || {}))
|
|
92
|
+
for (const field of Object.keys(afterSetters)) if (!Object.hasOwn(beforeSetters, field)) fields.add(field)
|
|
93
|
+
return fields
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Upsert merge: lock the rows the conflict keys match, check that the caller
|
|
98
|
+
* may patch each of them, insert the other rows with a plain INSERT (a clash on
|
|
99
|
+
* any other unique index fails the batch), then update the matched rows by id
|
|
100
|
+
* with the merge fields each row supplies.
|
|
101
|
+
* Returns the identifier of every row, merged or inserted.
|
|
102
|
+
* @param {any} args
|
|
103
|
+
* @param {{ rows: any[], attributes: Record<string, unknown>[], supplied: Set<string>[], onConflict: { keys: string[], merge: string[] }, chunkSize: number, needIds: boolean }} batch
|
|
104
|
+
*/
|
|
105
|
+
async function mergeRows ({ context, helpers, scope, scopes, runHooks, scopeName }, { rows, attributes, supplied, onConflict, chunkSize, needIds }) {
|
|
106
|
+
const existing = await helpers.dataIdsByKeys({ scopeName, context, rows: attributes, keys: onConflict.keys, chunkSize })
|
|
107
|
+
const matched = attributes.flatMap((_row, index) => existing[index] === undefined ? [] : [index])
|
|
108
|
+
const missing = attributes.flatMap((_row, index) => existing[index] === undefined ? [index] : [])
|
|
109
|
+
|
|
110
|
+
const visible = new Map()
|
|
111
|
+
for (const part of chunk(matched.map(index => existing[index]), VISIBLE_READ_CHUNK)) {
|
|
112
|
+
const records = await helpers.dataGetMinimal({ scopeName, context, runHooks, ids: part, filters: {}, queryPurpose: 'bulk-write' })
|
|
113
|
+
for (const record of records) visible.set(String(record.id), record)
|
|
114
|
+
}
|
|
115
|
+
for (const index of matched) {
|
|
116
|
+
const id = String(existing[index])
|
|
117
|
+
const minimalRecord = visible.get(id)
|
|
118
|
+
try {
|
|
119
|
+
if (!minimalRecord) {
|
|
120
|
+
throw new RestApiResourceError(`The ${scopeName} record these conflict keys match cannot be changed by the caller`, { subtype: 'forbidden', resourceType: scopeName })
|
|
121
|
+
}
|
|
122
|
+
await scope.checkPermissions({ method: 'patch', originalContext: { ...rows[index], method: 'patch', id, minimalRecord, originalMinimalRecord: minimalRecord } })
|
|
123
|
+
} catch (error) { throw atIndex(error, index) }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const ids = [...existing]
|
|
127
|
+
if (missing.length) {
|
|
128
|
+
const inserted = await helpers.dataInsertMany({ scopeName, context, rows: missing.map(index => attributes[index]), chunkSize, needIds })
|
|
129
|
+
missing.forEach((index, position) => { ids[index] = inserted?.[position] })
|
|
130
|
+
}
|
|
131
|
+
if (matched.length) {
|
|
132
|
+
const storageAdapter = helpers.getStorageAdapter(scopeName)
|
|
133
|
+
// Only the merge fields a row supplies change; the lock version advances.
|
|
134
|
+
const merges = matched.map(index => Object.fromEntries(onConflict.merge
|
|
135
|
+
.filter(field => supplied[index].has(field))
|
|
136
|
+
.map(field => [field, attributes[index][field]])))
|
|
137
|
+
// A merge clears a link the way a patch does, so it keeps one to a hidden target.
|
|
138
|
+
await keepHiddenLinks({ context, scopes, schemaInfo: context.schemaInfo, rows: matched.map((index, position) => ({ updates: merges[position], stored: visible.get(String(existing[index])) })) })
|
|
139
|
+
const updates = matched.map((index, position) => ({
|
|
140
|
+
id: existing[index],
|
|
141
|
+
row: storageAdapter.toStorageRow(merges[position], { context: rows[index], operation: 'patch' })
|
|
142
|
+
}))
|
|
143
|
+
await helpers.dataUpdateByIds({ scopeName, context, updates, chunkSize })
|
|
144
|
+
}
|
|
145
|
+
return ids
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** @param {any} args */
|
|
149
|
+
export default async function postManyMethod ({
|
|
150
|
+
params, context, vars, helpers, scope, scopes, runHooks, hasHooks, perRecordHooks, scopeOptions, scopeName, api, log
|
|
151
|
+
}) {
|
|
152
|
+
context.method = 'postMany'
|
|
153
|
+
try {
|
|
154
|
+
rejectRemovedOptions(params)
|
|
155
|
+
rejectBulkOptions(params)
|
|
156
|
+
context.scopeName = scopeName
|
|
157
|
+
context.schemaInfo = scopes[scopeName].vars.schemaInfo
|
|
158
|
+
context.format = resolveFormat(params.format, vars.format)
|
|
159
|
+
context.simplified = context.format === 'plain'
|
|
160
|
+
context.returning = resolveManyRowReturning(params.returning)
|
|
161
|
+
context.params = params
|
|
162
|
+
const chunkSize = resolveChunkSize(params.chunkSize)
|
|
163
|
+
const { inputKey, records } = selectManyInput(params)
|
|
164
|
+
refuseUnsupportedResource({
|
|
165
|
+
api, helpers, perRecordHooks, scopeName, schemaInfo: context.schemaInfo, operation: 'postMany', kind: 'post', storageHelper: 'dataInsertMany'
|
|
166
|
+
})
|
|
167
|
+
const conflict = resolveOnConflict(params.onConflict, { scopeName, schemaInfo: context.schemaInfo, scopes })
|
|
168
|
+
if (conflict?.action === 'merge') {
|
|
169
|
+
// Merged rows are updates: the resource must take patches as a batch too.
|
|
170
|
+
refuseUnsupportedResource({
|
|
171
|
+
api, helpers, perRecordHooks, scopeName, schemaInfo: context.schemaInfo, operation: 'postMany', kind: 'patch', storageHelper: 'dataUpdateByIds'
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
await beginWriteTransaction(context, params.transaction, helpers.newTransaction, runHooks)
|
|
176
|
+
context.db = context.transaction
|
|
177
|
+
|
|
178
|
+
const schemaInfo = context.schemaInfo
|
|
179
|
+
context.inputRecords = records.map((record, index) => {
|
|
180
|
+
try {
|
|
181
|
+
return toDocument({ record, inputKey, scopeName, schemaInfo }).document
|
|
182
|
+
} catch (error) { throw atIndex(error, index) }
|
|
183
|
+
})
|
|
184
|
+
// Batch hooks: ownership stamping and the refusals of plugins that cannot batch.
|
|
185
|
+
await runHooks('beforeProcessingPostMany')
|
|
186
|
+
|
|
187
|
+
const contracts = getRequestContracts({ scopeName, schemaInfo, includeDepthLimit: vars.includeDepthLimit, sortableFields: vars.sortableFields })
|
|
188
|
+
const targets = new Map()
|
|
189
|
+
context.inputRecords = context.inputRecords.map((document, index) => {
|
|
190
|
+
try {
|
|
191
|
+
const validated = validateRequestContractOrThrow(contracts.post, document, 'POST request body is invalid')
|
|
192
|
+
if (validated.data.id !== undefined) validated.data.id = requireDocumentResourceId(validated.data.id, { scopeOptions, vars })
|
|
193
|
+
collectRelationshipTargets(validated, api, targets)
|
|
194
|
+
return validated
|
|
195
|
+
} catch (error) { throw atIndex(error, index) }
|
|
196
|
+
})
|
|
197
|
+
// One scoped read per related type for the whole batch.
|
|
198
|
+
await validateRelationshipTargets(context, [...targets.values()], helpers, api)
|
|
199
|
+
|
|
200
|
+
const parents = []
|
|
201
|
+
const rows = []
|
|
202
|
+
for (const [index, document] of context.inputRecords.entries()) {
|
|
203
|
+
const rowContext = { ...context, method: 'post', inputRecord: document, originalInputAttributes: undefined, bulkIndex: index }
|
|
204
|
+
try {
|
|
205
|
+
const { belongsToUpdates, belongsToTargets, manyToManyRelationships, reverseRelationships } = processRelationships(scope, { context: rowContext })
|
|
206
|
+
if (manyToManyRelationships.length || reverseRelationships.length) {
|
|
207
|
+
throw unsupported(scopeName, 'postMany', 'rows set many-to-many or reverse relationships; create those links with single writes')
|
|
208
|
+
}
|
|
209
|
+
if (Object.keys(belongsToUpdates).length) {
|
|
210
|
+
document.data.attributes = { ...document.data.attributes, ...belongsToUpdates }
|
|
211
|
+
}
|
|
212
|
+
await validateResourceAttributesBeforeWrite({ context: rowContext, schema: schemaInfo.schemaInstance, belongsToUpdates, runHooks: noHooks })
|
|
213
|
+
rowContext.minimalRecord = {
|
|
214
|
+
type: scopeName,
|
|
215
|
+
...(document.data.id !== undefined && document.data.id !== null ? { id: String(document.data.id) } : {}),
|
|
216
|
+
attributes: structuredClone(document.data.attributes || {}),
|
|
217
|
+
relationships: structuredClone(document.data.relationships || {})
|
|
218
|
+
}
|
|
219
|
+
await scope.checkPermissions({ method: 'post', originalContext: rowContext })
|
|
220
|
+
parents.push(...belongsToTargets)
|
|
221
|
+
rows.push(rowContext)
|
|
222
|
+
} catch (error) { throw atIndex(error, index) }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (conflict) {
|
|
226
|
+
const byId = conflict.keys[0] === 'id'
|
|
227
|
+
rows.forEach((row, index) => {
|
|
228
|
+
const id = row.inputRecord.data.id
|
|
229
|
+
if (byId && (id === undefined || id === null)) throw atIndex(new RestApiValidationError('An upsert on the id needs an id in every row', { fields: ['data.id'] }), index)
|
|
230
|
+
if (byId || id === undefined || id === null) return
|
|
231
|
+
throw atIndex(new RestApiValidationError('An upsert row cannot carry an id: its conflict keys identify it', { fields: ['data.id'] }), index)
|
|
232
|
+
})
|
|
233
|
+
if (!byId) {
|
|
234
|
+
rows.forEach((row, index) => {
|
|
235
|
+
const attributes = row.inputRecord.data.attributes || {}
|
|
236
|
+
const empty = conflict.keys.filter(field => attributes[field] === undefined || attributes[field] === null)
|
|
237
|
+
if (empty.length) throw atIndex(new RestApiValidationError(`Conflict keys need values: ${empty.join(', ')}`, { fields: empty.map(field => `data.attributes.${field}`) }), index)
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
await runHooks('beforeDataCallPostMany')
|
|
243
|
+
// Parents are locked once for the batch, in the transaction.
|
|
244
|
+
await lockRelationshipTargets(api, context.transaction, parents)
|
|
245
|
+
|
|
246
|
+
const attributes = []
|
|
247
|
+
const supplied = []
|
|
248
|
+
for (const [index, row] of rows.entries()) {
|
|
249
|
+
const beforeSetters = { ...(row.inputRecord.data.attributes || {}) }
|
|
250
|
+
try {
|
|
251
|
+
await applyFieldSetters(row, api, helpers)
|
|
252
|
+
} catch (error) { throw atIndex(error, index) }
|
|
253
|
+
const data = row.inputRecord.data
|
|
254
|
+
supplied.push(suppliedFields(row, beforeSetters, data.attributes || {}))
|
|
255
|
+
attributes.push({ ...(data.attributes || {}), ...(data.id !== undefined ? { id: data.id } : {}) })
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let onConflict
|
|
259
|
+
if (conflict) {
|
|
260
|
+
onConflict = { keys: conflict.keys, action: conflict.action, merge: conflict.action === 'merge' ? conflict.mergeFields(supplied) : [] }
|
|
261
|
+
if (conflict.action === 'merge' && !onConflict.merge.length) {
|
|
262
|
+
throw new RestApiValidationError('onConflict merge has no fields to update; use ignore', { fields: ['onConflict'] })
|
|
263
|
+
}
|
|
264
|
+
const seen = new Map()
|
|
265
|
+
attributes.forEach((row, index) => {
|
|
266
|
+
const key = JSON.stringify(onConflict.keys.map(field => row[field]))
|
|
267
|
+
if (seen.has(key)) {
|
|
268
|
+
throw atIndex(new RestApiValidationError(`Rows ${seen.get(key)} and ${index} have the same conflict keys`, {
|
|
269
|
+
fields: onConflict.keys.map(field => `data.attributes.${field}`)
|
|
270
|
+
}), index)
|
|
271
|
+
}
|
|
272
|
+
seen.set(key, index)
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Without a conflict every row is inserted or the batch fails, so a count needs no ids.
|
|
277
|
+
const countsRows = context.returning === 'count' && !onConflict
|
|
278
|
+
const needIds = (context.returning !== 'none' && !countsRows) || hasHooks('afterDataCallPostMany')
|
|
279
|
+
const ids = onConflict?.action === 'merge'
|
|
280
|
+
? await mergeRows({ context, helpers, scope, scopes, runHooks, scopeName }, { rows, attributes, supplied, onConflict, chunkSize, needIds })
|
|
281
|
+
: await helpers.dataInsertMany({ scopeName, context, rows: attributes, chunkSize, needIds, onConflict })
|
|
282
|
+
context.ids = ids?.map(id => id === undefined || id === null ? undefined : String(id))
|
|
283
|
+
await runHooks('afterDataCallPostMany')
|
|
284
|
+
|
|
285
|
+
const response = countsRows
|
|
286
|
+
? { meta: { count: rows.length } }
|
|
287
|
+
: await manyRowResponse({
|
|
288
|
+
api, scopeName, context, ids: context.ids || [], returning: context.returning, format: context.format, queryParams: params.queryParams
|
|
289
|
+
})
|
|
290
|
+
await commitOwnedTransaction(context)
|
|
291
|
+
return response
|
|
292
|
+
} catch (error) {
|
|
293
|
+
await handleWriteMethodError(error, context, 'POST_MANY', scopeName, log)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -19,7 +19,8 @@ import {
|
|
|
19
19
|
validatePivotResource,
|
|
20
20
|
handleRecordReturnAfterWrite,
|
|
21
21
|
handleWriteMethodError,
|
|
22
|
-
commitOwnedTransaction
|
|
22
|
+
commitOwnedTransaction,
|
|
23
|
+
keepHiddenLinks
|
|
23
24
|
} from './common.js'
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -130,6 +131,9 @@ export default async function putMethod ({
|
|
|
130
131
|
}
|
|
131
132
|
}
|
|
132
133
|
}
|
|
134
|
+
if (context.isUpdate) {
|
|
135
|
+
await keepHiddenLinks({ context, scopes, schemaInfo: context.schemaInfo, rows: [{ updates: belongsToUpdates, stored: context.minimalRecord, inputRecord: context.inputRecord }] })
|
|
136
|
+
}
|
|
133
137
|
|
|
134
138
|
// Merge belongsTo updates with attributes
|
|
135
139
|
if (Object.keys(belongsToUpdates).length > 0) {
|