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,9 +2,11 @@
2
2
  // multi-row inserts, by-id CASE updates, by-id deletes and row locks. Every write
3
3
  // runs one statement per chunk; none of them runs through the filter hooks, so
4
4
  // MySQL never sees an UPDATE or DELETE whose subquery reads its own table.
5
- import { RestApiBulkInsertIdsError, RestApiValidationError } from '../../../../lib/rest-api-errors.js'
5
+ import { RestApiBulkInsertIdsError, RestApiBulkWriteUnsupportedError, RestApiResourceError, RestApiValidationError } from '../../../../lib/rest-api-errors.js'
6
6
  import { withQueryFilteringContext } from '../querying/query-builder-utils.js'
7
- import { applyDatabaseReadOptions, databaseIdentityExpression } from '../querying-writing/database-value-normalizers.js'
7
+ import { ANY_OWNER, applyWriteConditions, hasWriteConditions, writeConditions } from '../querying/query-constraint.js'
8
+ import { applyDatabaseReadOptions, databaseIdentityExpression, normalizeStoredFieldValue } from '../querying-writing/database-value-normalizers.js'
9
+ import { currentKnexMysqlSchema, listKnexMysqlIndexes, listKnexUniqueKeyColumns } from '../dbIntrospection.js'
8
10
  import { applyFieldSelectionToQuery, buildFieldSelection } from '../querying-writing/knex-field-helpers.js'
9
11
  import { applyInsertReturning } from '../querying-writing/database-capabilities.js'
10
12
  import { loadRelationshipIdentifiers } from '../querying/relationship-identifiers.js'
@@ -24,6 +26,38 @@ export function databaseFamily (db) {
24
26
  return 'mysql'
25
27
  }
26
28
 
29
+ // The mysql and mysql2 drivers' client flag that makes an UPDATE count the rows it matched.
30
+ const FOUND_ROWS = 0x2
31
+
32
+ /**
33
+ * Refuses a selection write on MySQL or MariaDB whose scope joins a derived
34
+ * table, as a filter on another table's field does: InnoDB reads a derived
35
+ * table from the snapshot without locking it, so the write could not re-check
36
+ * that filter on a row it waited for.
37
+ * @param {{ db: any, scope: any, filters?: Record<string, unknown>, method: string, scopeName: string }} request
38
+ */
39
+ function refuseUnlockedSelection ({ db, scope, filters, method, scopeName }) {
40
+ if (!filters || !Object.keys(filters).length || databaseFamily(db) !== 'mysql') return
41
+ if (!scope._statements.some(statement => statement.grouping === 'join' && typeof statement.table !== 'string')) return
42
+ const message = `${method} on '${scopeName}' filters on another table, which MySQL and MariaDB read from the transaction snapshot without locking it; filter on the row's own columns instead, for example on a link after locking its targets with lockByIds`
43
+ throw new RestApiValidationError(message, { fields: ['filters'], violations: [{ field: 'filters', rule: 'selection_cross_table', message }] })
44
+ }
45
+
46
+ /**
47
+ * Whether an UPDATE's count is the rows it matched. PostgreSQL and SQLite
48
+ * always count those; MySQL and MariaDB do so only on a connection with the
49
+ * FOUND_ROWS client flag, the drivers' default, and count the rows it changed
50
+ * otherwise. A connection this cannot see counts as not.
51
+ * @param {any} db
52
+ */
53
+ export async function countsMatchedRows (db) {
54
+ if (databaseFamily(db) !== 'mysql') return true
55
+ if (!db.isTransaction) return false
56
+ // A transaction's client hands back its own connection.
57
+ const connection = await db.client.acquireConnection()
58
+ return ((connection?.config?.clientFlags ?? 0) & FOUND_ROWS) !== 0
59
+ }
60
+
27
61
  /** @param {{ dbInfo?: { version?: string } } | undefined} capabilities */
28
62
  function supportsMariaDbReturning (capabilities) {
29
63
  const version = String(capabilities?.dbInfo?.version || '').toLowerCase()
@@ -53,15 +87,47 @@ export function chunk (items, size) {
53
87
  return chunks
54
88
  }
55
89
 
56
- /** @param {unknown} value @returns {boolean} */
57
- export const isRawValue = value => Boolean(value && typeof value === 'object' && /** @type {{ isRawInstance?: boolean }} */ (value).isRawInstance === true)
90
+ /**
91
+ * A Knex raw expression. JSON input cannot produce one: the check needs its
92
+ * toSQL function, not only the isRawInstance flag.
93
+ * @param {unknown} value @returns {boolean}
94
+ */
95
+ export function isRawValue (value) {
96
+ const raw = /** @type {{ isRawInstance?: boolean, toSQL?: unknown } | null} */ (value)
97
+ return Boolean(raw && typeof raw === 'object' && raw.isRawInstance === true && typeof raw.toSQL === 'function')
98
+ }
58
99
 
59
- // Conflict keys compare by exact value; drivers return numbers for integer keys.
60
- const keyOf = (row, columns) => JSON.stringify(columns.map(column => {
61
- const value = row[column]
62
- if (value === null || value === undefined) return null
63
- return typeof value === 'bigint' || typeof value === 'number' ? String(value) : value instanceof Date ? value.toISOString() : String(value)
64
- }))
100
+ /**
101
+ * Conflict keys compare exactly, in their public form: drivers return booleans
102
+ * as 1 and 0, numbers as strings and dates in their own shapes, so each value
103
+ * goes through its field's read normalization first. Strings compare as they
104
+ * are, without the column's collation. A field with a storage serializer
105
+ * compares its stored form.
106
+ * @param {Record<string, any>} schemaStructure
107
+ * @param {string[]} keys
108
+ * @param {string[]} keyColumns
109
+ */
110
+ function conflictKeyMatcher (schemaStructure, keys, keyColumns) {
111
+ const definitions = keys.map(field => schemaStructure[field] || {})
112
+ const canonical = (value, index) => {
113
+ if (value === null || value === undefined) return null
114
+ const definition = definitions[index]
115
+ const normal = definition.storage?.serialize ? value : normalizeStoredFieldValue(value, definition, { fieldName: keys[index] })
116
+ if (normal === null || normal === undefined) return null
117
+ if (normal instanceof Date) return normal.toISOString()
118
+ if (['number', 'integer'].includes(definition.type) && typeof normal === 'string') {
119
+ return /^-?\d+$/.test(normal) ? String(BigInt(normal)) : String(Number(normal))
120
+ }
121
+ return String(normal)
122
+ }
123
+ return {
124
+ /** A row's key from its public attributes, or its storage row for serialized fields. */
125
+ input: (attributes, storageRow) => JSON.stringify(keys.map((field, index) =>
126
+ canonical(definitions[index].storage?.serialize ? storageRow[keyColumns[index]] : attributes[field], index))),
127
+ /** A key read from the table. */
128
+ stored: row => JSON.stringify(keyColumns.map((column, index) => canonical(row[column], index)))
129
+ }
130
+ }
65
131
 
66
132
  /**
67
133
  * @param {object} dependencies
@@ -77,9 +143,31 @@ export function createManyRowDataHelpers ({ api, getScopeStorageAdapter, scopes,
77
143
  return adapter
78
144
  }
79
145
 
146
+ // Tables and key columns a unique index was found to cover. A missing index is
147
+ // looked for again on the next call, so adding one needs no restart.
148
+ const coveredKeys = new Set()
149
+ /**
150
+ * Refuses conflict keys that no unique index covers. Without one, the keys
151
+ * could match several rows, and a locking read on them would examine (and on
152
+ * MySQL lock) every row of the table.
153
+ */
154
+ const requireUniqueKeys = async ({ scopeName, db, tableName, keyColumns }) => {
155
+ const cacheKey = JSON.stringify([tableName, [...keyColumns].sort()])
156
+ if (coveredKeys.has(cacheKey)) return
157
+ const uniqueKeys = await listKnexUniqueKeyColumns(db, tableName)
158
+ if (!uniqueKeys.some(columns => columns.every(column => keyColumns.includes(column)))) {
159
+ throw new RestApiBulkWriteUnsupportedError({
160
+ resourceType: scopeName,
161
+ operation: 'postMany',
162
+ reason: `no unique index of '${tableName}' covers the onConflict keys (${keyColumns.join(', ')}); add one, so that the keys identify one row`
163
+ })
164
+ }
165
+ coveredKeys.add(cacheKey)
166
+ }
167
+
80
168
  /**
81
169
  * The resource's base query with ownership, row policy and `filters` applied,
82
- * as `{ query }`: a builder is thenable, so an async function cannot return it bare.
170
+ * as `{ query, alias }`: a builder is thenable, so an async function cannot return it bare.
83
171
  * @param {{ scopeName: string, context: any, runHooks: Function, alias?: string, purpose: string, filters?: Record<string, unknown> }} request
84
172
  */
85
173
  const dataScopedQuery = async ({ scopeName, context, runHooks, alias, purpose, filters }) => {
@@ -99,7 +187,7 @@ export function createManyRowDataHelpers ({ api, getScopeStorageAdapter, scopes,
99
187
  adapter: storageAdapter,
100
188
  storageAdapter
101
189
  }, () => runHooks('knexQueryFiltering'))
102
- return { query: state.query }
190
+ return { query: state.query, alias: tableAlias }
103
191
  }
104
192
 
105
193
  /**
@@ -114,6 +202,8 @@ export function createManyRowDataHelpers ({ api, getScopeStorageAdapter, scopes,
114
202
  const tableName = storageAdapter.getTableName()
115
203
  const idColumn = `${tableName}.${storageAdapter.getIdColumn()}`
116
204
  const { query } = await dataScopedQuery({ scopeName, context, runHooks, purpose: queryPurpose, filters })
205
+ // A selection write reads its rows here first, so it is refused here whatever rows match.
206
+ if (queryPurpose === 'bulk-write') refuseUnlockedSelection({ db, scope: query, filters, method: context.method, scopeName })
117
207
  if (ids) query.whereIn(idColumn, ids.map(id => storageAdapter.translateFilterValue('id', id)))
118
208
  if (after !== undefined) query.where(idColumn, '>', storageAdapter.translateFilterValue('id', after))
119
209
  // Policy joins can repeat a row; the identity projection keeps big integers exact.
@@ -123,11 +213,49 @@ export function createManyRowDataHelpers ({ api, getScopeStorageAdapter, scopes,
123
213
  return rows.map(row => row.id)
124
214
  }
125
215
 
216
+ /**
217
+ * The stored identifier of the row each input row matches by `keys`, or
218
+ * undefined, read with a row lock (SQLite's single writer needs none), one
219
+ * statement per chunk. Upserts decide from it which rows to insert and which
220
+ * to merge.
221
+ * @param {{ scopeName: string, context: any, rows: Record<string, unknown>[], keys: string[], chunkSize: number }} request
222
+ * @returns {Promise<Array<string | number | undefined>>}
223
+ */
224
+ const dataIdsByKeys = async ({ scopeName, context, rows, keys, chunkSize }) => {
225
+ const storageAdapter = adapterFor(scopeName)
226
+ const db = context.transaction
227
+ const tableName = storageAdapter.getTableName()
228
+ const keyColumns = keys.map(field => storageAdapter.translateColumn(field))
229
+ await requireUniqueKeys({ scopeName, db, tableName, keyColumns })
230
+ await takeSqliteWriteLock(db, tableName, storageAdapter.getIdColumn())
231
+ const storageRows = rows.map(attributes => storageAdapter.toStorageRow(attributes, { context, operation: 'post' }))
232
+ const matcher = conflictKeyMatcher(scopes[scopeName].vars.schemaInfo.schemaStructure, keys, keyColumns)
233
+ const inputKeys = rows.map((attributes, index) => matcher.input(attributes, storageRows[index]))
234
+ const wanted = new Set(inputKeys)
235
+ const found = new Map()
236
+ for (const part of chunk(storageRows, rowsPerStatement(db, chunkSize, keyColumns.length))) {
237
+ const query = db(tableName)
238
+ .whereIn(keyColumns, part.map(row => keyColumns.map(column => row[column] ?? null)))
239
+ .select({ id: databaseIdentityExpression(db, storageAdapter.getIdColumn()) }, ...keyColumns)
240
+ for (const row of await applyDatabaseReadOptions(lockRows(query, db))) {
241
+ const key = matcher.stored(row)
242
+ // The database matched by its own comparison (a collation, say); merging or skipping would guess.
243
+ if (!wanted.has(key)) {
244
+ throw new RestApiResourceError(`A stored ${scopeName} record matches the onConflict keys (${keys.join(', ')}) only as the database compares them, for example ignoring letter case; upsert keys must match exactly`, {
245
+ subtype: 'conflict', resourceType: scopeName
246
+ })
247
+ }
248
+ found.set(key, row.id)
249
+ }
250
+ }
251
+ return inputKeys.map(key => found.get(key))
252
+ }
253
+
126
254
  /**
127
255
  * Insert validated rows with one statement per chunk and column set.
128
256
  * Returns the stored identifier for each input row (undefined for rows an
129
257
  * ignore conflict skipped), or nothing when `needIds` is false.
130
- * @param {{ scopeName: string, context: any, rows: Record<string, unknown>[], chunkSize: number, needIds: boolean, onConflict?: { keys: string[], action: 'ignore' | 'merge', merge: string[] } }} request
258
+ * @param {{ scopeName: string, context: any, rows: Record<string, unknown>[], chunkSize: number, needIds: boolean, onConflict?: { keys: string[], action: 'ignore' } }} request
131
259
  * @returns {Promise<Array<string | number | undefined> | undefined>}
132
260
  */
133
261
  const dataInsertMany = async ({ scopeName, context, rows, chunkSize, needIds, onConflict }) => {
@@ -136,12 +264,25 @@ export function createManyRowDataHelpers ({ api, getScopeStorageAdapter, scopes,
136
264
  const tableName = storageAdapter.getTableName()
137
265
  const idColumn = storageAdapter.getIdColumn()
138
266
  const family = databaseFamily(db)
139
- const storageRows = rows.map(attributes => storageAdapter.toStorageRow(attributes, { context, operation: 'post' }))
140
- const keyColumns = onConflict?.keys.map(field => storageAdapter.translateColumn(field))
141
- const lockVersionColumn = context.schemaInfo.lockVersionField ? storageAdapter.translateColumn(context.schemaInfo.lockVersionField) : null
142
267
  /** @type {Array<string | number | undefined>} */
143
268
  const ids = new Array(rows.length)
144
269
 
270
+ if (onConflict && family === 'mysql') {
271
+ // ON DUPLICATE KEY fires on every unique index, the primary key included:
272
+ // lock the rows that match by the keys, then insert only the others with a
273
+ // plain INSERT, so a clash on any other unique index fails the batch.
274
+ const existing = await dataIdsByKeys({ scopeName, context, rows, keys: onConflict.keys, chunkSize })
275
+ const missing = rows.flatMap((_row, index) => existing[index] === undefined ? [index] : [])
276
+ const inserted = missing.length
277
+ ? await dataInsertMany({ scopeName, context, rows: missing.map(index => rows[index]), chunkSize, needIds })
278
+ : []
279
+ missing.forEach((index, position) => { ids[index] = inserted?.[position] })
280
+ return needIds ? ids : undefined
281
+ }
282
+
283
+ const storageRows = rows.map(attributes => storageAdapter.toStorageRow(attributes, { context, operation: 'post' }))
284
+ const keyColumns = onConflict?.keys.map(field => storageAdapter.translateColumn(field))
285
+
145
286
  // Rows with the same columns share a statement; SQLite cannot insert DEFAULT for a missing value.
146
287
  const groups = new Map()
147
288
  storageRows.forEach((row, index) => {
@@ -155,22 +296,8 @@ export function createManyRowDataHelpers ({ api, getScopeStorageAdapter, scopes,
155
296
  for (const part of chunk(indexes, rowsPerStatement(db, chunkSize, columns.length + 1, { insert: true }))) {
156
297
  const values = part.map(index => storageRows[index])
157
298
  let query = db(tableName).insert(values)
158
- if (onConflict) query = applyConflict(query, { db, family, tableName, idColumn, keyColumns, onConflict, storageAdapter, lockVersionColumn })
159
-
160
- if (onConflict && family === 'mysql') {
161
- // Neither MySQL nor MariaDB reports which rows an upsert inserted: read the keys around it.
162
- const existing = onConflict.action === 'ignore' && needIds
163
- ? new Set((await readByKeys(db, tableName, idColumn, keyColumns, values)).map(row => keyOf(row, keyColumns)))
164
- : null
165
- await query
166
- const found = new Map((await readByKeys(db, tableName, idColumn, keyColumns, values)).map(row => [keyOf(row, keyColumns), row.id]))
167
- part.forEach((index, position) => {
168
- const key = keyOf(values[position], keyColumns)
169
- if (!found.has(key)) throw conflictElsewhere(scopeName, onConflict)
170
- if (!existing?.has(key)) ids[index] = found.get(key)
171
- })
172
- continue
173
- }
299
+ // Only a clash on the conflict keys is skipped; any other unique index still fails.
300
+ if (onConflict) query = query.onConflict(keyColumns).ignore()
174
301
 
175
302
  if (!needIds) {
176
303
  await query
@@ -178,8 +305,7 @@ export function createManyRowDataHelpers ({ api, getScopeStorageAdapter, scopes,
178
305
  }
179
306
 
180
307
  if (family === 'mysql' && supportsMariaDbReturning(api.knex.capabilities)) {
181
- const { sql, bindings } = query.toSQL()
182
- const [returned] = await db.raw(`${sql} returning ${db.raw('??', [idColumn]).toQuery()}`, bindings)
308
+ const [returned] = await db.raw('? returning ??', [query, idColumn])
183
309
  returned.forEach((row, position) => { ids[part[position]] = row[idColumn] })
184
310
  continue
185
311
  }
@@ -203,8 +329,9 @@ export function createManyRowDataHelpers ({ api, getScopeStorageAdapter, scopes,
203
329
  let returned = await applyDatabaseReadOptions(applyInsertReturning(query, returnColumns))
204
330
  if (onConflict) {
205
331
  // Ignored rows return nothing, so returned rows are matched to inputs by their keys.
206
- const byKey = new Map(returned.map(row => [keyOf(Object.fromEntries(keyColumns.map(column => [column, row[`__jra_key_${column}`]])), keyColumns), row[idColumn]]))
207
- part.forEach((index, position) => { ids[index] = byKey.get(keyOf(values[position], keyColumns)) })
332
+ const matcher = conflictKeyMatcher(scopes[scopeName].vars.schemaInfo.schemaStructure, onConflict.keys, keyColumns)
333
+ const byKey = new Map(returned.map(row => [matcher.stored(Object.fromEntries(keyColumns.map(column => [column, row[`__jra_key_${column}`]]))), row[idColumn]]))
334
+ part.forEach((index, position) => { ids[index] = byKey.get(matcher.input(rows[index], values[position])) })
208
335
  continue
209
336
  }
210
337
  if (family === 'sqlite' && values.some(row => row[idColumn] === undefined || row[idColumn] === null)) {
@@ -217,14 +344,61 @@ export function createManyRowDataHelpers ({ api, getScopeStorageAdapter, scopes,
217
344
  return needIds ? ids : undefined
218
345
  }
219
346
 
347
+ // The rows a by-id UPDATE or DELETE writes: the ids, onlyIf, the caller's
348
+ // ownership conditions and, with `scoped`, the caller's scope (with a
349
+ // selection's filters), built once (its filtering hooks record the ownership
350
+ // conditions).
351
+ const writeMatcher = async ({ scopeName, context, runHooks, scoped, onlyIf, filters }) => {
352
+ const storageAdapter = adapterFor(scopeName)
353
+ const db = context.transaction
354
+ const family = databaseFamily(db)
355
+ const tableName = storageAdapter.getTableName()
356
+ const idColumn = storageAdapter.getIdColumn()
357
+ const identity = { id: databaseIdentityExpression(db, `${tableName}.${idColumn}`) }
358
+ const scope = scoped ? (await dataScopedQuery({ scopeName, context, runHooks, purpose: 'bulk-write', filters })).query : null
359
+ const guard = query => applyWriteConditions({ query: applyOnlyIf(query, onlyIf || {}, tableName), context, scopeName, storageAdapter, tableName })
360
+ // The WHERE of the write, with the scope as a subquery.
361
+ const matching = ids => {
362
+ const query = guard(db(tableName).whereIn(idColumn, ids))
363
+ return scope ? query.whereIn(idColumn, scope.clone().whereIn(`${tableName}.${idColumn}`, ids).clearSelect().select(`${tableName}.${idColumn}`)) : query
364
+ }
365
+ // A selection's filters must hold for a row as it is once locked, after any
366
+ // wait for another writer, or two writers that both matched the old row
367
+ // would both write it. A subquery does not see that row: InnoDB reads it
368
+ // from the transaction snapshot, PostgreSQL as of the statement's start.
369
+ const selection = Boolean(scope && filters && Object.keys(filters).length)
370
+ if (selection) refuseUnlockedSelection({ db, scope, filters, method: context.method, scopeName })
371
+ return {
372
+ matching,
373
+ // PostgreSQL: lock the rows first, so the statement that writes them
374
+ // starts after any wait and reads what their holder committed.
375
+ settle: async ids => {
376
+ if (selection && family === 'pg') await lockRows(db(tableName).whereIn(idColumn, ids).orderBy(idColumn), db).select(idColumn)
377
+ },
378
+ // The ids that still match, locked. MySQL and MariaDB check a selection's
379
+ // scope and filters in the locking read's own query block, on the
380
+ // current row.
381
+ locked: async ids => {
382
+ const query = selection && family === 'mysql'
383
+ ? guard(scope.clone().whereIn(`${tableName}.${idColumn}`, ids)).clearSelect()
384
+ : matching(ids)
385
+ const rows = await applyDatabaseReadOptions(lockRows(query, db).select(identity))
386
+ // A join of the scope or the filters can repeat a row.
387
+ return [...new Map(rows.map(row => [String(row.id), row.id])).values()]
388
+ }
389
+ }
390
+ }
391
+
220
392
  /**
221
393
  * Update visible rows by id: one UPDATE per chunk, `CASE id WHEN … THEN …`
222
394
  * for values that differ between rows and a plain SET for shared ones.
395
+ * With `scoped`, each statement carries the caller's scope (row policy
396
+ * included) and `filters` as a subquery.
223
397
  * Returns the identifiers the statements changed.
224
- * @param {{ scopeName: string, context: any, updates: Array<{ id: string | number, row: Record<string, unknown> }>, onlyIf?: Record<string, unknown>, chunkSize: number }} request
398
+ * @param {{ scopeName: string, context: any, runHooks?: Function, updates: Array<{ id: string | number, row: Record<string, unknown> }>, onlyIf?: Record<string, unknown>, chunkSize: number, scoped?: boolean, filters?: Record<string, unknown> }} request
225
399
  * @returns {Promise<Array<string | number>>}
226
400
  */
227
- const dataUpdateByIds = async ({ scopeName, context, updates, onlyIf, chunkSize }) => {
401
+ const dataUpdateByIds = async ({ scopeName, context, runHooks, updates, onlyIf, chunkSize, scoped = false, filters }) => {
228
402
  const storageAdapter = adapterFor(scopeName)
229
403
  const db = context.transaction
230
404
  const tableName = storageAdapter.getTableName()
@@ -232,9 +406,13 @@ export function createManyRowDataHelpers ({ api, getScopeStorageAdapter, scopes,
232
406
  const family = databaseFamily(db)
233
407
  const lockVersionColumn = context.schemaInfo.lockVersionField ? storageAdapter.translateColumn(context.schemaInfo.lockVersionField) : null
234
408
  const columnCount = new Set(updates.flatMap(update => Object.keys(update.row))).size
409
+ const { matching, settle, locked } = await writeMatcher({ scopeName, context, runHooks, scoped, onlyIf, filters })
410
+ // onlyIf, the scope and the caller's ownership narrow each statement; without them every listed id is written.
411
+ const guarded = scoped || Boolean(onlyIf) || hasWriteConditions(context, scopeName)
235
412
  const changed = []
236
413
 
237
- for (const part of chunk(updates, rowsPerStatement(db, chunkSize, 2 * columnCount + 1))) {
414
+ // A scoped statement binds each id twice, once more in its subquery.
415
+ for (const part of chunk(updates, rowsPerStatement(db, chunkSize, 2 * columnCount + (scoped ? 2 : 1)))) {
238
416
  const set = {}
239
417
  const columns = [...new Set(part.flatMap(update => Object.keys(update.row)))]
240
418
  for (const column of columns) {
@@ -254,23 +432,27 @@ export function createManyRowDataHelpers ({ api, getScopeStorageAdapter, scopes,
254
432
  set[column] = db.raw(`${sql} else ?? end`, [...bindings, column])
255
433
  }
256
434
  if (lockVersionColumn) set[lockVersionColumn] = db.raw('?? + 1', [lockVersionColumn])
257
- if (!Object.keys(set).length) {
258
- changed.push(...part.map(update => update.id))
259
- continue
260
- }
435
+ const writes = Object.keys(set).length > 0
261
436
  const ids = part.map(update => storageAdapter.translateFilterValue('id', update.id))
262
- const query = db(tableName).whereIn(idColumn, ids)
263
- if (!onlyIf) {
264
- await query.update(set)
437
+ if (!guarded) {
438
+ if (writes) await db(tableName).whereIn(idColumn, ids).update(set)
265
439
  changed.push(...part.map(update => update.id))
266
- } else if (family === 'mysql') {
267
- // MySQL cannot return updated rows: lock the rows that match, then update exactly those.
268
- const matched = await applyDatabaseReadOptions(applyOnlyIf(db(tableName).whereIn(idColumn, ids), onlyIf)
269
- .forUpdate().select({ id: databaseIdentityExpression(db, idColumn) }))
270
- if (matched.length) await db(tableName).whereIn(idColumn, matched.map(row => row.id)).update(set)
271
- changed.push(...matched.map(row => row.id))
440
+ } else if (family === 'mysql' || !writes) {
441
+ // MySQL cannot return updated rows. One row's UPDATE answers from its
442
+ // count when that counts matched rows; with a scope subquery it would
443
+ // name the updated table, which MySQL refuses.
444
+ if (writes && !scoped && part.length === 1 && await countsMatchedRows(db)) {
445
+ if (await matching(ids).update(set)) changed.push(part[0].id)
446
+ continue
447
+ }
448
+ // Otherwise lock the rows that match, then update exactly those.
449
+ await settle(ids)
450
+ const matched = await locked(ids)
451
+ if (matched.length && writes) await db(tableName).whereIn(idColumn, matched).update(set)
452
+ changed.push(...matched)
272
453
  } else {
273
- const rows = await applyDatabaseReadOptions(applyOnlyIf(query, onlyIf).returning({ id: databaseIdentityExpression(db, idColumn) }).update(set))
454
+ await settle(ids)
455
+ const rows = await applyDatabaseReadOptions(matching(ids).returning({ id: databaseIdentityExpression(db, idColumn) }).update(set))
274
456
  changed.push(...rows.map(row => row.id))
275
457
  }
276
458
  }
@@ -279,15 +461,45 @@ export function createManyRowDataHelpers ({ api, getScopeStorageAdapter, scopes,
279
461
 
280
462
  /**
281
463
  * Delete visible rows by id, one DELETE per chunk, then their pivot rows.
282
- * @param {{ scopeName: string, context: any, ids: Array<string | number>, chunkSize: number }} request
464
+ * With `scoped`, each DELETE carries the caller's scope and `filters` (see
465
+ * dataUpdateByIds).
466
+ * Returns the identifiers the statements deleted.
467
+ * @param {{ scopeName: string, context: any, runHooks?: Function, ids: Array<string | number>, chunkSize: number, scoped?: boolean, filters?: Record<string, unknown> }} request
468
+ * @returns {Promise<Array<string | number>>}
283
469
  */
284
- const dataDeleteByIds = async ({ scopeName, context, ids, chunkSize }) => {
470
+ const dataDeleteByIds = async ({ scopeName, context, runHooks, ids, chunkSize, scoped = false, filters }) => {
285
471
  const storageAdapter = adapterFor(scopeName)
286
472
  const db = context.transaction
287
- for (const part of chunk(ids, rowsPerStatement(db, chunkSize, 1))) {
288
- await db(storageAdapter.getTableName()).whereIn(storageAdapter.getIdColumn(), part.map(id => storageAdapter.translateFilterValue('id', id))).delete()
289
- await deletePivotReferences(api, scopeName, context, part)
473
+ const family = databaseFamily(db)
474
+ const tableName = storageAdapter.getTableName()
475
+ const idColumn = storageAdapter.getIdColumn()
476
+ const identity = { id: databaseIdentityExpression(db, idColumn) }
477
+ const { matching, settle, locked } = await writeMatcher({ scopeName, context, runHooks, scoped, filters })
478
+ const guarded = scoped || hasWriteConditions(context, scopeName)
479
+ const deleted = []
480
+ for (const part of chunk(ids, rowsPerStatement(db, chunkSize, scoped ? 2 : 1))) {
481
+ let values = part.map(id => storageAdapter.translateFilterValue('id', id))
482
+ if (guarded && family === 'pg') {
483
+ // The scope and the caller's ownership narrow the DELETE, which returns the rows it removed.
484
+ await settle(values)
485
+ values = (await applyDatabaseReadOptions(matching(values).returning(identity).delete())).map(row => row.id)
486
+ } else if (guarded && family === 'sqlite') {
487
+ // Knex reads no rows back from a SQLite DELETE, so its RETURNING runs as
488
+ // raw SQL around the builder, which keeps its own bindings: a '?' inside
489
+ // a string literal of a row policy stays text.
490
+ values = (await db.raw('? returning ? as ??', [matching(values).delete(), identity.id, 'id'])).map(row => row.id)
491
+ } else if (guarded) {
492
+ // MySQL cannot return deleted rows: lock the rows that still match, then delete exactly those.
493
+ values = await locked(values)
494
+ if (values.length) await db(tableName).whereIn(idColumn, values).delete()
495
+ } else {
496
+ await db(tableName).whereIn(idColumn, values).delete()
497
+ }
498
+ if (!values.length) continue
499
+ await deletePivotReferences(api, scopeName, context, values)
500
+ deleted.push(...values)
290
501
  }
502
+ return deleted
291
503
  }
292
504
 
293
505
  // Reads rows through the get field selection, so they leave as response records.
@@ -305,24 +517,133 @@ export function createManyRowDataHelpers ({ api, getScopeStorageAdapter, scopes,
305
517
  return buildJsonApiResponse(scope, rows, [], false, scopeName, context)
306
518
  }
307
519
 
520
+ // SQLite has one writer, but a deferred transaction's reads take only a shared
521
+ // lock: two connections could both read as if they held the rows, and the
522
+ // later write would fail with SQLITE_BUSY halfway through. A write statement
523
+ // takes the write lock even when it changes nothing, so a SQLite lock read
524
+ // starts with one, once per transaction; the lock read itself is then plain.
525
+ const writeLocked = new WeakSet()
526
+ const takeSqliteWriteLock = async (db, tableName, idColumn) => {
527
+ if (databaseFamily(db) !== 'sqlite' || writeLocked.has(db)) return
528
+ await db(tableName).whereRaw('0 = 1').update({ [idColumn]: db.ref(idColumn) })
529
+ writeLocked.add(db)
530
+ }
531
+
308
532
  const lockRows = (query, db, { skipLocked = false } = {}) => {
309
- // SQLite has a single writer, so its transaction already excludes other writers.
310
533
  if (databaseFamily(db) === 'sqlite') return query
311
534
  query.forUpdate()
312
535
  return skipLocked ? query.skipLocked() : query
313
536
  }
314
537
 
538
+ // The scoped query of a lock or claim, refused unless it is still a SELECT on
539
+ // the resource's own table: FOR UPDATE on a derived table or a common table
540
+ // expression locks nothing and reads the transaction snapshot, and InnoDB
541
+ // would lock every joined row the read examines.
542
+ const lockingQuery = async ({ scopeName, context, runHooks, operation, purpose, filters }) => {
543
+ const storageAdapter = adapterFor(scopeName)
544
+ const tableName = storageAdapter.getTableName()
545
+ const { query } = await dataScopedQuery({ scopeName, context, runHooks, purpose, filters })
546
+ const added = query._statements.find(statement => ['join', 'with', 'union'].includes(statement.grouping))?.grouping
547
+ if (added || query._single.table !== tableName) {
548
+ const what = { join: 'joins', with: 'a common table expression', union: 'a set operation' }[added] ?? 'another source table'
549
+ const message = `${operation} on '${scopeName}' needs a single-table query, but its filters or row policy add ${what}`
550
+ throw new RestApiValidationError(message, { fields: ['filters'], violations: [{ field: 'filters', rule: `${purpose}_single_table`, message }] })
551
+ }
552
+ await takeSqliteWriteLock(context.db, tableName, storageAdapter.getIdColumn())
553
+ return { query, storageAdapter, tableName, idColumn: `${tableName}.${storageAdapter.getIdColumn()}` }
554
+ }
555
+
315
556
  /**
316
- * Lock rows already known to be visible, in id order, on the bare table.
557
+ * Lock the visible rows among `ids` in id order and read them, in one locking
558
+ * read that carries the scope and row-policy predicates. No plain read comes
559
+ * first: under REPEATABLE READ (MySQL, MariaDB) it would fix the snapshot
560
+ * before the lock wait, hiding what the lock holder commits meanwhile.
317
561
  * Returns the rows the locking read saw as a JSON:API document.
318
- * @param {{ scopeName: string, context: any, ids: Array<string | number> }} request
562
+ * @param {{ scopeName: string, context: any, runHooks: Function, ids: Array<string | number> }} request
319
563
  */
320
- const dataLockByIds = async ({ scopeName, context, ids }) => {
321
- const storageAdapter = adapterFor(scopeName)
322
- const tableName = storageAdapter.getTableName()
323
- const idColumn = `${tableName}.${storageAdapter.getIdColumn()}`
324
- const query = lockRows(context.db(tableName).whereIn(idColumn, ids.map(id => storageAdapter.translateFilterValue('id', id))).orderBy(idColumn), context.db)
325
- return readRecords({ scopeName, context, query, tableName })
564
+ const dataLockByIds = async ({ scopeName, context, runHooks, ids }) => {
565
+ const { query, storageAdapter, tableName, idColumn } = await lockingQuery({ scopeName, context, runHooks, operation: 'lockByIds', purpose: 'lock' })
566
+ query.whereIn(idColumn, ids.map(id => storageAdapter.translateFilterValue('id', id))).orderBy(idColumn)
567
+ return readRecords({ scopeName, context, query: lockRows(query, context.db), tableName })
568
+ }
569
+
570
+ // The index lockWhere forces on MySQL, by schema, table and the read's columns (see lockIndexFor).
571
+ const lockIndexes = new Map()
572
+ /**
573
+ * InnoDB locks every row a locking read examines, and holds those locks until
574
+ * commit. lockWhere on MySQL therefore forces an index that keeps its read
575
+ * inside the caller's rows: one whose leading columns are the whole
576
+ * ownership columns the read is limited to, or, without ownership
577
+ * conditions, one that starts with a column the filters compare. Otherwise
578
+ * the read could walk the primary key, locking other owners' rows and the
579
+ * gaps between them. Invisible and ignored indexes cannot be forced.
580
+ * Returns the index name.
581
+ * @param {{ scopeName: string, context: any, storageAdapter: any, tableName: string, filters?: Record<string, unknown> }} request
582
+ * @returns {Promise<string>}
583
+ */
584
+ const lockIndexFor = async ({ scopeName, context, storageAdapter, tableName, filters = {} }) => {
585
+ const owned = Object.entries(context[writeConditions]?.[scopeName] || {})
586
+ .filter(([, value]) => value !== null && value !== ANY_OWNER)
587
+ .map(([field]) => storageAdapter.translateColumn(field))
588
+ const search = context.schemaInfo.searchSchemaStructure || {}
589
+ const compared = Object.keys(filters).flatMap(key => {
590
+ const definition = search[key]
591
+ if (!definition || definition.oneOf || definition.applyFilter || definition.polymorphicField || definition.actualField?.includes('.')) return []
592
+ return [storageAdapter.translateColumn(definition.actualField || key)]
593
+ })
594
+ // One pool can serve several databases, so the cache is per schema.
595
+ const schema = await currentKnexMysqlSchema(context.db)
596
+ const cacheKey = JSON.stringify([schema, tableName, [...owned].sort(), [...compared].sort()])
597
+ if (lockIndexes.has(cacheKey)) return lockIndexes.get(cacheKey)
598
+ const indexes = await listKnexMysqlIndexes(context.db, schema, tableName)
599
+ const leading = owned.length
600
+ ? indexes.filter(({ columns }) => columns.length >= owned.length &&
601
+ columns.slice(0, owned.length).every(column => owned.includes(column.name) && column.prefix === null))
602
+ : indexes.filter(({ columns }) => compared.includes(columns[0]?.name))
603
+ // Among those, one whose next column the filters compare locks fewer rows.
604
+ const index = leading.find(({ columns }) => compared.includes(columns[owned.length]?.name)) ?? leading[0]
605
+ if (!index) {
606
+ throw new RestApiBulkWriteUnsupportedError({
607
+ resourceType: scopeName,
608
+ operation: 'lockWhere',
609
+ reason: `on MySQL and MariaDB a locking read locks every row it examines; add a visible index of '${tableName}' that starts with ${owned.length ? `the whole ownership column(s) ${owned.join(', ')}` : 'a column the filters compare'}`
610
+ })
611
+ }
612
+ lockIndexes.set(cacheKey, index.name)
613
+ return index.name
614
+ }
615
+
616
+ /**
617
+ * Lock up to `limit` visible rows the filters select, waiting for
618
+ * transactions that hold them, in one locking read like dataLockByIds: in id
619
+ * order, except on MySQL, where the read is forced onto the index that
620
+ * lockIndexFor picks and the rows come in its order, as ORDER BY id could
621
+ * walk the primary key.
622
+ * @param {{ scopeName: string, context: any, runHooks: Function, filters?: Record<string, unknown>, limit: number }} request
623
+ */
624
+ const dataLockWhere = async ({ scopeName, context, runHooks, filters, limit }) => {
625
+ const read = async () => {
626
+ const { query, storageAdapter, tableName, idColumn } = await lockingQuery({ scopeName, context, runHooks, operation: 'lockWhere', purpose: 'lock', filters })
627
+ if (databaseFamily(context.db) === 'mysql') {
628
+ // Without FORCE INDEX the optimizer could still pick another index or a full scan.
629
+ const index = await lockIndexFor({ scopeName, context, storageAdapter, tableName, filters })
630
+ query.from(context.db.raw('?? force index (??)', [tableName, index]))
631
+ } else query.orderBy(idColumn)
632
+ return readRecords({ scopeName, context, query: lockRows(query.limit(limit), context.db), tableName })
633
+ }
634
+ let document = await read()
635
+ // A PostgreSQL statement finds only the rows committed when it began; after a
636
+ // lock wait it rechecks those, but never sees rows that started to match
637
+ // meanwhile. Read again, under the locks already held, until none appears.
638
+ // (InnoDB's locking reads see the latest rows, and its next-key locks keep
639
+ // new rows out of the range the read has passed.)
640
+ if (databaseFamily(context.db) === 'pg') {
641
+ for (let previous = -1; document.data.length > previous && document.data.length < limit;) {
642
+ previous = document.data.length
643
+ document = await read()
644
+ }
645
+ }
646
+ return document
326
647
  }
327
648
 
328
649
  /**
@@ -330,23 +651,10 @@ export function createManyRowDataHelpers ({ api, getScopeStorageAdapter, scopes,
330
651
  * @param {{ scopeName: string, context: any, runHooks: Function, filters?: Record<string, unknown>, sort: Array<{ field: string, direction: 'asc' | 'desc' }>, limit: number }} request
331
652
  */
332
653
  const dataClaim = async ({ scopeName, context, runHooks, filters, sort, limit }) => {
333
- const storageAdapter = adapterFor(scopeName)
334
- const tableName = storageAdapter.getTableName()
335
- const idColumn = `${tableName}.${storageAdapter.getIdColumn()}`
336
654
  const db = context.db
337
- // Resolves to { query }: awaiting a bare builder would run it.
338
- const scoped = async () => {
339
- const { query } = await dataScopedQuery({ scopeName, context, runHooks, purpose: 'claim', filters })
340
- if (query._statements.some(statement => statement.grouping === 'join')) {
341
- throw new RestApiValidationError(`claim on '${scopeName}' needs a single-table query, but its filters or row policy add joins`, {
342
- fields: ['filters'],
343
- violations: [{ field: 'filters', rule: 'claim_single_table', message: 'A row policy or filter that joins tables cannot be combined with SKIP LOCKED' }]
344
- })
345
- }
346
- for (const { field, direction } of sort) query.orderBy(`${tableName}.${storageAdapter.translateColumn(field)}`, direction)
347
- return { query }
348
- }
349
- const { query } = await scoped()
655
+ const claimQuery = () => lockingQuery({ scopeName, context, runHooks, operation: 'claim', purpose: 'claim', filters })
656
+ const { query, storageAdapter, tableName, idColumn } = await claimQuery()
657
+ for (const { field, direction } of sort) query.orderBy(`${tableName}.${storageAdapter.translateColumn(field)}`, direction)
350
658
  if (databaseFamily(db) !== 'mysql') {
351
659
  // PostgreSQL locks rows as the LIMIT takes them; SQLite has one writer.
352
660
  return readRecords({ scopeName, context, query: lockRows(query.limit(limit), db, { skipLocked: true }), tableName })
@@ -354,26 +662,33 @@ export function createManyRowDataHelpers ({ api, getScopeStorageAdapter, scopes,
354
662
 
355
663
  // InnoDB locks every row a locking read examines, including rows a sort
356
664
  // reads and the LIMIT then drops, so one claim would hold every match.
357
- // Instead: candidates in sort order by a plain read, then locking reads of
358
- // windows of candidates by primary key, each no larger than what is still needed.
665
+ // Instead: candidates in sort order by a plain read; then, for windows of
666
+ // candidates no larger than what is still needed, a SKIP LOCKED lock by
667
+ // primary key on the bare table, and a locking read of the held rows that
668
+ // drops any a committed change made stop matching. SKIP LOCKED never waits,
669
+ // so the snapshot the plain read starts misses nothing a lock wait let commit.
359
670
  const claimed = { data: [] }
671
+ const identity = { id: databaseIdentityExpression(db, idColumn) }
360
672
  const pageSize = Math.max(limit * 2, 20)
361
673
  for (let offset = 0; claimed.data.length < limit; offset += pageSize) {
362
- const candidates = (await applyDatabaseReadOptions(query.clone().offset(offset).limit(pageSize)
363
- .select({ id: databaseIdentityExpression(db, idColumn) }))).map(row => row.id)
674
+ const candidates = (await applyDatabaseReadOptions(query.clone().offset(offset).limit(pageSize).select(identity))).map(row => row.id)
364
675
  for (let next = 0; next < candidates.length && claimed.data.length < limit;) {
365
676
  const window = candidates.slice(next, next + limit - claimed.data.length)
366
677
  next += window.length
367
- const locked = (await scoped()).query.whereIn(idColumn, window).forUpdate().skipLocked()
368
- const document = await readRecords({ scopeName, context, query: locked, tableName })
369
- claimed.data.push(...document.data)
678
+ const byKey = applyWriteConditions({ query: db(tableName).whereIn(idColumn, window), context, scopeName, storageAdapter, tableName })
679
+ const held = (await applyDatabaseReadOptions(byKey.forUpdate().skipLocked().select(identity))).map(row => row.id)
680
+ if (!held.length) continue
681
+ const { query: current } = await claimQuery()
682
+ const document = await readRecords({ scopeName, context, query: current.whereIn(idColumn, held).forUpdate().skipLocked(), tableName })
683
+ const order = new Map(window.map((id, position) => [String(id), position]))
684
+ claimed.data.push(...document.data.sort((left, right) => order.get(String(left.id)) - order.get(String(right.id))))
370
685
  }
371
686
  if (candidates.length < pageSize) break
372
687
  }
373
688
  return claimed
374
689
  }
375
690
 
376
- return { dataScopedQuery, dataVisibleIds, dataInsertMany, dataUpdateByIds, dataDeleteByIds, dataLockByIds, dataClaim }
691
+ return { dataScopedQuery, dataVisibleIds, dataIdsByKeys, dataInsertMany, dataUpdateByIds, dataDeleteByIds, dataLockByIds, dataLockWhere, dataClaim }
377
692
  }
378
693
 
379
694
  const sameValue = (left, right) => {
@@ -383,44 +698,14 @@ const sameValue = (left, right) => {
383
698
  return false
384
699
  }
385
700
 
386
- function applyOnlyIf (query, onlyIf) {
701
+ function applyOnlyIf (query, onlyIf, tableName) {
387
702
  for (const [column, value] of Object.entries(onlyIf)) {
388
- if (value === null) query.whereNull(column)
389
- else query.where(column, value)
703
+ if (value === null) query.whereNull(`${tableName}.${column}`)
704
+ else query.where(`${tableName}.${column}`, value)
390
705
  }
391
706
  return query
392
707
  }
393
708
 
394
- function conflictElsewhere (scopeName, onConflict) {
395
- return new RestApiValidationError(`A '${scopeName}' row conflicted on a unique index other than (${onConflict.keys.join(', ')})`, {
396
- fields: onConflict.keys,
397
- violations: [{ field: 'onConflict.keys', rule: 'conflict_keys', message: 'Every input row must insert or match a row by the conflict keys' }]
398
- })
399
- }
400
-
401
- async function readByKeys (db, tableName, idColumn, keyColumns, values) {
402
- const rows = await applyDatabaseReadOptions(db(tableName)
403
- .whereIn(keyColumns, values.map(row => keyColumns.map(column => row[column] ?? null)))
404
- .select({ id: databaseIdentityExpression(db, idColumn) }, ...keyColumns))
405
- return rows
406
- }
407
-
408
- function applyConflict (query, { db, family, tableName, idColumn, keyColumns, onConflict, storageAdapter, lockVersionColumn }) {
409
- if (onConflict.action === 'ignore') {
410
- // INSERT IGNORE would also turn other errors into warnings; keep them errors.
411
- return family === 'mysql'
412
- ? query.onConflict(keyColumns).merge({ [idColumn]: db.raw('??', [idColumn]) })
413
- : query.onConflict(keyColumns).ignore()
414
- }
415
- const updates = {}
416
- for (const field of onConflict.merge) {
417
- const column = storageAdapter.translateColumn(field)
418
- updates[column] = family === 'mysql' ? db.raw('values(??)', [column]) : db.raw('excluded.??', [column])
419
- }
420
- if (lockVersionColumn) updates[lockVersionColumn] = db.raw('??.?? + 1', [tableName, lockVersionColumn])
421
- return query.onConflict(keyColumns).merge(updates)
422
- }
423
-
424
709
  // MySQL reports only the first generated id. One multi-row INSERT is expected to
425
710
  // take consecutive values; prove it by reading the range back inside the
426
711
  // transaction and comparing the rows with what was inserted.