json-rest-api 3.0.0-rc.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/docs/API.md +60 -27
- package/docs/GUIDE/07-polymorphic-relationships.md +4 -2
- package/docs/GUIDE/09-pagination-and-sorting.md +4 -2
- package/docs/GUIDE/13-hooks-and-lifecycle.md +6 -1
- package/docs/GUIDE/16-autofiltering.md +1 -1
- package/docs/GUIDE/17-row-policies.md +8 -6
- package/docs/GUIDE/21-schema-and-migrations.md +7 -1
- package/docs/GUIDE/27-many-row-writes.md +195 -28
- package/docs/GUIDE/30-backend-capabilities.md +16 -2
- package/docs/GUIDE/33-migrating-to-v2.md +2 -2
- package/docs/GUIDE/34-scoped-reads-and-locks.md +165 -26
- package/docs/GUIDE/35-migrating-to-v3.md +141 -10
- package/lib/rest-api-errors.js +6 -5
- package/package.json +2 -2
- package/plugins/core/connectors/lib/transport-http-helpers.js +31 -3
- package/plugins/core/file-handling-plugin.js +2 -1
- package/plugins/core/lib/anyapi/anyapi-registry.js +3 -2
- package/plugins/core/lib/anyapi/canonical-link-store.js +5 -2
- package/plugins/core/lib/anyapi/canonical-relationship-reader.js +56 -60
- package/plugins/core/lib/dbIntrospection.js +106 -6
- package/plugins/core/lib/querying/aggregate-query.js +12 -6
- package/plugins/core/lib/querying/include-query-helpers.js +11 -13
- package/plugins/core/lib/querying/include-to-one.js +54 -59
- package/plugins/core/lib/querying/knex-pagination-helpers.js +9 -2
- package/plugins/core/lib/querying/query-constraint.d.ts +11 -0
- package/plugins/core/lib/querying/query-constraint.js +45 -0
- package/plugins/core/lib/querying/read-only-query.js +50 -13
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
- package/plugins/core/lib/storage/canonical-data-helpers.js +72 -41
- package/plugins/core/lib/storage/many-row-data-helpers.js +409 -124
- package/plugins/core/lib/storage/ordinary-data-helpers.js +47 -79
- package/plugins/core/lib/storage/single-row-writes.js +71 -0
- package/plugins/core/lib/storage/storage-adapter.js +18 -8
- package/plugins/core/lib/storage/storage-mapping.js +4 -1
- package/plugins/core/lib/storage/storage-types.d.ts +8 -1
- package/plugins/core/lib/writing/many-to-many-manipulations.js +5 -2
- package/plugins/core/lib/writing/relationship-processor.js +33 -37
- package/plugins/core/rest-api-autofilter-plugin.js +30 -8
- package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
- package/plugins/core/rest-api-plugin-methods/common.js +117 -77
- package/plugins/core/rest-api-plugin-methods/delete-many.js +54 -28
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +22 -0
- package/plugins/core/rest-api-plugin-methods/get.js +48 -42
- package/plugins/core/rest-api-plugin-methods/many-row-common.js +47 -5
- package/plugins/core/rest-api-plugin-methods/patch-many.js +81 -34
- package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
- package/plugins/core/rest-api-plugin-methods/post-many.js +139 -21
- package/plugins/core/rest-api-plugin-methods/put.js +5 -1
- package/plugins/core/rest-api-plugin-methods/scoped-reads.js +72 -27
- package/plugins/core/rest-api-plugin.js +2 -1
- package/plugins/core/rest-api-positioning-plugin.js +1 -1
- package/types/data-access-methods.d.ts +32 -11
- package/types/errors.d.ts +5 -4
- package/types/hook-context.d.ts +2 -0
|
@@ -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',
|
|
@@ -4,14 +4,16 @@
|
|
|
4
4
|
import { lockRelationshipTargets, processRelationships } from '../lib/writing/relationship-processor.js'
|
|
5
5
|
import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
|
|
6
6
|
import { requireDocumentResourceId } from '../lib/querying-writing/resource-id-normalization.js'
|
|
7
|
-
import { rejectRemovedOptions, resolveFormat
|
|
7
|
+
import { rejectRemovedOptions, resolveFormat } from '../lib/querying-writing/response-options.js'
|
|
8
8
|
import { beginWriteTransaction } from '../../../lib/error-context.js'
|
|
9
|
-
import { RestApiValidationError } from '../../../lib/rest-api-errors.js'
|
|
9
|
+
import { RestApiResourceError, RestApiValidationError } from '../../../lib/rest-api-errors.js'
|
|
10
|
+
import { chunk } from '../lib/storage/many-row-data-helpers.js'
|
|
10
11
|
import {
|
|
11
12
|
applyFieldSetters,
|
|
12
13
|
collectRelationshipTargets,
|
|
13
14
|
commitOwnedTransaction,
|
|
14
15
|
handleWriteMethodError,
|
|
16
|
+
keepHiddenLinks,
|
|
15
17
|
validateRelationshipTargets,
|
|
16
18
|
validateResourceAttributesBeforeWrite
|
|
17
19
|
} from './common.js'
|
|
@@ -22,32 +24,45 @@ import {
|
|
|
22
24
|
refuseUnsupportedResource,
|
|
23
25
|
rejectBulkOptions,
|
|
24
26
|
resolveChunkSize,
|
|
27
|
+
resolveManyRowReturning,
|
|
25
28
|
selectManyInput,
|
|
26
29
|
toDocument,
|
|
27
30
|
unsupported
|
|
28
31
|
} from './many-row-common.js'
|
|
32
|
+
import { VISIBLE_READ_CHUNK } from './patch-many.js'
|
|
29
33
|
|
|
30
34
|
/**
|
|
31
35
|
* Upserts may only match the caller's own rows: the conflict keys must hold
|
|
32
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.
|
|
33
39
|
*/
|
|
34
40
|
function resolveOnConflict (onConflict, { scopeName, schemaInfo, scopes }) {
|
|
35
41
|
if (onConflict === undefined) return undefined
|
|
36
42
|
const fail = message => { throw new RestApiValidationError(message, { fields: ['onConflict'] }) }
|
|
37
43
|
if (!onConflict || typeof onConflict !== 'object' || Array.isArray(onConflict)) fail('onConflict must be an object')
|
|
38
|
-
const { keys, action, merge, ...unknown } = onConflict
|
|
44
|
+
const { keys: requestedKeys, action, merge, ...unknown } = onConflict
|
|
45
|
+
let keys = requestedKeys
|
|
39
46
|
if (Object.keys(unknown).length) fail(`Unknown onConflict options: ${Object.keys(unknown).join(', ')}`)
|
|
40
47
|
if (action !== 'ignore' && action !== 'merge') fail("onConflict.action must be 'ignore' or 'merge'")
|
|
41
48
|
const fields = schemaInfo.schemaStructure
|
|
42
49
|
const stored = field => typeof field === 'string' && Object.hasOwn(fields, field) && !fields[field].virtual && !fields[field].computed
|
|
43
|
-
if (!Array.isArray(keys) || !keys.length || !keys.every(stored)) fail('onConflict.keys must list stored fields of the resource')
|
|
44
|
-
|
|
45
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
|
+
|
|
46
61
|
const missingOwners = ownership.filter(field => !keys.includes(field))
|
|
47
62
|
if (missingOwners.length) {
|
|
48
63
|
throw unsupported(scopeName, 'postMany', `onConflict.keys must include the ownership field(s) ${missingOwners.join(', ')}, or a conflict could match another owner's row`)
|
|
49
64
|
}
|
|
50
|
-
if (action === 'merge' &&
|
|
65
|
+
if (action === 'merge' && rowPolicy) {
|
|
51
66
|
throw unsupported(scopeName, 'postMany', 'onConflict merge cannot see the row policy; insert with ignore, then patchMany the rows you may change')
|
|
52
67
|
}
|
|
53
68
|
const protectedFields = new Set(['id', ...keys, ...ownership, schemaInfo.lockVersionField].filter(Boolean))
|
|
@@ -59,11 +74,77 @@ function resolveOnConflict (onConflict, { scopeName, schemaInfo, scopes }) {
|
|
|
59
74
|
fail('onConflict.merge applies only to the merge action')
|
|
60
75
|
}
|
|
61
76
|
// Without a list, merge updates every stored field the rows supply.
|
|
62
|
-
const mergeFields =
|
|
77
|
+
const mergeFields = supplied => (merge ?? [...new Set(supplied.flatMap(fields => [...fields]))])
|
|
63
78
|
.filter(field => stored(field) && !protectedFields.has(field))
|
|
64
79
|
return { keys, action, mergeFields }
|
|
65
80
|
}
|
|
66
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
|
+
|
|
67
148
|
/** @param {any} args */
|
|
68
149
|
export default async function postManyMethod ({
|
|
69
150
|
params, context, vars, helpers, scope, scopes, runHooks, hasHooks, perRecordHooks, scopeOptions, scopeName, api, log
|
|
@@ -76,7 +157,7 @@ export default async function postManyMethod ({
|
|
|
76
157
|
context.schemaInfo = scopes[scopeName].vars.schemaInfo
|
|
77
158
|
context.format = resolveFormat(params.format, vars.format)
|
|
78
159
|
context.simplified = context.format === 'plain'
|
|
79
|
-
context.returning =
|
|
160
|
+
context.returning = resolveManyRowReturning(params.returning)
|
|
80
161
|
context.params = params
|
|
81
162
|
const chunkSize = resolveChunkSize(params.chunkSize)
|
|
82
163
|
const { inputKey, records } = selectManyInput(params)
|
|
@@ -84,6 +165,12 @@ export default async function postManyMethod ({
|
|
|
84
165
|
api, helpers, perRecordHooks, scopeName, schemaInfo: context.schemaInfo, operation: 'postMany', kind: 'post', storageHelper: 'dataInsertMany'
|
|
85
166
|
})
|
|
86
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
|
+
}
|
|
87
174
|
|
|
88
175
|
await beginWriteTransaction(context, params.transaction, helpers.newTransaction, runHooks)
|
|
89
176
|
context.db = context.transaction
|
|
@@ -135,16 +222,20 @@ export default async function postManyMethod ({
|
|
|
135
222
|
} catch (error) { throw atIndex(error, index) }
|
|
136
223
|
}
|
|
137
224
|
|
|
138
|
-
let onConflict
|
|
139
225
|
if (conflict) {
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
if (
|
|
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)
|
|
144
232
|
})
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
+
})
|
|
148
239
|
}
|
|
149
240
|
}
|
|
150
241
|
|
|
@@ -153,22 +244,49 @@ export default async function postManyMethod ({
|
|
|
153
244
|
await lockRelationshipTargets(api, context.transaction, parents)
|
|
154
245
|
|
|
155
246
|
const attributes = []
|
|
247
|
+
const supplied = []
|
|
156
248
|
for (const [index, row] of rows.entries()) {
|
|
249
|
+
const beforeSetters = { ...(row.inputRecord.data.attributes || {}) }
|
|
157
250
|
try {
|
|
158
251
|
await applyFieldSetters(row, api, helpers)
|
|
159
252
|
} catch (error) { throw atIndex(error, index) }
|
|
160
253
|
const data = row.inputRecord.data
|
|
254
|
+
supplied.push(suppliedFields(row, beforeSetters, data.attributes || {}))
|
|
161
255
|
attributes.push({ ...(data.attributes || {}), ...(data.id !== undefined ? { id: data.id } : {}) })
|
|
162
256
|
}
|
|
163
257
|
|
|
164
|
-
|
|
165
|
-
|
|
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 })
|
|
166
282
|
context.ids = ids?.map(id => id === undefined || id === null ? undefined : String(id))
|
|
167
283
|
await runHooks('afterDataCallPostMany')
|
|
168
284
|
|
|
169
|
-
const response =
|
|
170
|
-
|
|
171
|
-
|
|
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
|
+
})
|
|
172
290
|
await commitOwnedTransaction(context)
|
|
173
291
|
return response
|
|
174
292
|
} catch (error) {
|
|
@@ -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) {
|