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.
Files changed (55) hide show
  1. package/README.md +2 -2
  2. package/docs/API.md +60 -27
  3. package/docs/GUIDE/07-polymorphic-relationships.md +4 -2
  4. package/docs/GUIDE/09-pagination-and-sorting.md +4 -2
  5. package/docs/GUIDE/13-hooks-and-lifecycle.md +6 -1
  6. package/docs/GUIDE/16-autofiltering.md +1 -1
  7. package/docs/GUIDE/17-row-policies.md +8 -6
  8. package/docs/GUIDE/21-schema-and-migrations.md +7 -1
  9. package/docs/GUIDE/27-many-row-writes.md +195 -28
  10. package/docs/GUIDE/30-backend-capabilities.md +16 -2
  11. package/docs/GUIDE/33-migrating-to-v2.md +2 -2
  12. package/docs/GUIDE/34-scoped-reads-and-locks.md +165 -26
  13. package/docs/GUIDE/35-migrating-to-v3.md +141 -10
  14. package/lib/rest-api-errors.js +6 -5
  15. package/package.json +2 -2
  16. package/plugins/core/connectors/lib/transport-http-helpers.js +31 -3
  17. package/plugins/core/file-handling-plugin.js +2 -1
  18. package/plugins/core/lib/anyapi/anyapi-registry.js +3 -2
  19. package/plugins/core/lib/anyapi/canonical-link-store.js +5 -2
  20. package/plugins/core/lib/anyapi/canonical-relationship-reader.js +56 -60
  21. package/plugins/core/lib/dbIntrospection.js +106 -6
  22. package/plugins/core/lib/querying/aggregate-query.js +12 -6
  23. package/plugins/core/lib/querying/include-query-helpers.js +11 -13
  24. package/plugins/core/lib/querying/include-to-one.js +54 -59
  25. package/plugins/core/lib/querying/knex-pagination-helpers.js +9 -2
  26. package/plugins/core/lib/querying/query-constraint.d.ts +11 -0
  27. package/plugins/core/lib/querying/query-constraint.js +45 -0
  28. package/plugins/core/lib/querying/read-only-query.js +50 -13
  29. package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
  30. package/plugins/core/lib/storage/canonical-data-helpers.js +72 -41
  31. package/plugins/core/lib/storage/many-row-data-helpers.js +409 -124
  32. package/plugins/core/lib/storage/ordinary-data-helpers.js +47 -79
  33. package/plugins/core/lib/storage/single-row-writes.js +71 -0
  34. package/plugins/core/lib/storage/storage-adapter.js +18 -8
  35. package/plugins/core/lib/storage/storage-mapping.js +4 -1
  36. package/plugins/core/lib/storage/storage-types.d.ts +8 -1
  37. package/plugins/core/lib/writing/many-to-many-manipulations.js +5 -2
  38. package/plugins/core/lib/writing/relationship-processor.js +33 -37
  39. package/plugins/core/rest-api-autofilter-plugin.js +30 -8
  40. package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
  41. package/plugins/core/rest-api-plugin-methods/common.js +117 -77
  42. package/plugins/core/rest-api-plugin-methods/delete-many.js +54 -28
  43. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +22 -0
  44. package/plugins/core/rest-api-plugin-methods/get.js +48 -42
  45. package/plugins/core/rest-api-plugin-methods/many-row-common.js +47 -5
  46. package/plugins/core/rest-api-plugin-methods/patch-many.js +81 -34
  47. package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
  48. package/plugins/core/rest-api-plugin-methods/post-many.js +139 -21
  49. package/plugins/core/rest-api-plugin-methods/put.js +5 -1
  50. package/plugins/core/rest-api-plugin-methods/scoped-reads.js +72 -27
  51. package/plugins/core/rest-api-plugin.js +2 -1
  52. package/plugins/core/rest-api-positioning-plugin.js +1 -1
  53. package/types/data-access-methods.d.ts +32 -11
  54. package/types/errors.d.ts +5 -4
  55. 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, resolveReturning } from '../lib/querying-writing/response-options.js'
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 refuseProtectedInput ({ document, raw, schemaInfo, index }) {
54
- const { fields, relationships } = protectedFields(schemaInfo)
55
- const attributes = document.data.attributes || {}
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 still carries an (empty) attributes object.
109
- if (Object.keys(entry.raw).length) entry.document.data.attributes ||= {}
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
- if (!Object.keys(rowContext.inputRecord.data.attributes).length && !Object.keys(row.raw).length) {
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: one scoped read per chunk.
137
- const visible = new Map()
138
- for (const part of chunk(rows.map(row => row.rowContext.id), VISIBLE_READ_CHUNK)) {
139
- const records = await helpers.dataGetMinimal({ scopeName, context, runHooks, ids: part, filters: {}, queryPurpose: 'bulk-write' })
140
- for (const record of records) visible.set(String(storageAdapter.translateFilterValue('id', record.id)), record)
141
- }
142
- const kept = keepVisible({ requested: rows.map(row => row.rowContext.id), visible, missing, scopeName, storageAdapter })
143
- const keptRows = rows.filter(row => kept.includes(row.rowContext.id))
144
- for (const { rowContext, index } of keptRows) {
145
- rowContext.minimalRecord = visible.get(String(storageAdapter.translateFilterValue('id', rowContext.id)))
146
- try {
147
- await scope.checkPermissions({ method: 'patch', originalContext: rowContext })
148
- } catch (error) { throw atIndex(error, index) }
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 ? await helpers.dataUpdateByIds({ scopeName, context, updates, onlyIf, chunkSize }) : []
168
- context.ids = changed.map(String)
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 = resolveReturning(params.returning, 'none')
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, resolveReturning } from '../lib/querying-writing/response-options.js'
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' && scopes[scopeName]?.vars?.rowPolicy) {
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 = rows => merge ?? [...new Set(rows.flatMap(row => Object.keys(row)))]
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 = resolveReturning(params.returning, 'none')
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 rowAttributes = rows.map(row => row.inputRecord.data.attributes || {})
141
- rowAttributes.forEach((attributes, index) => {
142
- const empty = conflict.keys.filter(field => attributes[field] === undefined || attributes[field] === null)
143
- if (empty.length) throw atIndex(new RestApiValidationError(`Conflict keys need values: ${empty.join(', ')}`, { fields: empty.map(field => `data.attributes.${field}`) }), index)
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
- onConflict = { keys: conflict.keys, action: conflict.action, merge: conflict.action === 'merge' ? conflict.mergeFields(rowAttributes) : [] }
146
- if (conflict.action === 'merge' && !onConflict.merge.length) {
147
- throw new RestApiValidationError('onConflict merge has no fields to update; use ignore', { fields: ['onConflict'] })
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
- const needIds = context.returning !== 'none' || hasHooks('afterDataCallPostMany')
165
- const ids = await helpers.dataInsertMany({ scopeName, context, rows: attributes, chunkSize, needIds, onConflict })
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 = await manyRowResponse({
170
- api, scopeName, context, ids: context.ids || [], returning: context.returning, format: context.format, queryParams: params.queryParams
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) {