json-rest-api 3.0.0-rc.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/docs/API.md +60 -27
- package/docs/GUIDE/07-polymorphic-relationships.md +4 -2
- package/docs/GUIDE/09-pagination-and-sorting.md +4 -2
- package/docs/GUIDE/13-hooks-and-lifecycle.md +6 -1
- package/docs/GUIDE/16-autofiltering.md +1 -1
- package/docs/GUIDE/17-row-policies.md +8 -6
- package/docs/GUIDE/21-schema-and-migrations.md +7 -1
- package/docs/GUIDE/27-many-row-writes.md +201 -28
- package/docs/GUIDE/30-backend-capabilities.md +16 -2
- package/docs/GUIDE/33-migrating-to-v2.md +2 -2
- package/docs/GUIDE/34-scoped-reads-and-locks.md +165 -26
- package/docs/GUIDE/35-migrating-to-v3.md +141 -10
- package/lib/rest-api-errors.js +6 -5
- package/package.json +2 -2
- package/plugins/core/connectors/lib/transport-http-helpers.js +31 -3
- package/plugins/core/file-handling-plugin.js +2 -1
- package/plugins/core/lib/anyapi/anyapi-registry.js +3 -2
- package/plugins/core/lib/anyapi/canonical-link-store.js +5 -2
- package/plugins/core/lib/anyapi/canonical-relationship-reader.js +56 -60
- package/plugins/core/lib/dbIntrospection.js +106 -6
- package/plugins/core/lib/querying/aggregate-query.js +12 -6
- package/plugins/core/lib/querying/include-query-helpers.js +11 -13
- package/plugins/core/lib/querying/include-to-one.js +54 -59
- package/plugins/core/lib/querying/knex-pagination-helpers.js +9 -2
- package/plugins/core/lib/querying/query-constraint.d.ts +11 -0
- package/plugins/core/lib/querying/query-constraint.js +45 -0
- package/plugins/core/lib/querying/read-only-query.js +50 -13
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
- package/plugins/core/lib/storage/canonical-data-helpers.js +72 -41
- package/plugins/core/lib/storage/many-row-data-helpers.js +409 -124
- package/plugins/core/lib/storage/ordinary-data-helpers.js +67 -81
- package/plugins/core/lib/storage/single-row-writes.js +71 -0
- package/plugins/core/lib/storage/storage-adapter.js +18 -8
- package/plugins/core/lib/storage/storage-mapping.js +4 -1
- package/plugins/core/lib/storage/storage-types.d.ts +12 -1
- package/plugins/core/lib/writing/many-to-many-manipulations.js +5 -2
- package/plugins/core/lib/writing/relationship-processor.js +33 -37
- package/plugins/core/rest-api-autofilter-plugin.js +30 -8
- package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
- package/plugins/core/rest-api-plugin-methods/common.js +117 -77
- package/plugins/core/rest-api-plugin-methods/delete-many.js +54 -28
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +22 -0
- package/plugins/core/rest-api-plugin-methods/get.js +48 -42
- package/plugins/core/rest-api-plugin-methods/many-row-common.js +51 -8
- package/plugins/core/rest-api-plugin-methods/patch-many.js +81 -34
- package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
- package/plugins/core/rest-api-plugin-methods/post-many.js +149 -21
- package/plugins/core/rest-api-plugin-methods/put.js +5 -1
- package/plugins/core/rest-api-plugin-methods/scoped-reads.js +72 -27
- package/plugins/core/rest-api-plugin.js +2 -1
- package/plugins/core/rest-api-positioning-plugin.js +1 -1
- package/types/data-access-methods.d.ts +32 -11
- package/types/errors.d.ts +5 -4
- package/types/hook-context.d.ts +2 -0
|
@@ -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 {
|
|
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
|
-
/**
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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'
|
|
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
|
-
|
|
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
|
|
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
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
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 =
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
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
|
|
358
|
-
//
|
|
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
|
|
368
|
-
const
|
|
369
|
-
|
|
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
|
|
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.
|