json-rest-api 3.0.0-rc.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +195 -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 +47 -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 +8 -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 +47 -5
- 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 +139 -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
|
@@ -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,7 @@ 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'
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* Every update advances the resource's lock version by one, whatever the input says.
|
|
@@ -192,7 +194,7 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
192
194
|
}
|
|
193
195
|
|
|
194
196
|
/** @type {OrdinaryDataReadHelpers['dataGet']} */
|
|
195
|
-
const dataGet = async ({ scopeName, context }) => {
|
|
197
|
+
const dataGet = async ({ scopeName, context, runHooks, scoped = false }) => {
|
|
196
198
|
const storageAdapter = getScopeStorageAdapter(scopeName)
|
|
197
199
|
context.storageAdapter = storageAdapter
|
|
198
200
|
const scope = getScope(scopeName)
|
|
@@ -207,15 +209,32 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
207
209
|
// Enrichment removes fetched dependencies that the caller did not request.
|
|
208
210
|
context.computedDependencies = fieldSelectionInfo.computedDependencies
|
|
209
211
|
|
|
210
|
-
// The preceding minimal read has already applied permission and row filters
|
|
211
|
-
|
|
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
|
+
}
|
|
212
230
|
|
|
213
231
|
const selectionState = await applyFieldSelectionToQuery({
|
|
214
232
|
query,
|
|
215
233
|
scope,
|
|
216
234
|
fieldSelectionInfo,
|
|
217
235
|
tableName,
|
|
218
|
-
|
|
236
|
+
// A row policy may join tables, so a scoped read names its columns' table.
|
|
237
|
+
useTablePrefix: scoped,
|
|
219
238
|
storageAdapter,
|
|
220
239
|
db,
|
|
221
240
|
context,
|
|
@@ -250,7 +269,7 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
250
269
|
return buildJsonApiResponse(scope, records, included, true, scopeName, context)
|
|
251
270
|
}
|
|
252
271
|
|
|
253
|
-
// Supplying ids returns minimal records for
|
|
272
|
+
// Supplying ids returns minimal records for all of them, in one statement.
|
|
254
273
|
/** @overload @param {DataMinimalRequest & { ids: Array<string | number> }} request @returns {Promise<DataResource[]>} */
|
|
255
274
|
/** @overload @param {DataMinimalRequest & { ids?: undefined, context: DataReadContext & {id: string | number} }} request @returns {Promise<DataResource | null>} */
|
|
256
275
|
/** @param {DataMinimalRequest & { ids?: Array<string | number> }} request */
|
|
@@ -273,11 +292,12 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
273
292
|
|
|
274
293
|
log.debug(`[Knex] GET_MINIMAL ${tableName}/${id}`)
|
|
275
294
|
|
|
276
|
-
|
|
295
|
+
const table = db(tableName)
|
|
296
|
+
// whereInIdentifiers returns the builder it was given, which modify's types lose.
|
|
277
297
|
let query = ids === undefined
|
|
278
|
-
?
|
|
279
|
-
:
|
|
280
|
-
|
|
298
|
+
? table.where(idProperty, id)
|
|
299
|
+
: /** @type {typeof table} */ (table.modify(whereInIdentifiers, idProperty, ids.map(value => storageAdapter.translateFilterValue('id', value))))
|
|
300
|
+
.distinct(`${tableName}.${idProperty}`)
|
|
281
301
|
|
|
282
302
|
if (typeof applyQueryFilters === 'function') {
|
|
283
303
|
const scopedQueryState = await applyQueryFilters({
|
|
@@ -313,7 +333,7 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
313
333
|
return [field === 'id' ? 'id' : column, databaseIdentityExpression(db, `${tableName}.${column}`)]
|
|
314
334
|
}))
|
|
315
335
|
if (ids !== undefined) {
|
|
316
|
-
// Filtering owns predicates; the
|
|
336
|
+
// Filtering owns predicates; the id list owns its identity projection.
|
|
317
337
|
query.clearSelect().distinct(`${tableName}.${idProperty}`)
|
|
318
338
|
// Deduplicate policy joins by ID, without comparing JSON or other row values.
|
|
319
339
|
/** @type {StorageRow[]} */
|
|
@@ -447,12 +467,10 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
447
467
|
// Calculate pagination metadata with total
|
|
448
468
|
context.returnMeta.paginationMeta = calculatePaginationMeta(total, page, pageSize)
|
|
449
469
|
} else {
|
|
450
|
-
// Without count,
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
// No total, pageCount, or hasMore when counts are disabled
|
|
455
|
-
}
|
|
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 }
|
|
456
474
|
}
|
|
457
475
|
|
|
458
476
|
// Generate links
|
|
@@ -623,29 +641,11 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
623
641
|
return
|
|
624
642
|
}
|
|
625
643
|
|
|
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
644
|
// Updates cannot replace the primary key.
|
|
642
645
|
delete finalAttributes[idProperty]
|
|
643
646
|
advanceLockVersion(finalAttributes, context, storageAdapter, db)
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
.where(idProperty, id)
|
|
647
|
-
.update(finalAttributes)
|
|
648
|
-
}
|
|
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 })
|
|
649
649
|
}
|
|
650
650
|
|
|
651
651
|
/** @type {OrdinaryDataWriteHelpers['dataPatch']} */
|
|
@@ -660,22 +660,6 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
660
660
|
|
|
661
661
|
log.debug(`[Knex] PATCH ${tableName}/${id}`)
|
|
662
662
|
|
|
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
663
|
// Core has already merged relationship values and applied setters.
|
|
680
664
|
const attributes = inputRecord.data.attributes || {}
|
|
681
665
|
|
|
@@ -691,12 +675,8 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
691
675
|
delete finalAttributes[idProperty]
|
|
692
676
|
advanceLockVersion(finalAttributes, context, storageAdapter, db)
|
|
693
677
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
await db(tableName)
|
|
697
|
-
.where(idProperty, id)
|
|
698
|
-
.update(finalAttributes)
|
|
699
|
-
}
|
|
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 })
|
|
700
680
|
}
|
|
701
681
|
|
|
702
682
|
/** @type {OrdinaryDataWriteHelpers['dataDelete']} */
|
|
@@ -711,36 +691,24 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
711
691
|
|
|
712
692
|
log.debug(`[Knex] DELETE ${tableName}/${id}`)
|
|
713
693
|
|
|
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()
|
|
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 })
|
|
735
698
|
await deletePivotReferences(api, scopeName, context)
|
|
736
699
|
|
|
737
700
|
return { success: true }
|
|
738
701
|
}
|
|
739
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
|
+
|
|
740
707
|
return {
|
|
741
708
|
dataRelatedIdsQuery,
|
|
742
709
|
dataExists,
|
|
743
710
|
dataGet,
|
|
711
|
+
dataGetScoped,
|
|
744
712
|
dataQuery,
|
|
745
713
|
dataAggregate,
|
|
746
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,
|
|
@@ -306,6 +306,13 @@ export interface DataReadHelpers<GetResult> {
|
|
|
306
306
|
dataGetMinimal: DataMinimalReader
|
|
307
307
|
dataGet: (request: DataReadRequest & {
|
|
308
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 }
|
|
309
316
|
}) => Promise<GetResult>
|
|
310
317
|
dataQuery: (request: DataReadRequest & {
|
|
311
318
|
runHooks: NonNullable<DataReadRequest['runHooks']>
|
|
@@ -517,7 +524,7 @@ export interface CanonicalDataDependencies {
|
|
|
517
524
|
getScopeStorageAdapter(scopeName: string): StorageAdapter | null
|
|
518
525
|
getDescriptor(scopeName: string): CanonicalDescriptor
|
|
519
526
|
linkStore: {
|
|
520
|
-
invalidateDeletedLinkTargets(scopeName: string, context: DataIdentityContext): Promise<void>
|
|
527
|
+
invalidateDeletedLinkTargets(scopeName: string, context: DataIdentityContext, lockRow: () => Promise<void>): Promise<void>
|
|
521
528
|
deleteResourceLinks(request: { descriptor: CanonicalDescriptor, scopeName: string, id: string | number, db: StorageDatabase }): Promise<void>
|
|
522
529
|
}
|
|
523
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)
|
|
@@ -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.
|
|
@@ -2,6 +2,7 @@ import { RestApiResourceError, RestApiValidationError } from '../../lib/rest-api
|
|
|
2
2
|
import { assertGenuineSystemFlag, isSystemContext } from '../../lib/system-context.js'
|
|
3
3
|
import { snapshotResourceConfiguration } from './lib/querying-writing/schema-helpers.js'
|
|
4
4
|
import { createStorageAdapterUtilities } from './lib/querying/storage-adapter-utils.js'
|
|
5
|
+
import { ANY_OWNER, recordWriteCondition } from './lib/querying/query-constraint.js'
|
|
5
6
|
|
|
6
7
|
const PUBLIC_PRESET_NAME = 'public'
|
|
7
8
|
// Queries that write or lock the rows they select: shared rows are never theirs.
|
|
@@ -286,6 +287,13 @@ function refuseSharedCreate ({ filter, resolvedValue, scopeName }) {
|
|
|
286
287
|
})
|
|
287
288
|
}
|
|
288
289
|
|
|
290
|
+
// An optional owner that did not resolve stamps nothing; the row must still not end up shared.
|
|
291
|
+
function refuseSharedResult ({ compiledConfig, record, scopeName }) {
|
|
292
|
+
for (const filter of compiledConfig.filters) {
|
|
293
|
+
if (filter.shared && sharedFieldValue(record, filter) == null) refuseSharedCreate({ filter, resolvedValue: null, scopeName })
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
289
297
|
function sharedFieldValue (record, filter) {
|
|
290
298
|
if (filter.relationshipName) {
|
|
291
299
|
const linkage = record?.relationships?.[filter.relationshipName]?.data
|
|
@@ -324,6 +332,7 @@ async function enforceScopedInput ({ compiledConfig, context, scopeName, injectM
|
|
|
324
332
|
injectMissing
|
|
325
333
|
})
|
|
326
334
|
}
|
|
335
|
+
if (injectMissing) refuseSharedResult({ compiledConfig, record: context.inputRecord?.data, scopeName })
|
|
327
336
|
}
|
|
328
337
|
|
|
329
338
|
export const AutoFilterPlugin = {
|
|
@@ -386,9 +395,18 @@ export const AutoFilterPlugin = {
|
|
|
386
395
|
log
|
|
387
396
|
})
|
|
388
397
|
|
|
389
|
-
if (resolvedValue === undefined) continue
|
|
390
|
-
|
|
391
398
|
const columnRef = adapterUtils.translateColumn(scopeName, filter.field, tableName)
|
|
399
|
+
// Every scoped read records the owner a later write of its rows must
|
|
400
|
+
// still match, so a single write that reads first carries it too.
|
|
401
|
+
if (resolvedValue === undefined) {
|
|
402
|
+
// An optional owner that did not resolve: reads are not narrowed, but
|
|
403
|
+
// shared rows stay out of writes and locks.
|
|
404
|
+
if (filter.shared) {
|
|
405
|
+
if (writes) query.whereNotNull(columnRef)
|
|
406
|
+
recordWriteCondition(context, scopeName, filter.field, ANY_OWNER)
|
|
407
|
+
}
|
|
408
|
+
continue
|
|
409
|
+
}
|
|
392
410
|
const translatedValue = adapterUtils.translateFilterValue(scopeName, filter.field, resolvedValue)
|
|
393
411
|
|
|
394
412
|
if (filter.shared) {
|
|
@@ -396,15 +414,15 @@ export const AutoFilterPlugin = {
|
|
|
396
414
|
if (translatedValue === null) {
|
|
397
415
|
if (writes) query.whereRaw('1 = 0')
|
|
398
416
|
else query.whereNull(columnRef)
|
|
399
|
-
} else if (writes) {
|
|
400
|
-
query.where(columnRef, translatedValue)
|
|
401
417
|
} else {
|
|
402
|
-
|
|
418
|
+
if (writes) query.where(columnRef, translatedValue)
|
|
419
|
+
else query.where(builder => builder.where(columnRef, translatedValue).orWhereNull(columnRef))
|
|
420
|
+
recordWriteCondition(context, scopeName, filter.field, translatedValue)
|
|
403
421
|
}
|
|
404
|
-
} else if (translatedValue === null) {
|
|
405
|
-
query.whereNull(columnRef)
|
|
406
422
|
} else {
|
|
407
|
-
query.
|
|
423
|
+
if (translatedValue === null) query.whereNull(columnRef)
|
|
424
|
+
else query.where(columnRef, translatedValue)
|
|
425
|
+
recordWriteCondition(context, scopeName, filter.field, translatedValue)
|
|
408
426
|
}
|
|
409
427
|
}
|
|
410
428
|
})
|
|
@@ -468,6 +486,7 @@ export const AutoFilterPlugin = {
|
|
|
468
486
|
applyResolvedInputValue({ context: { inputRecord }, filter, resolvedValue, injectMissing: true })
|
|
469
487
|
}
|
|
470
488
|
}
|
|
489
|
+
for (const inputRecord of context.inputRecords) refuseSharedResult({ compiledConfig, record: inputRecord?.data, scopeName })
|
|
471
490
|
})
|
|
472
491
|
|
|
473
492
|
// Shared rows are readable by every owner but writable only in a system context.
|
|
@@ -486,6 +505,9 @@ export const AutoFilterPlugin = {
|
|
|
486
505
|
addHook('beforeDataCallPatch', 'autofilter-shared-row-patch', { batch: true }, refuseSharedRowWrite)
|
|
487
506
|
addHook('beforeDataCallPut', 'autofilter-shared-row-put', { batch: true }, refuseSharedRowWrite)
|
|
488
507
|
addHook('beforeDataCallDelete', 'autofilter-shared-row-delete', { batch: true }, refuseSharedRowWrite)
|
|
508
|
+
// Links of a shared row belong to every owner that sees it.
|
|
509
|
+
addHook('beforeDataCallPostRelationship', 'autofilter-shared-row-post-relationship', {}, refuseSharedRowWrite)
|
|
510
|
+
addHook('beforeDataCallDeleteRelationship', 'autofilter-shared-row-delete-relationship', {}, refuseSharedRowWrite)
|
|
489
511
|
|
|
490
512
|
api.autofilter = {
|
|
491
513
|
getConfig: () => ({
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
|
|
2
2
|
import { getEffectiveSortableFields } from '../lib/querying/query-field-sort-helpers.js'
|
|
3
3
|
import { compileAggregateRequest } from '../lib/querying/aggregate-query.js'
|
|
4
|
+
import { isSystemContext } from '../../../lib/system-context.js'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Group a filtered collection and compute measures for each group.
|
|
@@ -18,7 +19,8 @@ export default async function aggregateMethod ({ params, context, vars, helpers,
|
|
|
18
19
|
schemaInfo: context.schemaInfo,
|
|
19
20
|
scopeName,
|
|
20
21
|
defaultLimit: vars.queryDefaultLimit,
|
|
21
|
-
maxLimit: vars.queryMaxLimit
|
|
22
|
+
maxLimit: vars.queryMaxLimit,
|
|
23
|
+
system: isSystemContext(context)
|
|
22
24
|
})
|
|
23
25
|
const requestContracts = getRequestContracts({
|
|
24
26
|
scopeName,
|