json-rest-api 2.4.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/docs/API.md +262 -54
- package/docs/GUIDE/01-getting-started.md +1 -1
- package/docs/GUIDE/07-polymorphic-relationships.md +4 -2
- package/docs/GUIDE/09-pagination-and-sorting.md +4 -2
- package/docs/GUIDE/13-hooks-and-lifecycle.md +34 -6
- package/docs/GUIDE/14-custom-resource-methods.md +3 -2
- package/docs/GUIDE/16-autofiltering.md +54 -2
- package/docs/GUIDE/17-row-policies.md +15 -5
- package/docs/GUIDE/19-managed-transactions.md +8 -9
- package/docs/GUIDE/20-transaction-outcomes.md +3 -4
- package/docs/GUIDE/21-schema-and-migrations.md +7 -1
- package/docs/GUIDE/22-version-field-migrations.md +3 -3
- package/docs/GUIDE/27-many-row-writes.md +458 -0
- package/docs/GUIDE/28-socketio.md +10 -2
- package/docs/GUIDE/30-backend-capabilities.md +42 -2
- package/docs/GUIDE/31-positioning.md +3 -1
- package/docs/GUIDE/33-migrating-to-v2.md +2 -2
- package/docs/GUIDE/34-scoped-reads-and-locks.md +464 -0
- package/docs/GUIDE/35-migrating-to-v3.md +325 -0
- package/docs/GUIDE/index.md +5 -2
- package/docs/QUICKSTART.md +2 -2
- package/docs/contributing.md +1 -1
- package/index.d.ts +8 -1
- package/index.js +4 -0
- package/lib/rest-api-errors.js +79 -0
- package/lib/runtime/json-rest-api.js +18 -3
- package/lib/system-context.js +42 -0
- package/package.json +3 -2
- package/plugins/core/bulk-operations-plugin.js +54 -436
- package/plugins/core/connectors/lib/transport-http-helpers.js +34 -3
- package/plugins/core/file-handling-plugin.js +17 -2
- package/plugins/core/lib/anyapi/anyapi-registry.js +3 -2
- package/plugins/core/lib/anyapi/canonical-link-store.js +5 -2
- package/plugins/core/lib/anyapi/canonical-relationship-reader.js +56 -60
- package/plugins/core/lib/dbIntrospection.js +106 -6
- package/plugins/core/lib/querying/aggregate-query.js +215 -22
- package/plugins/core/lib/querying/include-query-helpers.js +11 -13
- package/plugins/core/lib/querying/include-to-one.js +54 -59
- package/plugins/core/lib/querying/knex-pagination-helpers.js +9 -2
- package/plugins/core/lib/querying/query-constraint.d.ts +11 -0
- package/plugins/core/lib/querying/query-constraint.js +45 -0
- package/plugins/core/lib/querying/read-only-query.js +92 -0
- package/plugins/core/lib/querying-writing/compile-schemas.js +14 -0
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
- package/plugins/core/lib/storage/canonical-data-helpers.js +103 -39
- package/plugins/core/lib/storage/many-row-data-helpers.js +738 -0
- package/plugins/core/lib/storage/ordinary-data-helpers.js +63 -79
- package/plugins/core/lib/storage/single-row-writes.js +71 -0
- package/plugins/core/lib/storage/storage-adapter.js +18 -8
- package/plugins/core/lib/storage/storage-mapping.js +4 -1
- package/plugins/core/lib/storage/storage-types.d.ts +21 -3
- package/plugins/core/lib/writing/many-to-many-manipulations.js +10 -5
- package/plugins/core/lib/writing/relationship-processor.js +33 -37
- package/plugins/core/rest-api-anyapi-knex-plugin.js +3 -0
- package/plugins/core/rest-api-autofilter-plugin.js +137 -10
- package/plugins/core/rest-api-knex-plugin.js +2 -0
- package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
- package/plugins/core/rest-api-plugin-methods/common.js +144 -84
- package/plugins/core/rest-api-plugin-methods/delete-many.js +132 -0
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +22 -0
- package/plugins/core/rest-api-plugin-methods/get.js +48 -42
- package/plugins/core/rest-api-plugin-methods/many-row-common.js +274 -0
- package/plugins/core/rest-api-plugin-methods/patch-many.js +333 -0
- package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
- package/plugins/core/rest-api-plugin-methods/post-many.js +295 -0
- package/plugins/core/rest-api-plugin-methods/put.js +5 -1
- package/plugins/core/rest-api-plugin-methods/scoped-reads.js +221 -0
- package/plugins/core/rest-api-plugin.js +17 -0
- package/plugins/core/rest-api-positioning-plugin.js +22 -4
- package/plugins/core/rest-api-query-projections-plugin.js +9 -1
- package/plugins/core/rest-api-row-policy-plugin.js +4 -0
- package/plugins/core/socketio-plugin.js +80 -2
- package/types/autofilter.d.ts +5 -1
- package/types/data-access-methods.d.ts +141 -0
- package/types/errors.d.ts +45 -0
- package/types/hook-context.d.ts +21 -0
- package/types/plugin-options.d.ts +3 -1
- package/types/resource-methods.d.ts +27 -4
- package/types/row-policy.d.ts +3 -0
- package/types/runtime.d.ts +7 -1
- package/docs/GUIDE/27-bulk-operations.md +0 -277
- package/types/bulk-methods.d.ts +0 -63
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
// Many-row storage for table-backed resources: scoped builders, visible-id reads,
|
|
2
|
+
// multi-row inserts, by-id CASE updates, by-id deletes and row locks. Every write
|
|
3
|
+
// runs one statement per chunk; none of them runs through the filter hooks, so
|
|
4
|
+
// MySQL never sees an UPDATE or DELETE whose subquery reads its own table.
|
|
5
|
+
import { RestApiBulkInsertIdsError, RestApiBulkWriteUnsupportedError, RestApiResourceError, RestApiValidationError } from '../../../../lib/rest-api-errors.js'
|
|
6
|
+
import { withQueryFilteringContext } from '../querying/query-builder-utils.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'
|
|
10
|
+
import { applyFieldSelectionToQuery, buildFieldSelection } from '../querying-writing/knex-field-helpers.js'
|
|
11
|
+
import { applyInsertReturning } from '../querying-writing/database-capabilities.js'
|
|
12
|
+
import { loadRelationshipIdentifiers } from '../querying/relationship-identifiers.js'
|
|
13
|
+
import { buildJsonApiResponse } from '../querying/knex-json-api-transformers-querying.js'
|
|
14
|
+
import { deletePivotReferences } from '../writing/many-to-many-manipulations.js'
|
|
15
|
+
|
|
16
|
+
// Bound parameters one statement may carry, with headroom for predicates.
|
|
17
|
+
const PLACEHOLDER_LIMITS = { sqlite: 32000, pg: 65000, mysql: 65000 }
|
|
18
|
+
// Knex writes a SQLite multi-row INSERT as SELECT … UNION ALL, and SQLite caps a compound SELECT at 500 terms.
|
|
19
|
+
const SQLITE_INSERT_ROWS = 500
|
|
20
|
+
|
|
21
|
+
/** @param {import('knex').Knex} db */
|
|
22
|
+
export function databaseFamily (db) {
|
|
23
|
+
const client = String(db.client.config.client)
|
|
24
|
+
if (client.includes('sqlite')) return 'sqlite'
|
|
25
|
+
if (client === 'pg' || client === 'postgresql') return 'pg'
|
|
26
|
+
return 'mysql'
|
|
27
|
+
}
|
|
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
|
+
|
|
61
|
+
/** @param {{ dbInfo?: { version?: string } } | undefined} capabilities */
|
|
62
|
+
function supportsMariaDbReturning (capabilities) {
|
|
63
|
+
const version = String(capabilities?.dbInfo?.version || '').toLowerCase()
|
|
64
|
+
if (!version.includes('mariadb')) return false
|
|
65
|
+
const [major, minor] = version.split(/[.-]/).map(Number)
|
|
66
|
+
return major > 10 || (major === 10 && minor >= 5)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Rows per statement: the requested chunk size, lowered so a statement's
|
|
71
|
+
* bound parameters (and a SQLite INSERT's compound terms) stay under the dialect's limit.
|
|
72
|
+
* @param {import('knex').Knex} db
|
|
73
|
+
* @param {number} chunkSize
|
|
74
|
+
* @param {number} bindingsPerRow
|
|
75
|
+
* @param {{ insert?: boolean }} [options]
|
|
76
|
+
*/
|
|
77
|
+
export function rowsPerStatement (db, chunkSize, bindingsPerRow, { insert = false } = {}) {
|
|
78
|
+
const family = databaseFamily(db)
|
|
79
|
+
const rows = Math.min(chunkSize, Math.floor(PLACEHOLDER_LIMITS[family] / Math.max(1, bindingsPerRow)))
|
|
80
|
+
return Math.max(1, insert && family === 'sqlite' ? Math.min(rows, SQLITE_INSERT_ROWS) : rows)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @template T @param {T[]} items @param {number} size @returns {T[][]} */
|
|
84
|
+
export function chunk (items, size) {
|
|
85
|
+
const chunks = []
|
|
86
|
+
for (let offset = 0; offset < items.length; offset += size) chunks.push(items.slice(offset, offset + size))
|
|
87
|
+
return chunks
|
|
88
|
+
}
|
|
89
|
+
|
|
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
|
+
}
|
|
99
|
+
|
|
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
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {object} dependencies
|
|
134
|
+
* @param {any} dependencies.api
|
|
135
|
+
* @param {(scopeName: string) => any} dependencies.getScopeStorageAdapter
|
|
136
|
+
* @param {Record<string, any>} dependencies.scopes
|
|
137
|
+
* @param {any} dependencies.log
|
|
138
|
+
*/
|
|
139
|
+
export function createManyRowDataHelpers ({ api, getScopeStorageAdapter, scopes, log }) {
|
|
140
|
+
const adapterFor = scopeName => {
|
|
141
|
+
const adapter = getScopeStorageAdapter(scopeName)
|
|
142
|
+
if (!adapter) throw new Error(`Storage adapter for resource '${scopeName}' not found`)
|
|
143
|
+
return adapter
|
|
144
|
+
}
|
|
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
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The resource's base query with ownership, row policy and `filters` applied,
|
|
170
|
+
* as `{ query, alias }`: a builder is thenable, so an async function cannot return it bare.
|
|
171
|
+
* @param {{ scopeName: string, context: any, runHooks: Function, alias?: string, purpose: string, filters?: Record<string, unknown> }} request
|
|
172
|
+
*/
|
|
173
|
+
const dataScopedQuery = async ({ scopeName, context, runHooks, alias, purpose, filters }) => {
|
|
174
|
+
const storageAdapter = adapterFor(scopeName)
|
|
175
|
+
context.storageAdapter = storageAdapter
|
|
176
|
+
const db = context.db || api.knex.instance
|
|
177
|
+
const tableName = storageAdapter.getTableName()
|
|
178
|
+
const tableAlias = alias || tableName
|
|
179
|
+
const state = await withQueryFilteringContext(context, {
|
|
180
|
+
query: storageAdapter.buildBaseQuery({ transaction: db, tableAlias: tableAlias === tableName ? undefined : tableAlias }),
|
|
181
|
+
filters,
|
|
182
|
+
schemaInfo: context.schemaInfo,
|
|
183
|
+
scopeName,
|
|
184
|
+
tableName: tableAlias,
|
|
185
|
+
db,
|
|
186
|
+
queryPurpose: purpose,
|
|
187
|
+
adapter: storageAdapter,
|
|
188
|
+
storageAdapter
|
|
189
|
+
}, () => runHooks('knexQueryFiltering'))
|
|
190
|
+
return { query: state.query, alias: tableAlias }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Identifiers the caller may touch, in id order: those of `ids` that are
|
|
195
|
+
* visible, or one keyset page of the rows `filters` select.
|
|
196
|
+
* @param {{ scopeName: string, context: any, runHooks: Function, ids?: unknown[], filters?: Record<string, unknown>, queryPurpose: string, after?: unknown, limit?: number }} request
|
|
197
|
+
* @returns {Promise<Array<string | number>>}
|
|
198
|
+
*/
|
|
199
|
+
const dataVisibleIds = async ({ scopeName, context, runHooks, ids, filters, queryPurpose, after, limit }) => {
|
|
200
|
+
const storageAdapter = adapterFor(scopeName)
|
|
201
|
+
const db = context.db || api.knex.instance
|
|
202
|
+
const tableName = storageAdapter.getTableName()
|
|
203
|
+
const idColumn = `${tableName}.${storageAdapter.getIdColumn()}`
|
|
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 })
|
|
207
|
+
if (ids) query.whereIn(idColumn, ids.map(id => storageAdapter.translateFilterValue('id', id)))
|
|
208
|
+
if (after !== undefined) query.where(idColumn, '>', storageAdapter.translateFilterValue('id', after))
|
|
209
|
+
// Policy joins can repeat a row; the identity projection keeps big integers exact.
|
|
210
|
+
query.distinct({ id: databaseIdentityExpression(db, idColumn) }).orderBy(idColumn)
|
|
211
|
+
if (limit) query.limit(limit)
|
|
212
|
+
const rows = await applyDatabaseReadOptions(query)
|
|
213
|
+
return rows.map(row => row.id)
|
|
214
|
+
}
|
|
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
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Insert validated rows with one statement per chunk and column set.
|
|
256
|
+
* Returns the stored identifier for each input row (undefined for rows an
|
|
257
|
+
* ignore conflict skipped), or nothing when `needIds` is false.
|
|
258
|
+
* @param {{ scopeName: string, context: any, rows: Record<string, unknown>[], chunkSize: number, needIds: boolean, onConflict?: { keys: string[], action: 'ignore' } }} request
|
|
259
|
+
* @returns {Promise<Array<string | number | undefined> | undefined>}
|
|
260
|
+
*/
|
|
261
|
+
const dataInsertMany = async ({ scopeName, context, rows, chunkSize, needIds, onConflict }) => {
|
|
262
|
+
const storageAdapter = adapterFor(scopeName)
|
|
263
|
+
const db = context.transaction
|
|
264
|
+
const tableName = storageAdapter.getTableName()
|
|
265
|
+
const idColumn = storageAdapter.getIdColumn()
|
|
266
|
+
const family = databaseFamily(db)
|
|
267
|
+
/** @type {Array<string | number | undefined>} */
|
|
268
|
+
const ids = new Array(rows.length)
|
|
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
|
+
|
|
286
|
+
// Rows with the same columns share a statement; SQLite cannot insert DEFAULT for a missing value.
|
|
287
|
+
const groups = new Map()
|
|
288
|
+
storageRows.forEach((row, index) => {
|
|
289
|
+
const signature = Object.keys(row).sort().join('\u0000')
|
|
290
|
+
if (!groups.has(signature)) groups.set(signature, [])
|
|
291
|
+
groups.get(signature).push(index)
|
|
292
|
+
})
|
|
293
|
+
let increment
|
|
294
|
+
for (const indexes of groups.values()) {
|
|
295
|
+
const columns = Object.keys(storageRows[indexes[0]])
|
|
296
|
+
for (const part of chunk(indexes, rowsPerStatement(db, chunkSize, columns.length + 1, { insert: true }))) {
|
|
297
|
+
const values = part.map(index => storageRows[index])
|
|
298
|
+
let query = db(tableName).insert(values)
|
|
299
|
+
// Only a clash on the conflict keys is skipped; any other unique index still fails.
|
|
300
|
+
if (onConflict) query = query.onConflict(keyColumns).ignore()
|
|
301
|
+
|
|
302
|
+
if (!needIds) {
|
|
303
|
+
await query
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (family === 'mysql' && supportsMariaDbReturning(api.knex.capabilities)) {
|
|
308
|
+
const [returned] = await db.raw('? returning ??', [query, idColumn])
|
|
309
|
+
returned.forEach((row, position) => { ids[part[position]] = row[idColumn] })
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (family === 'mysql') {
|
|
314
|
+
if (increment === undefined) {
|
|
315
|
+
const [[setting]] = await db.raw('select @@session.auto_increment_increment as step')
|
|
316
|
+
increment = Number(setting.step)
|
|
317
|
+
}
|
|
318
|
+
const explicit = values.every(row => row[idColumn] !== undefined && row[idColumn] !== null)
|
|
319
|
+
const [firstId] = await query
|
|
320
|
+
const stored = explicit
|
|
321
|
+
? values.map(row => row[idColumn])
|
|
322
|
+
: await verifyMysqlInsertIds({ db, tableName, idColumn, values, firstId, increment, scopeName, schemaInfo: context.schemaInfo, storageAdapter })
|
|
323
|
+
part.forEach((index, position) => { ids[index] = stored[position] })
|
|
324
|
+
continue
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const returnColumns = { [idColumn]: databaseIdentityExpression(db, idColumn) }
|
|
328
|
+
for (const column of keyColumns || []) returnColumns[`__jra_key_${column}`] = column
|
|
329
|
+
let returned = await applyDatabaseReadOptions(applyInsertReturning(query, returnColumns))
|
|
330
|
+
if (onConflict) {
|
|
331
|
+
// Ignored rows return nothing, so returned rows are matched to inputs by their keys.
|
|
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])) })
|
|
335
|
+
continue
|
|
336
|
+
}
|
|
337
|
+
if (family === 'sqlite' && values.some(row => row[idColumn] === undefined || row[idColumn] === null)) {
|
|
338
|
+
// SQLite does not promise RETURNING order; generated row ids increase in insertion order.
|
|
339
|
+
returned = [...returned].sort((left, right) => Number(left[idColumn]) - Number(right[idColumn]))
|
|
340
|
+
}
|
|
341
|
+
returned.forEach((row, position) => { ids[part[position]] = row[idColumn] })
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return needIds ? ids : undefined
|
|
345
|
+
}
|
|
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
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Update visible rows by id: one UPDATE per chunk, `CASE id WHEN … THEN …`
|
|
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.
|
|
397
|
+
* Returns the identifiers the statements changed.
|
|
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
|
|
399
|
+
* @returns {Promise<Array<string | number>>}
|
|
400
|
+
*/
|
|
401
|
+
const dataUpdateByIds = async ({ scopeName, context, runHooks, updates, onlyIf, chunkSize, scoped = false, filters }) => {
|
|
402
|
+
const storageAdapter = adapterFor(scopeName)
|
|
403
|
+
const db = context.transaction
|
|
404
|
+
const tableName = storageAdapter.getTableName()
|
|
405
|
+
const idColumn = storageAdapter.getIdColumn()
|
|
406
|
+
const family = databaseFamily(db)
|
|
407
|
+
const lockVersionColumn = context.schemaInfo.lockVersionField ? storageAdapter.translateColumn(context.schemaInfo.lockVersionField) : null
|
|
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)
|
|
412
|
+
const changed = []
|
|
413
|
+
|
|
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)))) {
|
|
416
|
+
const set = {}
|
|
417
|
+
const columns = [...new Set(part.flatMap(update => Object.keys(update.row)))]
|
|
418
|
+
for (const column of columns) {
|
|
419
|
+
const entries = part.filter(update => Object.hasOwn(update.row, column))
|
|
420
|
+
const first = entries[0].row[column]
|
|
421
|
+
const same = entries.length === part.length && entries.every(update => sameValue(update.row[column], first))
|
|
422
|
+
if (same) {
|
|
423
|
+
set[column] = first
|
|
424
|
+
continue
|
|
425
|
+
}
|
|
426
|
+
const bindings = [idColumn]
|
|
427
|
+
let sql = 'case ??'
|
|
428
|
+
for (const update of entries) {
|
|
429
|
+
sql += ' when ? then ?'
|
|
430
|
+
bindings.push(storageAdapter.translateFilterValue('id', update.id), update.row[column])
|
|
431
|
+
}
|
|
432
|
+
set[column] = db.raw(`${sql} else ?? end`, [...bindings, column])
|
|
433
|
+
}
|
|
434
|
+
if (lockVersionColumn) set[lockVersionColumn] = db.raw('?? + 1', [lockVersionColumn])
|
|
435
|
+
const writes = Object.keys(set).length > 0
|
|
436
|
+
const ids = part.map(update => storageAdapter.translateFilterValue('id', update.id))
|
|
437
|
+
if (!guarded) {
|
|
438
|
+
if (writes) await db(tableName).whereIn(idColumn, ids).update(set)
|
|
439
|
+
changed.push(...part.map(update => update.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)
|
|
453
|
+
} else {
|
|
454
|
+
await settle(ids)
|
|
455
|
+
const rows = await applyDatabaseReadOptions(matching(ids).returning({ id: databaseIdentityExpression(db, idColumn) }).update(set))
|
|
456
|
+
changed.push(...rows.map(row => row.id))
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return changed
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Delete visible rows by id, one DELETE per chunk, then their pivot rows.
|
|
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>>}
|
|
469
|
+
*/
|
|
470
|
+
const dataDeleteByIds = async ({ scopeName, context, runHooks, ids, chunkSize, scoped = false, filters }) => {
|
|
471
|
+
const storageAdapter = adapterFor(scopeName)
|
|
472
|
+
const db = context.transaction
|
|
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)
|
|
501
|
+
}
|
|
502
|
+
return deleted
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Reads rows through the get field selection, so they leave as response records.
|
|
506
|
+
const readRecords = async ({ scopeName, context, query, tableName }) => {
|
|
507
|
+
const scope = api.resources[scopeName]
|
|
508
|
+
const storageAdapter = adapterFor(scopeName)
|
|
509
|
+
const db = context.db
|
|
510
|
+
const fieldSelectionInfo = await buildFieldSelection(scope, { context })
|
|
511
|
+
context.computedDependencies = fieldSelectionInfo.computedDependencies
|
|
512
|
+
const { query: selected } = await applyFieldSelectionToQuery({
|
|
513
|
+
query, scope, fieldSelectionInfo, tableName, useTablePrefix: true, storageAdapter, db, context, scopeName
|
|
514
|
+
})
|
|
515
|
+
const rows = await applyDatabaseReadOptions(selected)
|
|
516
|
+
await loadRelationshipIdentifiers(rows, scopeName, scopes, db, context)
|
|
517
|
+
return buildJsonApiResponse(scope, rows, [], false, scopeName, context)
|
|
518
|
+
}
|
|
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
|
+
|
|
532
|
+
const lockRows = (query, db, { skipLocked = false } = {}) => {
|
|
533
|
+
if (databaseFamily(db) === 'sqlite') return query
|
|
534
|
+
query.forUpdate()
|
|
535
|
+
return skipLocked ? query.skipLocked() : query
|
|
536
|
+
}
|
|
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
|
+
|
|
556
|
+
/**
|
|
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.
|
|
561
|
+
* Returns the rows the locking read saw as a JSON:API document.
|
|
562
|
+
* @param {{ scopeName: string, context: any, runHooks: Function, ids: Array<string | number> }} request
|
|
563
|
+
*/
|
|
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
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Take up to `limit` matching rows no other transaction holds.
|
|
651
|
+
* @param {{ scopeName: string, context: any, runHooks: Function, filters?: Record<string, unknown>, sort: Array<{ field: string, direction: 'asc' | 'desc' }>, limit: number }} request
|
|
652
|
+
*/
|
|
653
|
+
const dataClaim = async ({ scopeName, context, runHooks, filters, sort, limit }) => {
|
|
654
|
+
const db = context.db
|
|
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)
|
|
658
|
+
if (databaseFamily(db) !== 'mysql') {
|
|
659
|
+
// PostgreSQL locks rows as the LIMIT takes them; SQLite has one writer.
|
|
660
|
+
return readRecords({ scopeName, context, query: lockRows(query.limit(limit), db, { skipLocked: true }), tableName })
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// InnoDB locks every row a locking read examines, including rows a sort
|
|
664
|
+
// reads and the LIMIT then drops, so one claim would hold every match.
|
|
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.
|
|
670
|
+
const claimed = { data: [] }
|
|
671
|
+
const identity = { id: databaseIdentityExpression(db, idColumn) }
|
|
672
|
+
const pageSize = Math.max(limit * 2, 20)
|
|
673
|
+
for (let offset = 0; claimed.data.length < limit; offset += pageSize) {
|
|
674
|
+
const candidates = (await applyDatabaseReadOptions(query.clone().offset(offset).limit(pageSize).select(identity))).map(row => row.id)
|
|
675
|
+
for (let next = 0; next < candidates.length && claimed.data.length < limit;) {
|
|
676
|
+
const window = candidates.slice(next, next + limit - claimed.data.length)
|
|
677
|
+
next += window.length
|
|
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))))
|
|
685
|
+
}
|
|
686
|
+
if (candidates.length < pageSize) break
|
|
687
|
+
}
|
|
688
|
+
return claimed
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return { dataScopedQuery, dataVisibleIds, dataIdsByKeys, dataInsertMany, dataUpdateByIds, dataDeleteByIds, dataLockByIds, dataLockWhere, dataClaim }
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const sameValue = (left, right) => {
|
|
695
|
+
if (left === right) return true
|
|
696
|
+
if (isRawValue(left) || isRawValue(right)) return false
|
|
697
|
+
if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime()
|
|
698
|
+
return false
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function applyOnlyIf (query, onlyIf, tableName) {
|
|
702
|
+
for (const [column, value] of Object.entries(onlyIf)) {
|
|
703
|
+
if (value === null) query.whereNull(`${tableName}.${column}`)
|
|
704
|
+
else query.where(`${tableName}.${column}`, value)
|
|
705
|
+
}
|
|
706
|
+
return query
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// MySQL reports only the first generated id. One multi-row INSERT is expected to
|
|
710
|
+
// take consecutive values; prove it by reading the range back inside the
|
|
711
|
+
// transaction and comparing the rows with what was inserted.
|
|
712
|
+
async function verifyMysqlInsertIds ({ db, tableName, idColumn, values, firstId, increment, scopeName, schemaInfo, storageAdapter }) {
|
|
713
|
+
const start = Number(firstId)
|
|
714
|
+
const expected = values.map((_row, position) => start + position * increment)
|
|
715
|
+
const comparable = comparableColumns(values, schemaInfo, storageAdapter)
|
|
716
|
+
const rows = await applyDatabaseReadOptions(db(tableName).whereIn(idColumn, expected).orderBy(idColumn)
|
|
717
|
+
.select({ __jra_id: idColumn }, ...comparable))
|
|
718
|
+
const verified = rows.length === values.length && rows.every((row, position) =>
|
|
719
|
+
Number(row.__jra_id) === expected[position] && comparable.every(column => String(row[column]) === String(values[position][column])))
|
|
720
|
+
if (!verified) {
|
|
721
|
+
throw new RestApiBulkInsertIdsError({ resourceType: scopeName, expected: values.length, verified: rows.length })
|
|
722
|
+
}
|
|
723
|
+
return expected
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Columns whose stored value reads back exactly: strings and integers.
|
|
727
|
+
function comparableColumns (values, schemaInfo, storageAdapter) {
|
|
728
|
+
const columns = []
|
|
729
|
+
for (const [field, definition] of Object.entries(schemaInfo.schemaStructure || {})) {
|
|
730
|
+
if (field === 'id' || definition.virtual || definition.computed || definition.storage?.serialize) continue
|
|
731
|
+
const column = storageAdapter.translateColumn(field)
|
|
732
|
+
const sample = values[0][column]
|
|
733
|
+
const textual = ['string', 'email', 'uuid'].includes(definition.type) && typeof sample === 'string'
|
|
734
|
+
const integral = (definition.type === 'integer' || definition.belongsTo) && Number.isSafeInteger(Number(sample)) && sample !== null && sample !== ''
|
|
735
|
+
if ((textual || integral) && values.every(row => row[column] !== null && row[column] !== undefined)) columns.push(column)
|
|
736
|
+
}
|
|
737
|
+
return columns
|
|
738
|
+
}
|