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
|
@@ -8,7 +8,8 @@ import { buildJsonApiResponse } from '../querying/knex-json-api-transformers-que
|
|
|
8
8
|
import { toJsonApiRecordWithBelongsTo } from '../querying-writing/knex-json-api-transformers.js'
|
|
9
9
|
import { processIncludes } from '../querying/knex-process-includes.js'
|
|
10
10
|
import { loadRelationshipIdentifiers } from '../querying/relationship-identifiers.js'
|
|
11
|
-
import { applyQueryConstraint } from '../querying/query-constraint.js'
|
|
11
|
+
import { applyQueryConstraint, queryConstraint } from '../querying/query-constraint.js'
|
|
12
|
+
import { deleteFoundRow, foundRow, lockFoundRow, updateFoundRow } from './single-row-writes.js'
|
|
12
13
|
import { runAggregateQuery } from '../querying/aggregate-query.js'
|
|
13
14
|
import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
|
|
14
15
|
import { getOperationDiagnosticContext } from '../../../../lib/error-context.js'
|
|
@@ -19,6 +20,8 @@ import { getUrlPrefix } from '../querying/url-helpers.js'
|
|
|
19
20
|
import { unwrapQueryBuilderState, withQueryFilteringContext } from '../querying/query-builder-utils.js'
|
|
20
21
|
import { serializeJsonApiQuery } from '../querying-writing/connectors-query-parser.js'
|
|
21
22
|
import { applyDatabaseReadOptions, databaseIdentityExpression } from '../querying-writing/database-value-normalizers.js'
|
|
23
|
+
import { whereInIdentifiers } from '../querying/identifier-query.js'
|
|
24
|
+
import { databaseFamily } from './many-row-data-helpers.js'
|
|
22
25
|
|
|
23
26
|
/**
|
|
24
27
|
* Every update advances the resource's lock version by one, whatever the input says.
|
|
@@ -192,7 +195,7 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
192
195
|
}
|
|
193
196
|
|
|
194
197
|
/** @type {OrdinaryDataReadHelpers['dataGet']} */
|
|
195
|
-
const dataGet = async ({ scopeName, context }) => {
|
|
198
|
+
const dataGet = async ({ scopeName, context, runHooks, scoped = false }) => {
|
|
196
199
|
const storageAdapter = getScopeStorageAdapter(scopeName)
|
|
197
200
|
context.storageAdapter = storageAdapter
|
|
198
201
|
const scope = getScope(scopeName)
|
|
@@ -207,15 +210,32 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
207
210
|
// Enrichment removes fetched dependencies that the caller did not request.
|
|
208
211
|
context.computedDependencies = fieldSelectionInfo.computedDependencies
|
|
209
212
|
|
|
210
|
-
// The preceding minimal read has already applied permission and row filters
|
|
211
|
-
|
|
213
|
+
// The preceding minimal read has already applied permission and row filters,
|
|
214
|
+
// unless the read is `scoped`: then it applies them itself, as the only read.
|
|
215
|
+
let query = db(tableName).where(scoped ? `${tableName}.${idProperty}` : idProperty, id)
|
|
216
|
+
if (scoped) {
|
|
217
|
+
const filterHooks = runHooks
|
|
218
|
+
if (!filterHooks) throw new Error('A scoped read needs runHooks')
|
|
219
|
+
query = (await withQueryFilteringContext(context, {
|
|
220
|
+
query,
|
|
221
|
+
filters: context.queryParams?.filters,
|
|
222
|
+
schemaInfo: context.schemaInfo,
|
|
223
|
+
scopeName,
|
|
224
|
+
tableName,
|
|
225
|
+
db,
|
|
226
|
+
queryPurpose: 'single',
|
|
227
|
+
adapter: storageAdapter,
|
|
228
|
+
storageAdapter
|
|
229
|
+
}, () => filterHooks('knexQueryFiltering'))).query
|
|
230
|
+
}
|
|
212
231
|
|
|
213
232
|
const selectionState = await applyFieldSelectionToQuery({
|
|
214
233
|
query,
|
|
215
234
|
scope,
|
|
216
235
|
fieldSelectionInfo,
|
|
217
236
|
tableName,
|
|
218
|
-
|
|
237
|
+
// A row policy may join tables, so a scoped read names its columns' table.
|
|
238
|
+
useTablePrefix: scoped,
|
|
219
239
|
storageAdapter,
|
|
220
240
|
db,
|
|
221
241
|
context,
|
|
@@ -250,7 +270,7 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
250
270
|
return buildJsonApiResponse(scope, records, included, true, scopeName, context)
|
|
251
271
|
}
|
|
252
272
|
|
|
253
|
-
// Supplying ids returns minimal records for
|
|
273
|
+
// Supplying ids returns minimal records for all of them, in one statement.
|
|
254
274
|
/** @overload @param {DataMinimalRequest & { ids: Array<string | number> }} request @returns {Promise<DataResource[]>} */
|
|
255
275
|
/** @overload @param {DataMinimalRequest & { ids?: undefined, context: DataReadContext & {id: string | number} }} request @returns {Promise<DataResource | null>} */
|
|
256
276
|
/** @param {DataMinimalRequest & { ids?: Array<string | number> }} request */
|
|
@@ -261,7 +281,8 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
261
281
|
runHooks,
|
|
262
282
|
applyQueryFilters,
|
|
263
283
|
filters = context.queryParams?.filters,
|
|
264
|
-
queryPurpose = 'single'
|
|
284
|
+
queryPurpose = 'single',
|
|
285
|
+
locked = false
|
|
265
286
|
}) {
|
|
266
287
|
const storageAdapter = getScopeStorageAdapter(scopeName)
|
|
267
288
|
context.storageAdapter = storageAdapter
|
|
@@ -273,11 +294,12 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
273
294
|
|
|
274
295
|
log.debug(`[Knex] GET_MINIMAL ${tableName}/${id}`)
|
|
275
296
|
|
|
276
|
-
|
|
297
|
+
const table = db(tableName)
|
|
298
|
+
// whereInIdentifiers returns the builder it was given, which modify's types lose.
|
|
277
299
|
let query = ids === undefined
|
|
278
|
-
?
|
|
279
|
-
:
|
|
280
|
-
|
|
300
|
+
? table.where(idProperty, id)
|
|
301
|
+
: /** @type {typeof table} */ (table.modify(whereInIdentifiers, idProperty, ids.map(value => storageAdapter.translateFilterValue('id', value))))
|
|
302
|
+
.distinct(`${tableName}.${idProperty}`)
|
|
281
303
|
|
|
282
304
|
if (typeof applyQueryFilters === 'function') {
|
|
283
305
|
const scopedQueryState = await applyQueryFilters({
|
|
@@ -312,8 +334,21 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
312
334
|
const column = storageAdapter.translateColumn(field)
|
|
313
335
|
return [field === 'id' ? 'id' : column, databaseIdentityExpression(db, `${tableName}.${column}`)]
|
|
314
336
|
}))
|
|
337
|
+
if (ids !== undefined && locked && databaseFamily(db) === 'mysql') {
|
|
338
|
+
// InnoDB answers a plain read from the transaction's snapshot and a
|
|
339
|
+
// locking read from the latest rows. Rows this transaction has locked
|
|
340
|
+
// are read with a locking read in the scope's own query block, row
|
|
341
|
+
// policy included, so a row another transaction committed after the
|
|
342
|
+
// snapshot is seen as it was locked. (PostgreSQL's plain and locking
|
|
343
|
+
// reads see the same rows; SQLite has one writer.)
|
|
344
|
+
/** @type {StorageRow[]} */
|
|
345
|
+
const rows = await applyDatabaseReadOptions(query.clearSelect().select(`${tableName}.*`, identities).forShare())
|
|
346
|
+
// A policy join can repeat a row.
|
|
347
|
+
const records = [...new Map(rows.map(row => [String(row.id), row])).values()]
|
|
348
|
+
return records.map(record => toJsonApiRecordWithBelongsTo(scope, record, scopeName))
|
|
349
|
+
}
|
|
315
350
|
if (ids !== undefined) {
|
|
316
|
-
// Filtering owns predicates; the
|
|
351
|
+
// Filtering owns predicates; the id list owns its identity projection.
|
|
317
352
|
query.clearSelect().distinct(`${tableName}.${idProperty}`)
|
|
318
353
|
// Deduplicate policy joins by ID, without comparing JSON or other row values.
|
|
319
354
|
/** @type {StorageRow[]} */
|
|
@@ -405,6 +440,9 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
405
440
|
})
|
|
406
441
|
const { pageSize } = paginationInfo
|
|
407
442
|
|
|
443
|
+
// Rows this transaction has locked, read as its locking reads see them (see dataGetMinimal).
|
|
444
|
+
const constraint = context[queryConstraint]
|
|
445
|
+
if (constraint?.scopeName === scopeName && constraint.locked && databaseFamily(db) === 'mysql') query.forShare()
|
|
408
446
|
/** @type {StorageRow[]} */
|
|
409
447
|
const records = await applyDatabaseReadOptions(query)
|
|
410
448
|
|
|
@@ -447,12 +485,10 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
447
485
|
// Calculate pagination metadata with total
|
|
448
486
|
context.returnMeta.paginationMeta = calculatePaginationMeta(total, page, pageSize)
|
|
449
487
|
} else {
|
|
450
|
-
// Without count,
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
// No total, pageCount, or hasMore when counts are disabled
|
|
455
|
-
}
|
|
488
|
+
// Without a count, the page's one extra row answers hasMore.
|
|
489
|
+
const hasMore = records.length > pageSize
|
|
490
|
+
if (hasMore) records.pop()
|
|
491
|
+
context.returnMeta.paginationMeta = { page, pageSize, hasMore }
|
|
456
492
|
}
|
|
457
493
|
|
|
458
494
|
// Generate links
|
|
@@ -623,29 +659,11 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
623
659
|
return
|
|
624
660
|
}
|
|
625
661
|
|
|
626
|
-
const exists = await db(tableName)
|
|
627
|
-
.where(idProperty, id)
|
|
628
|
-
.first()
|
|
629
|
-
|
|
630
|
-
if (!exists) {
|
|
631
|
-
throw new RestApiResourceError(
|
|
632
|
-
'Resource not found',
|
|
633
|
-
{
|
|
634
|
-
subtype: ERROR_SUBTYPES.NOT_FOUND,
|
|
635
|
-
resourceType: scopeName,
|
|
636
|
-
resourceId: id
|
|
637
|
-
}
|
|
638
|
-
)
|
|
639
|
-
}
|
|
640
|
-
|
|
641
662
|
// Updates cannot replace the primary key.
|
|
642
663
|
delete finalAttributes[idProperty]
|
|
643
664
|
advanceLockVersion(finalAttributes, context, storageAdapter, db)
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
.where(idProperty, id)
|
|
647
|
-
.update(finalAttributes)
|
|
648
|
-
}
|
|
665
|
+
const row = foundRow({ byId: () => db(tableName).where(`${tableName}.${idProperty}`, id), context, scopeName, storageAdapter, tableName })
|
|
666
|
+
await updateFoundRow(row, { db, scopeName, id, idColumn: `${tableName}.${idProperty}`, values: finalAttributes })
|
|
649
667
|
}
|
|
650
668
|
|
|
651
669
|
/** @type {OrdinaryDataWriteHelpers['dataPatch']} */
|
|
@@ -660,22 +678,6 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
660
678
|
|
|
661
679
|
log.debug(`[Knex] PATCH ${tableName}/${id}`)
|
|
662
680
|
|
|
663
|
-
// Check if record exists
|
|
664
|
-
const exists = await db(tableName)
|
|
665
|
-
.where(idProperty, id)
|
|
666
|
-
.first()
|
|
667
|
-
|
|
668
|
-
if (!exists) {
|
|
669
|
-
throw new RestApiResourceError(
|
|
670
|
-
'Resource not found',
|
|
671
|
-
{
|
|
672
|
-
subtype: ERROR_SUBTYPES.NOT_FOUND,
|
|
673
|
-
resourceType: scopeName,
|
|
674
|
-
resourceId: id
|
|
675
|
-
}
|
|
676
|
-
)
|
|
677
|
-
}
|
|
678
|
-
|
|
679
681
|
// Core has already merged relationship values and applied setters.
|
|
680
682
|
const attributes = inputRecord.data.attributes || {}
|
|
681
683
|
|
|
@@ -691,12 +693,8 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
691
693
|
delete finalAttributes[idProperty]
|
|
692
694
|
advanceLockVersion(finalAttributes, context, storageAdapter, db)
|
|
693
695
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
await db(tableName)
|
|
697
|
-
.where(idProperty, id)
|
|
698
|
-
.update(finalAttributes)
|
|
699
|
-
}
|
|
696
|
+
const row = foundRow({ byId: () => db(tableName).where(`${tableName}.${idProperty}`, id), context, scopeName, storageAdapter, tableName })
|
|
697
|
+
await updateFoundRow(row, { db, scopeName, id, idColumn: `${tableName}.${idProperty}`, values: finalAttributes })
|
|
700
698
|
}
|
|
701
699
|
|
|
702
700
|
/** @type {OrdinaryDataWriteHelpers['dataDelete']} */
|
|
@@ -711,36 +709,24 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
711
709
|
|
|
712
710
|
log.debug(`[Knex] DELETE ${tableName}/${id}`)
|
|
713
711
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
if (!exists) {
|
|
720
|
-
throw new RestApiResourceError(
|
|
721
|
-
'Resource not found',
|
|
722
|
-
{
|
|
723
|
-
subtype: ERROR_SUBTYPES.NOT_FOUND,
|
|
724
|
-
resourceType: scopeName,
|
|
725
|
-
resourceId: id
|
|
726
|
-
}
|
|
727
|
-
)
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
// Delete the record
|
|
731
|
-
await invalidateDeletedPivotTargets(api, scopeName, context)
|
|
732
|
-
await db(tableName)
|
|
733
|
-
.where(idProperty, id)
|
|
734
|
-
.delete()
|
|
712
|
+
const row = foundRow({ byId: () => db(tableName).where(`${tableName}.${idProperty}`, id), context, scopeName, storageAdapter, tableName })
|
|
713
|
+
// Before the DELETE: an ON DELETE CASCADE would remove the links that tell which parents to advance.
|
|
714
|
+
await invalidateDeletedPivotTargets(api, scopeName, context, () => lockFoundRow(row, { scopeName, id, idColumn: `${tableName}.${idProperty}` }))
|
|
715
|
+
await deleteFoundRow(row, { scopeName, id })
|
|
735
716
|
await deletePivotReferences(api, scopeName, context)
|
|
736
717
|
|
|
737
718
|
return { success: true }
|
|
738
719
|
}
|
|
739
720
|
|
|
721
|
+
// A get with no reader of the minimal record reads once, scoped.
|
|
722
|
+
/** @type {NonNullable<OrdinaryDataReadHelpers['dataGetScoped']>} */
|
|
723
|
+
const dataGetScoped = ({ scopeName, context, runHooks }) => dataGet({ scopeName, context, runHooks, scoped: true })
|
|
724
|
+
|
|
740
725
|
return {
|
|
741
726
|
dataRelatedIdsQuery,
|
|
742
727
|
dataExists,
|
|
743
728
|
dataGet,
|
|
729
|
+
dataGetScoped,
|
|
744
730
|
dataQuery,
|
|
745
731
|
dataAggregate,
|
|
746
732
|
dataPost,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Single patch, put and delete find their row with a scoped read first, on
|
|
3
|
+
// table and AnyAPI storage alike. Their UPDATE or DELETE repeats the owner that
|
|
4
|
+
// read recorded, and its own row count tells whether the row has gone or moved
|
|
5
|
+
// to another owner since.
|
|
6
|
+
import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
|
|
7
|
+
import { ERROR_SUBTYPES } from '../querying-writing/knex-constants.js'
|
|
8
|
+
import { applyWriteConditions } from '../querying/query-constraint.js'
|
|
9
|
+
import { countsMatchedRows } from './many-row-data-helpers.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} scopeName
|
|
13
|
+
* @param {string | number} id
|
|
14
|
+
*/
|
|
15
|
+
const resourceNotFound = (scopeName, id) => new RestApiResourceError('Resource not found', {
|
|
16
|
+
subtype: ERROR_SUBTYPES.NOT_FOUND,
|
|
17
|
+
resourceType: scopeName,
|
|
18
|
+
resourceId: id
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The row a single write found with its scoped read: `byId` looks it up, and
|
|
23
|
+
* the owner that read recorded is added, so a row that has gone or moved to
|
|
24
|
+
* another owner since is not matched.
|
|
25
|
+
* @param {{ byId: () => any, context: any, scopeName: string, storageAdapter: import('./storage-types.js').StorageAdapter, tableName: string }} target
|
|
26
|
+
* @returns {() => any}
|
|
27
|
+
*/
|
|
28
|
+
export const foundRow = ({ byId, context, scopeName, storageAdapter, tableName }) => () =>
|
|
29
|
+
applyWriteConditions({ query: byId(), context, scopeName, storageAdapter, tableName })
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Locks the row a single write found; not found when it has gone or moved.
|
|
33
|
+
* @param {() => any} row
|
|
34
|
+
* @param {{ scopeName: string, id: string | number, idColumn: string }} target
|
|
35
|
+
*/
|
|
36
|
+
export async function lockFoundRow (row, { scopeName, id, idColumn }) {
|
|
37
|
+
if (!await row().select(idColumn).forUpdate().first()) throw resourceNotFound(scopeName, id)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Updates the row a single write found, or, with nothing stored to change,
|
|
42
|
+
* locks it. Not found when it has gone or moved.
|
|
43
|
+
* @param {() => any} row
|
|
44
|
+
* @param {{ db: any, scopeName: string, id: string | number, idColumn: string, values: Record<string, unknown> }} update
|
|
45
|
+
* @returns {Promise<number>} The rows the UPDATE counted
|
|
46
|
+
*/
|
|
47
|
+
export async function updateFoundRow (row, { db, scopeName, id, idColumn, values }) {
|
|
48
|
+
if (!Object.keys(values).length) {
|
|
49
|
+
await lockFoundRow(row, { scopeName, id, idColumn })
|
|
50
|
+
return 0
|
|
51
|
+
}
|
|
52
|
+
const counted = await row().update(values)
|
|
53
|
+
if (counted) return counted
|
|
54
|
+
// MySQL without FOUND_ROWS counts the rows an UPDATE changed: an unchanged row counts zero.
|
|
55
|
+
if (await countsMatchedRows(db)) throw resourceNotFound(scopeName, id)
|
|
56
|
+
await lockFoundRow(row, { scopeName, id, idColumn })
|
|
57
|
+
return 0
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Deletes the row a single write found. A DELETE counts the rows it removed,
|
|
62
|
+
* FOUND_ROWS or not; not found when it is gone.
|
|
63
|
+
* @param {() => any} row
|
|
64
|
+
* @param {{ scopeName: string, id: string | number }} target
|
|
65
|
+
* @returns {Promise<number>}
|
|
66
|
+
*/
|
|
67
|
+
export async function deleteFoundRow (row, { scopeName, id }) {
|
|
68
|
+
const deleted = await row().delete()
|
|
69
|
+
if (!deleted) throw resourceNotFound(scopeName, id)
|
|
70
|
+
return deleted
|
|
71
|
+
}
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
translateCanonicalAttributesForStorage,
|
|
15
15
|
} from './canonical-storage-mapping.js'
|
|
16
16
|
import { assertScalarQueryField } from '../querying-writing/field-utils.js'
|
|
17
|
-
import { normalizeValueForDatabaseStorage } from '../querying-writing/database-value-normalizers.js'
|
|
17
|
+
import { assertSingleColumnValue, normalizeValueForDatabaseStorage } from '../querying-writing/database-value-normalizers.js'
|
|
18
18
|
|
|
19
19
|
/** @import { BaseStorageAdapterOptions, CanonicalDescriptor, SelectTranslator, StorageAdapter, StorageAdapterOptions, StorageAdapterLookupOptions, StorageSchemaInfo, StorageFieldDefinition } from './storage-types.js' */
|
|
20
20
|
|
|
@@ -59,16 +59,22 @@ const normalizeBooleanValue = (value) => {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
+
* A filter or cursor value, or a list of them, for one column. Objects and
|
|
63
|
+
* nested lists are refused: a MySQL driver could expand them into SQL.
|
|
62
64
|
* @param {unknown} value
|
|
63
65
|
* @param {StorageFieldDefinition} [definition]
|
|
64
|
-
* @param {{ isRelationship?: boolean, databaseClient?: string, textStorage?: boolean }} [options]
|
|
66
|
+
* @param {{ isRelationship?: boolean, databaseClient?: string, textStorage?: boolean, fieldName?: string, parameter?: string }} [options]
|
|
65
67
|
* @returns {unknown}
|
|
66
68
|
*/
|
|
67
|
-
const normalizeFilterValueForDefinition = (value, definition = {}, { isRelationship = false, databaseClient, textStorage } = {}) => {
|
|
69
|
+
const normalizeFilterValueForDefinition = (value, definition = {}, { isRelationship = false, databaseClient, textStorage, fieldName, parameter = `filters.${fieldName}` } = {}) => {
|
|
68
70
|
if (value === null || value === undefined) return value
|
|
69
71
|
if (Array.isArray(value)) {
|
|
70
|
-
return value.map(entry =>
|
|
72
|
+
return value.map(entry => {
|
|
73
|
+
assertSingleColumnValue(entry, { fieldName, parameter })
|
|
74
|
+
return normalizeFilterValueForDefinition(entry, definition, { isRelationship, databaseClient, textStorage, fieldName, parameter })
|
|
75
|
+
})
|
|
71
76
|
}
|
|
77
|
+
assertSingleColumnValue(value, { fieldName, parameter })
|
|
72
78
|
|
|
73
79
|
if (isRelationship) {
|
|
74
80
|
return normalizeBelongsToValue(value)
|
|
@@ -181,7 +187,9 @@ const createTableAdapter = ({ knex, schemaInfo }) => {
|
|
|
181
187
|
const definition = schemaInfo.schemaStructure?.[field]
|
|
182
188
|
return normalizeFilterValueForDefinition(value, definition, {
|
|
183
189
|
isRelationship: Boolean(definition?.belongsTo || definition?.belongsToPolymorphic),
|
|
184
|
-
databaseClient: knex.client.config.client
|
|
190
|
+
databaseClient: knex.client.config.client,
|
|
191
|
+
fieldName: field,
|
|
192
|
+
parameter: 'page'
|
|
185
193
|
})
|
|
186
194
|
}
|
|
187
195
|
/** @type {StorageAdapter['translateFilterValue']} */
|
|
@@ -209,7 +217,7 @@ const createTableAdapter = ({ knex, schemaInfo }) => {
|
|
|
209
217
|
schemaField?.belongsTo ||
|
|
210
218
|
schemaField?.belongsToPolymorphic
|
|
211
219
|
)
|
|
212
|
-
return normalizeFilterValueForDefinition(value, definition, { isRelationship, databaseClient: knex.client.config.client })
|
|
220
|
+
return normalizeFilterValueForDefinition(value, definition, { isRelationship, databaseClient: knex.client.config.client, fieldName: field })
|
|
213
221
|
}
|
|
214
222
|
|
|
215
223
|
return baseAdapter({
|
|
@@ -305,7 +313,7 @@ const createCanonicalAdapter = ({ knex, schemaInfo, descriptor }) => {
|
|
|
305
313
|
)
|
|
306
314
|
const definition = searchField || schemaField || {}
|
|
307
315
|
|
|
308
|
-
return normalizeFilterValueForDefinition(value, definition, { isRelationship, databaseClient: knex.client.config.client, textStorage: fieldsInfo[fieldName]?.slotType === 'string' })
|
|
316
|
+
return normalizeFilterValueForDefinition(value, definition, { isRelationship, databaseClient: knex.client.config.client, textStorage: fieldsInfo[fieldName]?.slotType === 'string', fieldName: field })
|
|
309
317
|
}
|
|
310
318
|
|
|
311
319
|
/** @type {StorageAdapter['translateCursorValue']} */
|
|
@@ -315,7 +323,9 @@ const createCanonicalAdapter = ({ knex, schemaInfo, descriptor }) => {
|
|
|
315
323
|
return normalizeFilterValueForDefinition(value, definition, {
|
|
316
324
|
isRelationship: Boolean(definition?.belongsTo || definition?.belongsToPolymorphic),
|
|
317
325
|
databaseClient: knex.client.config.client,
|
|
318
|
-
textStorage: fieldsInfo[field]?.slotType === 'string'
|
|
326
|
+
textStorage: fieldsInfo[field]?.slotType === 'string',
|
|
327
|
+
fieldName: field,
|
|
328
|
+
parameter: 'page'
|
|
319
329
|
})
|
|
320
330
|
}
|
|
321
331
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
import { normalizeValueForDatabaseStorage } from '../querying-writing/database-value-normalizers.js'
|
|
2
|
+
import { assertSingleColumnValue, normalizeValueForDatabaseStorage } from '../querying-writing/database-value-normalizers.js'
|
|
3
|
+
import { SERIALIZATION_CAPABILITIES } from '../querying-writing/database-capabilities.js'
|
|
3
4
|
import { assertFieldNameMap } from '../querying-writing/field-utils.js'
|
|
4
5
|
|
|
5
6
|
/** @import { FieldStorage, StorageConfig, StorageFieldDefinition, StorageInfo, StorageNaming, StorageRow, StorageSchemaInfo, StorageSerializerDetails, StorageWriteOptions } from './storage-types.js' */
|
|
@@ -262,6 +263,8 @@ export const serializeFieldValueForStorage = (value, {
|
|
|
262
263
|
}
|
|
263
264
|
return serialized
|
|
264
265
|
}
|
|
266
|
+
// Only object and array fields store a structure (as JSON); any other column takes one value.
|
|
267
|
+
if (!(definition.type && SERIALIZATION_CAPABILITIES.structuredTypes.includes(definition.type))) assertSingleColumnValue(value, { fieldName })
|
|
265
268
|
return normalizeValueForDatabaseStorage(value, definition.type, {
|
|
266
269
|
databaseClient,
|
|
267
270
|
textStorage,
|
|
@@ -13,6 +13,8 @@ export interface MandatoryQueryConstraint {
|
|
|
13
13
|
scopeName: string
|
|
14
14
|
values?: StorageRow
|
|
15
15
|
idsQuery?: StorageQuery
|
|
16
|
+
/** The caller's transaction holds row locks on the ids: read them as its locking reads see them. */
|
|
17
|
+
locked?: boolean
|
|
16
18
|
}
|
|
17
19
|
export type SelectColumn = string | Knex.Raw<unknown> | StorageQuery | Record<string, string>
|
|
18
20
|
export type SelectColumns = SelectColumn[] | Record<string, string> | null | undefined
|
|
@@ -291,6 +293,8 @@ export type QueryBuilderResult = StorageQuery | { query: QueryBuilderResult }
|
|
|
291
293
|
export interface DataMinimalRequest extends DataReadRequest {
|
|
292
294
|
filters?: StorageRow
|
|
293
295
|
queryPurpose?: string
|
|
296
|
+
/** With ids: the caller's transaction holds row locks on them, so read them as its locking reads see them. */
|
|
297
|
+
locked?: boolean
|
|
294
298
|
applyQueryFilters?: (params: QueryFilteringState & { query: StorageQuery }) => Promise<{ query: QueryBuilderResult } | null | undefined>
|
|
295
299
|
}
|
|
296
300
|
|
|
@@ -306,6 +310,13 @@ export interface DataReadHelpers<GetResult> {
|
|
|
306
310
|
dataGetMinimal: DataMinimalReader
|
|
307
311
|
dataGet: (request: DataReadRequest & {
|
|
308
312
|
context: DataReadContext & { id: string | number }
|
|
313
|
+
// Apply the row filters in this read, when no minimal read preceded it.
|
|
314
|
+
scoped?: boolean
|
|
315
|
+
}) => Promise<GetResult>
|
|
316
|
+
// dataGet({ scoped: true }), for storages that support it.
|
|
317
|
+
dataGetScoped?: (request: DataReadRequest & {
|
|
318
|
+
runHooks: NonNullable<DataReadRequest['runHooks']>
|
|
319
|
+
context: DataReadContext & { id: string | number }
|
|
309
320
|
}) => Promise<GetResult>
|
|
310
321
|
dataQuery: (request: DataReadRequest & {
|
|
311
322
|
runHooks: NonNullable<DataReadRequest['runHooks']>
|
|
@@ -517,7 +528,7 @@ export interface CanonicalDataDependencies {
|
|
|
517
528
|
getScopeStorageAdapter(scopeName: string): StorageAdapter | null
|
|
518
529
|
getDescriptor(scopeName: string): CanonicalDescriptor
|
|
519
530
|
linkStore: {
|
|
520
|
-
invalidateDeletedLinkTargets(scopeName: string, context: DataIdentityContext): Promise<void>
|
|
531
|
+
invalidateDeletedLinkTargets(scopeName: string, context: DataIdentityContext, lockRow: () => Promise<void>): Promise<void>
|
|
521
532
|
deleteResourceLinks(request: { descriptor: CanonicalDescriptor, scopeName: string, id: string | number, db: StorageDatabase }): Promise<void>
|
|
522
533
|
}
|
|
523
534
|
buildIncludes(request: { parentResources: DataResource[], descriptor: CanonicalDescriptor, context: DataReadContext }): Promise<DataResource[]>
|
|
@@ -6,7 +6,10 @@ import { whereInIdentifiers } from '../querying/identifier-query.js'
|
|
|
6
6
|
import { applyDatabaseReadOptions, databaseIdentityExpression } from '../querying-writing/database-value-normalizers.js'
|
|
7
7
|
import { assertVersionedRelationshipTransaction, hasVersionedInverse, invalidateManyToManyVersions } from './resource-version.js'
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// Advances the versions of parents linked to a record about to be deleted.
|
|
10
|
+
// When there are such parents, `lockRow` first locks the record's row against
|
|
11
|
+
// link writers, and answers not found when it has gone.
|
|
12
|
+
export async function invalidateDeletedPivotTargets (api, scopeName, context, lockRow) {
|
|
10
13
|
const relationships = []
|
|
11
14
|
for (const [parentType, parent] of Object.entries(api.resources)) {
|
|
12
15
|
if (!parent.vars.schemaInfo?.versionField) continue
|
|
@@ -17,7 +20,7 @@ export async function invalidateDeletedPivotTargets (api, scopeName, context) {
|
|
|
17
20
|
}
|
|
18
21
|
if (!relationships.length) return
|
|
19
22
|
assertVersionedRelationshipTransaction(api, relationships[0], context.transaction)
|
|
20
|
-
await
|
|
23
|
+
await lockRow()
|
|
21
24
|
const id = api.knex.helpers.getStorageAdapter(scopeName).translateFilterValue('id', context.id)
|
|
22
25
|
for (const inverse of relationships) {
|
|
23
26
|
const pivot = getPivotStorage(api, inverse)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { RestApiResourceError, RestApiValidationError } from '../../../../lib/rest-api-errors.js'
|
|
2
2
|
import { validateRelationshipDataCardinality } from '../querying-writing/relationship-contracts.js'
|
|
3
|
-
import {
|
|
3
|
+
import { whereInIdentifiers } from '../querying/identifier-query.js'
|
|
4
4
|
import { applyDatabaseReadOptions, databaseIdentityExpression } from '../querying-writing/database-value-normalizers.js'
|
|
5
5
|
|
|
6
6
|
export const lockRelationshipParent = async ({ context, helpers, scopeName }) => {
|
|
@@ -40,47 +40,43 @@ export const lockRelationshipTargets = async (api, transaction, identifiers, { e
|
|
|
40
40
|
const resolved = []
|
|
41
41
|
const resources = new Map()
|
|
42
42
|
for (const { type, id } of identifiers) {
|
|
43
|
-
if (!resources.has(type)) resources.set(type, { adapter: api.knex.helpers.getStorageAdapter(type), seen: new Set(), resolved: new Set() })
|
|
44
|
-
const { adapter, seen } = resources.get(type)
|
|
43
|
+
if (!resources.has(type)) resources.set(type, { adapter: api.knex.helpers.getStorageAdapter(type), seen: new Set(), resolved: new Set(), group: [], found: new Set() })
|
|
44
|
+
const { adapter, seen, group } = resources.get(type)
|
|
45
45
|
const value = adapter.translateFilterValue('id', id)
|
|
46
46
|
if (seen.has(String(value))) continue
|
|
47
47
|
seen.add(String(value))
|
|
48
|
-
|
|
48
|
+
const target = { type, id, value, adapter }
|
|
49
|
+
targets.push(target)
|
|
50
|
+
group.push(target)
|
|
49
51
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
.where(adapter.getIdColumn(), value), isExclusive(type)).first({ [adapter.getIdColumn()]: databaseIdentityExpression(transaction, adapter.getIdColumn()) }))
|
|
72
|
-
if (!record) {
|
|
73
|
-
throw new RestApiResourceError(`Related ${type} with id ${id} not found`, {
|
|
74
|
-
subtype: 'not_found', resourceType: type, resourceId: id
|
|
75
|
-
})
|
|
76
|
-
}
|
|
77
|
-
storedId = String(record[adapter.getIdColumn()])
|
|
78
|
-
}
|
|
79
|
-
const identities = resources.get(type).resolved
|
|
80
|
-
if (!identities.has(storedId)) {
|
|
81
|
-
identities.add(storedId)
|
|
82
|
-
resolved.push({ type, id })
|
|
52
|
+
// One statement per target type at any count; whereInIdentifiers binds a long
|
|
53
|
+
// id list as one parameter where the database limits parameters. Types lock
|
|
54
|
+
// in name order and each statement in id order, so two writers naming the same
|
|
55
|
+
// targets in different orders take their locks in the same order.
|
|
56
|
+
for (const [type, { adapter, group, found }] of [...resources].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)) {
|
|
57
|
+
const idColumn = adapter.getIdColumn()
|
|
58
|
+
const rows = await applyDatabaseReadOptions(lockTargetRows(adapter.buildBaseQuery({ transaction })
|
|
59
|
+
.modify(whereInIdentifiers, idColumn, group.map(target => target.value))
|
|
60
|
+
.orderBy(`${adapter.getTableName()}.${idColumn}`), isExclusive(type)).select({ [idColumn]: databaseIdentityExpression(transaction, idColumn) }))
|
|
61
|
+
for (const row of rows) found.add(String(row[idColumn]))
|
|
62
|
+
}
|
|
63
|
+
for (const { type, id, value, adapter } of targets) {
|
|
64
|
+
let storedId = String(value)
|
|
65
|
+
// Let the database resolve alternate spellings under its column collation.
|
|
66
|
+
if (!resources.get(type).found.has(storedId)) {
|
|
67
|
+
const record = await applyDatabaseReadOptions(lockTargetRows(adapter.buildBaseQuery({ transaction })
|
|
68
|
+
.where(adapter.getIdColumn(), value), isExclusive(type)).first({ [adapter.getIdColumn()]: databaseIdentityExpression(transaction, adapter.getIdColumn()) }))
|
|
69
|
+
if (!record) {
|
|
70
|
+
throw new RestApiResourceError(`Related ${type} with id ${id} not found`, {
|
|
71
|
+
subtype: 'not_found', resourceType: type, resourceId: id
|
|
72
|
+
})
|
|
83
73
|
}
|
|
74
|
+
storedId = String(record[adapter.getIdColumn()])
|
|
75
|
+
}
|
|
76
|
+
const identities = resources.get(type).resolved
|
|
77
|
+
if (!identities.has(storedId)) {
|
|
78
|
+
identities.add(storedId)
|
|
79
|
+
resolved.push({ type, id })
|
|
84
80
|
}
|
|
85
81
|
}
|
|
86
82
|
// Return each database identity once, retaining its first submitted spelling.
|