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
|
@@ -9,6 +9,7 @@ import { toJsonApiRecordWithBelongsTo } from '../querying-writing/knex-json-api-
|
|
|
9
9
|
import { processIncludes } from '../querying/knex-process-includes.js'
|
|
10
10
|
import { loadRelationshipIdentifiers } from '../querying/relationship-identifiers.js'
|
|
11
11
|
import { applyQueryConstraint } 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,21 @@ 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
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Every update advances the resource's lock version by one, whatever the input says.
|
|
27
|
+
* @param {StorageRow} row
|
|
28
|
+
* @param {{ schemaInfo: { lockVersionField?: string } }} context
|
|
29
|
+
* @param {import('./storage-types.js').StorageAdapter} storageAdapter
|
|
30
|
+
* @param {import('./storage-types.js').StorageDatabase} db
|
|
31
|
+
*/
|
|
32
|
+
function advanceLockVersion (row, context, storageAdapter, db) {
|
|
33
|
+
const field = context.schemaInfo.lockVersionField
|
|
34
|
+
if (!field) return
|
|
35
|
+
const column = storageAdapter.translateColumn(field)
|
|
36
|
+
row[column] = db.raw('?? + 1', [column])
|
|
37
|
+
}
|
|
22
38
|
|
|
23
39
|
/** @param {OrdinaryDataDependencies} dependencies */
|
|
24
40
|
export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeStorageAdapter: findStorageAdapter }) {
|
|
@@ -178,7 +194,7 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
178
194
|
}
|
|
179
195
|
|
|
180
196
|
/** @type {OrdinaryDataReadHelpers['dataGet']} */
|
|
181
|
-
const dataGet = async ({ scopeName, context }) => {
|
|
197
|
+
const dataGet = async ({ scopeName, context, runHooks, scoped = false }) => {
|
|
182
198
|
const storageAdapter = getScopeStorageAdapter(scopeName)
|
|
183
199
|
context.storageAdapter = storageAdapter
|
|
184
200
|
const scope = getScope(scopeName)
|
|
@@ -193,15 +209,32 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
193
209
|
// Enrichment removes fetched dependencies that the caller did not request.
|
|
194
210
|
context.computedDependencies = fieldSelectionInfo.computedDependencies
|
|
195
211
|
|
|
196
|
-
// The preceding minimal read has already applied permission and row filters
|
|
197
|
-
|
|
212
|
+
// The preceding minimal read has already applied permission and row filters,
|
|
213
|
+
// unless the read is `scoped`: then it applies them itself, as the only read.
|
|
214
|
+
let query = db(tableName).where(scoped ? `${tableName}.${idProperty}` : idProperty, id)
|
|
215
|
+
if (scoped) {
|
|
216
|
+
const filterHooks = runHooks
|
|
217
|
+
if (!filterHooks) throw new Error('A scoped read needs runHooks')
|
|
218
|
+
query = (await withQueryFilteringContext(context, {
|
|
219
|
+
query,
|
|
220
|
+
filters: context.queryParams?.filters,
|
|
221
|
+
schemaInfo: context.schemaInfo,
|
|
222
|
+
scopeName,
|
|
223
|
+
tableName,
|
|
224
|
+
db,
|
|
225
|
+
queryPurpose: 'single',
|
|
226
|
+
adapter: storageAdapter,
|
|
227
|
+
storageAdapter
|
|
228
|
+
}, () => filterHooks('knexQueryFiltering'))).query
|
|
229
|
+
}
|
|
198
230
|
|
|
199
231
|
const selectionState = await applyFieldSelectionToQuery({
|
|
200
232
|
query,
|
|
201
233
|
scope,
|
|
202
234
|
fieldSelectionInfo,
|
|
203
235
|
tableName,
|
|
204
|
-
|
|
236
|
+
// A row policy may join tables, so a scoped read names its columns' table.
|
|
237
|
+
useTablePrefix: scoped,
|
|
205
238
|
storageAdapter,
|
|
206
239
|
db,
|
|
207
240
|
context,
|
|
@@ -236,7 +269,7 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
236
269
|
return buildJsonApiResponse(scope, records, included, true, scopeName, context)
|
|
237
270
|
}
|
|
238
271
|
|
|
239
|
-
// Supplying ids returns minimal records for
|
|
272
|
+
// Supplying ids returns minimal records for all of them, in one statement.
|
|
240
273
|
/** @overload @param {DataMinimalRequest & { ids: Array<string | number> }} request @returns {Promise<DataResource[]>} */
|
|
241
274
|
/** @overload @param {DataMinimalRequest & { ids?: undefined, context: DataReadContext & {id: string | number} }} request @returns {Promise<DataResource | null>} */
|
|
242
275
|
/** @param {DataMinimalRequest & { ids?: Array<string | number> }} request */
|
|
@@ -259,11 +292,12 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
259
292
|
|
|
260
293
|
log.debug(`[Knex] GET_MINIMAL ${tableName}/${id}`)
|
|
261
294
|
|
|
262
|
-
|
|
295
|
+
const table = db(tableName)
|
|
296
|
+
// whereInIdentifiers returns the builder it was given, which modify's types lose.
|
|
263
297
|
let query = ids === undefined
|
|
264
|
-
?
|
|
265
|
-
:
|
|
266
|
-
|
|
298
|
+
? table.where(idProperty, id)
|
|
299
|
+
: /** @type {typeof table} */ (table.modify(whereInIdentifiers, idProperty, ids.map(value => storageAdapter.translateFilterValue('id', value))))
|
|
300
|
+
.distinct(`${tableName}.${idProperty}`)
|
|
267
301
|
|
|
268
302
|
if (typeof applyQueryFilters === 'function') {
|
|
269
303
|
const scopedQueryState = await applyQueryFilters({
|
|
@@ -299,7 +333,7 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
299
333
|
return [field === 'id' ? 'id' : column, databaseIdentityExpression(db, `${tableName}.${column}`)]
|
|
300
334
|
}))
|
|
301
335
|
if (ids !== undefined) {
|
|
302
|
-
// Filtering owns predicates; the
|
|
336
|
+
// Filtering owns predicates; the id list owns its identity projection.
|
|
303
337
|
query.clearSelect().distinct(`${tableName}.${idProperty}`)
|
|
304
338
|
// Deduplicate policy joins by ID, without comparing JSON or other row values.
|
|
305
339
|
/** @type {StorageRow[]} */
|
|
@@ -433,12 +467,10 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
433
467
|
// Calculate pagination metadata with total
|
|
434
468
|
context.returnMeta.paginationMeta = calculatePaginationMeta(total, page, pageSize)
|
|
435
469
|
} else {
|
|
436
|
-
// Without count,
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
// No total, pageCount, or hasMore when counts are disabled
|
|
441
|
-
}
|
|
470
|
+
// Without a count, the page's one extra row answers hasMore.
|
|
471
|
+
const hasMore = records.length > pageSize
|
|
472
|
+
if (hasMore) records.pop()
|
|
473
|
+
context.returnMeta.paginationMeta = { page, pageSize, hasMore }
|
|
442
474
|
}
|
|
443
475
|
|
|
444
476
|
// Generate links
|
|
@@ -609,28 +641,11 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
609
641
|
return
|
|
610
642
|
}
|
|
611
643
|
|
|
612
|
-
const exists = await db(tableName)
|
|
613
|
-
.where(idProperty, id)
|
|
614
|
-
.first()
|
|
615
|
-
|
|
616
|
-
if (!exists) {
|
|
617
|
-
throw new RestApiResourceError(
|
|
618
|
-
'Resource not found',
|
|
619
|
-
{
|
|
620
|
-
subtype: ERROR_SUBTYPES.NOT_FOUND,
|
|
621
|
-
resourceType: scopeName,
|
|
622
|
-
resourceId: id
|
|
623
|
-
}
|
|
624
|
-
)
|
|
625
|
-
}
|
|
626
|
-
|
|
627
644
|
// Updates cannot replace the primary key.
|
|
628
645
|
delete finalAttributes[idProperty]
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
.update(finalAttributes)
|
|
633
|
-
}
|
|
646
|
+
advanceLockVersion(finalAttributes, context, storageAdapter, db)
|
|
647
|
+
const row = foundRow({ byId: () => db(tableName).where(`${tableName}.${idProperty}`, id), context, scopeName, storageAdapter, tableName })
|
|
648
|
+
await updateFoundRow(row, { db, scopeName, id, idColumn: `${tableName}.${idProperty}`, values: finalAttributes })
|
|
634
649
|
}
|
|
635
650
|
|
|
636
651
|
/** @type {OrdinaryDataWriteHelpers['dataPatch']} */
|
|
@@ -645,22 +660,6 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
645
660
|
|
|
646
661
|
log.debug(`[Knex] PATCH ${tableName}/${id}`)
|
|
647
662
|
|
|
648
|
-
// Check if record exists
|
|
649
|
-
const exists = await db(tableName)
|
|
650
|
-
.where(idProperty, id)
|
|
651
|
-
.first()
|
|
652
|
-
|
|
653
|
-
if (!exists) {
|
|
654
|
-
throw new RestApiResourceError(
|
|
655
|
-
'Resource not found',
|
|
656
|
-
{
|
|
657
|
-
subtype: ERROR_SUBTYPES.NOT_FOUND,
|
|
658
|
-
resourceType: scopeName,
|
|
659
|
-
resourceId: id
|
|
660
|
-
}
|
|
661
|
-
)
|
|
662
|
-
}
|
|
663
|
-
|
|
664
663
|
// Core has already merged relationship values and applied setters.
|
|
665
664
|
const attributes = inputRecord.data.attributes || {}
|
|
666
665
|
|
|
@@ -674,13 +673,10 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
674
673
|
|
|
675
674
|
// Remove the idProperty from attributes to prevent updating the primary key
|
|
676
675
|
delete finalAttributes[idProperty]
|
|
676
|
+
advanceLockVersion(finalAttributes, context, storageAdapter, db)
|
|
677
677
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
await db(tableName)
|
|
681
|
-
.where(idProperty, id)
|
|
682
|
-
.update(finalAttributes)
|
|
683
|
-
}
|
|
678
|
+
const row = foundRow({ byId: () => db(tableName).where(`${tableName}.${idProperty}`, id), context, scopeName, storageAdapter, tableName })
|
|
679
|
+
await updateFoundRow(row, { db, scopeName, id, idColumn: `${tableName}.${idProperty}`, values: finalAttributes })
|
|
684
680
|
}
|
|
685
681
|
|
|
686
682
|
/** @type {OrdinaryDataWriteHelpers['dataDelete']} */
|
|
@@ -695,36 +691,24 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
695
691
|
|
|
696
692
|
log.debug(`[Knex] DELETE ${tableName}/${id}`)
|
|
697
693
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
if (!exists) {
|
|
704
|
-
throw new RestApiResourceError(
|
|
705
|
-
'Resource not found',
|
|
706
|
-
{
|
|
707
|
-
subtype: ERROR_SUBTYPES.NOT_FOUND,
|
|
708
|
-
resourceType: scopeName,
|
|
709
|
-
resourceId: id
|
|
710
|
-
}
|
|
711
|
-
)
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
// Delete the record
|
|
715
|
-
await invalidateDeletedPivotTargets(api, scopeName, context)
|
|
716
|
-
await db(tableName)
|
|
717
|
-
.where(idProperty, id)
|
|
718
|
-
.delete()
|
|
694
|
+
const row = foundRow({ byId: () => db(tableName).where(`${tableName}.${idProperty}`, id), context, scopeName, storageAdapter, tableName })
|
|
695
|
+
// Before the DELETE: an ON DELETE CASCADE would remove the links that tell which parents to advance.
|
|
696
|
+
await invalidateDeletedPivotTargets(api, scopeName, context, () => lockFoundRow(row, { scopeName, id, idColumn: `${tableName}.${idProperty}` }))
|
|
697
|
+
await deleteFoundRow(row, { scopeName, id })
|
|
719
698
|
await deletePivotReferences(api, scopeName, context)
|
|
720
699
|
|
|
721
700
|
return { success: true }
|
|
722
701
|
}
|
|
723
702
|
|
|
703
|
+
// A get with no reader of the minimal record reads once, scoped.
|
|
704
|
+
/** @type {NonNullable<OrdinaryDataReadHelpers['dataGetScoped']>} */
|
|
705
|
+
const dataGetScoped = ({ scopeName, context, runHooks }) => dataGet({ scopeName, context, runHooks, scoped: true })
|
|
706
|
+
|
|
724
707
|
return {
|
|
725
708
|
dataRelatedIdsQuery,
|
|
726
709
|
dataExists,
|
|
727
710
|
dataGet,
|
|
711
|
+
dataGetScoped,
|
|
728
712
|
dataQuery,
|
|
729
713
|
dataAggregate,
|
|
730
714
|
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,
|
|
@@ -122,6 +122,8 @@ export interface StorageSchemaInfo {
|
|
|
122
122
|
storage?: StorageConfig
|
|
123
123
|
storageInfo?: StorageInfo
|
|
124
124
|
descriptor?: CanonicalDescriptor
|
|
125
|
+
// A stored integer every library update advances by one.
|
|
126
|
+
lockVersionField?: string
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
export interface ResourceConversionSchema extends StorageSchemaInfo {
|
|
@@ -304,6 +306,13 @@ export interface DataReadHelpers<GetResult> {
|
|
|
304
306
|
dataGetMinimal: DataMinimalReader
|
|
305
307
|
dataGet: (request: DataReadRequest & {
|
|
306
308
|
context: DataReadContext & { id: string | number }
|
|
309
|
+
// Apply the row filters in this read, when no minimal read preceded it.
|
|
310
|
+
scoped?: boolean
|
|
311
|
+
}) => Promise<GetResult>
|
|
312
|
+
// dataGet({ scoped: true }), for storages that support it.
|
|
313
|
+
dataGetScoped?: (request: DataReadRequest & {
|
|
314
|
+
runHooks: NonNullable<DataReadRequest['runHooks']>
|
|
315
|
+
context: DataReadContext & { id: string | number }
|
|
307
316
|
}) => Promise<GetResult>
|
|
308
317
|
dataQuery: (request: DataReadRequest & {
|
|
309
318
|
runHooks: NonNullable<DataReadRequest['runHooks']>
|
|
@@ -324,8 +333,17 @@ export interface DataReadHelpers<GetResult> {
|
|
|
324
333
|
// Compiled by compileAggregateRequest: outputs carry SQL aliases.
|
|
325
334
|
export interface AggregatePlan {
|
|
326
335
|
scopeName: string
|
|
327
|
-
|
|
328
|
-
|
|
336
|
+
// Buckets carry their period and zone; their SQL is written per dialect.
|
|
337
|
+
groups: Array<{
|
|
338
|
+
name: string; field: string; definition: StorageFieldDefinition; alias: string
|
|
339
|
+
bucket?: 'day' | 'week' | 'month' | 'year'
|
|
340
|
+
timezone?: { kind: 'utc' } | { kind: 'offset'; minutes: number } | { kind: 'named'; name: string }
|
|
341
|
+
}>
|
|
342
|
+
// where: the conditions of a conditional measure, ANDed inside a CASE.
|
|
343
|
+
measures: Array<{
|
|
344
|
+
name: string; fn: 'count' | 'countDistinct' | 'sum' | 'avg' | 'min' | 'max'; field: string; definition: StorageFieldDefinition | null; alias: string
|
|
345
|
+
where: Array<{ field: string; definition: StorageFieldDefinition; operator: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'notIn'; value: unknown }> | null
|
|
346
|
+
}>
|
|
329
347
|
sort: Array<{ alias: string; direction: 'asc' | 'desc' }>
|
|
330
348
|
page: { size: number; number: number } | null
|
|
331
349
|
}
|
|
@@ -506,7 +524,7 @@ export interface CanonicalDataDependencies {
|
|
|
506
524
|
getScopeStorageAdapter(scopeName: string): StorageAdapter | null
|
|
507
525
|
getDescriptor(scopeName: string): CanonicalDescriptor
|
|
508
526
|
linkStore: {
|
|
509
|
-
invalidateDeletedLinkTargets(scopeName: string, context: DataIdentityContext): Promise<void>
|
|
527
|
+
invalidateDeletedLinkTargets(scopeName: string, context: DataIdentityContext, lockRow: () => Promise<void>): Promise<void>
|
|
510
528
|
deleteResourceLinks(request: { descriptor: CanonicalDescriptor, scopeName: string, id: string | number, db: StorageDatabase }): Promise<void>
|
|
511
529
|
}
|
|
512
530
|
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)
|
|
@@ -26,7 +29,8 @@ export async function invalidateDeletedPivotTargets (api, scopeName, context) {
|
|
|
26
29
|
}
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
|
|
32
|
+
// Removes the pivot rows of deleted records: context.id, or every id in `ids`.
|
|
33
|
+
export async function deletePivotReferences (api, scopeName, context, ids = [context.id]) {
|
|
30
34
|
const targets = new Map()
|
|
31
35
|
for (const [ownerType, owner] of Object.entries(api.resources)) {
|
|
32
36
|
for (const relationship of Object.values(owner.vars.schemaInfo?.schemaRelationships || {})) {
|
|
@@ -37,9 +41,10 @@ export async function deletePivotReferences (api, scopeName, context) {
|
|
|
37
41
|
}
|
|
38
42
|
}
|
|
39
43
|
}
|
|
40
|
-
const
|
|
44
|
+
const adapter = api.knex.helpers.getStorageAdapter(scopeName)
|
|
45
|
+
const values = ids.map(id => adapter.translateFilterValue('id', id))
|
|
41
46
|
const db = context.transaction || context.db || api.knex.instance
|
|
42
|
-
for (const { tableName, column } of targets.values()) await db(tableName).
|
|
47
|
+
for (const { tableName, column } of targets.values()) await db(tableName).whereIn(column, values).delete()
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
export async function invalidateRemovedPivotVersions (api, relDef, query, transaction) {
|
|
@@ -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.
|
|
@@ -312,6 +312,9 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
312
312
|
|
|
313
313
|
addHook('resource:added', 'anyapi-register-resource', {}, async ({ context }) => {
|
|
314
314
|
const { scopeName, scopeOptions = {} } = context
|
|
315
|
+
if (scopeOptions.lockVersionField !== undefined) {
|
|
316
|
+
throw new Error(`lockVersionField in '${scopeName}' needs table storage; AnyAPI resources do not support it`)
|
|
317
|
+
}
|
|
315
318
|
const scope = api.resources[scopeName]
|
|
316
319
|
const declaration = snapshotResourceConfiguration(scopeOptions)
|
|
317
320
|
declarations.set(scopeName, declaration)
|