json-rest-api 2.1.0 → 2.3.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/docs/API.md +80 -0
- package/docs/GUIDE/17-row-policies.md +1 -0
- package/docs/GUIDE/21-schema-and-migrations.md +99 -2
- package/docs/GUIDE/30-backend-capabilities.md +3 -1
- package/docs/GUIDE/33-migrating-to-v2.md +20 -0
- package/package.json +1 -1
- package/plugins/core/lib/anyapi/anyapi-registry.js +97 -42
- package/plugins/core/lib/anyapi/schema-utils.js +168 -53
- package/plugins/core/lib/anyapi/stored-definition.js +3 -0
- package/plugins/core/lib/dbIntrospection.js +28 -0
- package/plugins/core/lib/querying/aggregate-query.js +170 -0
- package/plugins/core/lib/querying/knex-query-helpers.js +14 -4
- package/plugins/core/lib/querying-writing/compile-schemas.js +17 -0
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +16 -0
- package/plugins/core/lib/querying-writing/default-data-helpers.js +4 -0
- package/plugins/core/lib/querying-writing/field-types.js +43 -0
- package/plugins/core/lib/querying-writing/request-contracts.js +1 -1
- package/plugins/core/lib/storage/canonical-data-helpers.js +29 -0
- package/plugins/core/lib/storage/canonical-storage-mapping.js +11 -0
- package/plugins/core/lib/storage/ordinary-data-helpers.js +25 -0
- package/plugins/core/lib/storage/storage-types.d.ts +23 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +140 -31
- package/plugins/core/rest-api-plugin-methods/aggregate.js +41 -0
- package/plugins/core/rest-api-plugin-methods/common.js +16 -1
- package/plugins/core/rest-api-plugin.js +2 -0
- package/types/anyapi.d.ts +13 -2
- package/types/plugin-options.d.ts +2 -1
- package/types/resource-methods.d.ts +19 -0
- package/types/resource-schema.d.ts +5 -3
|
@@ -24,6 +24,10 @@ export const defaultDataHelpers = {
|
|
|
24
24
|
throw new Error('No storage implementation for query. Install a storage plugin.')
|
|
25
25
|
},
|
|
26
26
|
|
|
27
|
+
dataAggregate: async function (request) {
|
|
28
|
+
throw new Error('No storage implementation for aggregate. Install a storage plugin.')
|
|
29
|
+
},
|
|
30
|
+
|
|
27
31
|
dataPost: async function (request) {
|
|
28
32
|
throw new Error('No storage implementation for post. Install a storage plugin.')
|
|
29
33
|
},
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { addType } from 'json-rest-schema'
|
|
2
|
+
|
|
3
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
4
|
+
const UUID_PATTERN = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/
|
|
5
|
+
const TEXT_SEARCH_OPERATORS = new Set(['like', 'contains', 'startswith', 'endswith'])
|
|
6
|
+
|
|
7
|
+
// Partial-match filters search fragments of a value, not whole values.
|
|
8
|
+
const isTextSearch = definition => TEXT_SEARCH_OPERATORS.has(String(definition?.filterOperator).toLowerCase())
|
|
9
|
+
|
|
10
|
+
const formattedStringType = (pattern, format) => {
|
|
11
|
+
const type = (context) => {
|
|
12
|
+
if (typeof context.value !== 'string') context.throwTypeError()
|
|
13
|
+
const value = context.definition.noTrim ? context.value : context.value.trim()
|
|
14
|
+
if (!isTextSearch(context.definition) && !pattern.test(value)) context.throwTypeError()
|
|
15
|
+
return value
|
|
16
|
+
}
|
|
17
|
+
type.toJsonSchema = ({ definition }) => isTextSearch(definition) ? { type: 'string' } : { type: 'string', format, pattern: pattern.source }
|
|
18
|
+
return type
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let installed = false
|
|
22
|
+
|
|
23
|
+
/** Register the string types json-rest-schema lacks; both storages keep them in string columns. */
|
|
24
|
+
export function installFieldTypes () {
|
|
25
|
+
if (installed) return
|
|
26
|
+
installed = true
|
|
27
|
+
addType('email', formattedStringType(EMAIL_PATTERN, 'email'))
|
|
28
|
+
addType('uuid', formattedStringType(UUID_PATTERN, 'uuid'))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reject field types no casting function exists for, at registration rather than on the first write.
|
|
33
|
+
* @param {{ structure: Record<string, any>, types: Record<string, unknown> }} schema
|
|
34
|
+
* @param {string} label
|
|
35
|
+
*/
|
|
36
|
+
export function assertKnownFieldTypes (schema, label) {
|
|
37
|
+
for (const [fieldName, definition] of Object.entries(schema.structure)) {
|
|
38
|
+
const type = definition?.type
|
|
39
|
+
if (typeof type === 'string' && !Object.hasOwn(schema.types, type)) {
|
|
40
|
+
throw new Error(`Field '${fieldName}' in ${label} has unknown type '${type}'`)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -354,7 +354,7 @@ function buildWritableAttributesStructure (schemaInfo = {}) {
|
|
|
354
354
|
if (!fieldDef) continue
|
|
355
355
|
if (fieldName === idProperty) continue
|
|
356
356
|
if (excludedFieldNames.has(fieldName)) continue
|
|
357
|
-
if (fieldDef.computed === true) continue
|
|
357
|
+
if (fieldDef.computed === true || fieldDef.retired === true) continue
|
|
358
358
|
if (fieldDef.type === undefined) continue
|
|
359
359
|
attributesStructure[fieldName] = { ...fieldDef }
|
|
360
360
|
}
|
|
@@ -5,6 +5,7 @@ import { applySortDescriptorOrder, buildEffectiveSortList, parseSortEntry, resol
|
|
|
5
5
|
import { normalizeId, resolveFieldInfo } from '../anyapi/utils/descriptor-helpers.js'
|
|
6
6
|
import { decorateResourceLinks, getAllowedQueryFieldNames } from '../anyapi/canonical-relationship-reader.js'
|
|
7
7
|
import { applyQueryConstraint } from '../querying/query-constraint.js'
|
|
8
|
+
import { runAggregateQuery } from '../querying/aggregate-query.js'
|
|
8
9
|
import { calculatePaginationMeta, generatePaginationLinks, generateCursorPaginationLinks, buildCursorMeta, applyPaginationToQuery } from '../querying/knex-pagination-helpers.js'
|
|
9
10
|
import { getUrlPrefix, buildJsonApiLink } from '../querying/url-helpers.js'
|
|
10
11
|
import { applyFieldSelectionToQuery, buildFieldSelection } from '../querying-writing/knex-field-helpers.js'
|
|
@@ -670,6 +671,33 @@ export function createCanonicalDataHelpers ({
|
|
|
670
671
|
return response
|
|
671
672
|
}
|
|
672
673
|
|
|
674
|
+
/** @type {CanonicalDataReadHelpers['dataAggregate']} */
|
|
675
|
+
const dataAggregate = async ({ scopeName, context, runHooks, plan }) => {
|
|
676
|
+
const storageAdapter = getScopeStorageAdapter(scopeName)
|
|
677
|
+
if (!storageAdapter) throw new Error(`Storage adapter for resource '${scopeName}' not found`)
|
|
678
|
+
context.storageAdapter = storageAdapter
|
|
679
|
+
const descriptor = getDescriptor(scopeName)
|
|
680
|
+
const db = context.db || context.transaction || api.knex.instance
|
|
681
|
+
const tableAlias = context.schemaInfo.tableName || descriptor.resource
|
|
682
|
+
|
|
683
|
+
const filteredState = await withQueryFilteringContext(context, {
|
|
684
|
+
query: storageAdapter.buildBaseQuery({ transaction: db, tableAlias }),
|
|
685
|
+
filters: context.queryParams.filters,
|
|
686
|
+
schemaInfo: context.schemaInfo,
|
|
687
|
+
scopeName,
|
|
688
|
+
tableName: tableAlias,
|
|
689
|
+
db,
|
|
690
|
+
queryPurpose: 'aggregate',
|
|
691
|
+
isAnyApi: true,
|
|
692
|
+
adapter: storageAdapter,
|
|
693
|
+
storageAdapter,
|
|
694
|
+
}, async () => {
|
|
695
|
+
await applyBuiltInAnyApiQueryFilters(context)
|
|
696
|
+
await runHooks('knexQueryFiltering')
|
|
697
|
+
})
|
|
698
|
+
return runAggregateQuery({ source: filteredState.query, sourceTable: tableAlias, storageAdapter, db, plan })
|
|
699
|
+
}
|
|
700
|
+
|
|
673
701
|
return {
|
|
674
702
|
dataExists,
|
|
675
703
|
dataPost,
|
|
@@ -678,6 +706,7 @@ export function createCanonicalDataHelpers ({
|
|
|
678
706
|
dataDelete,
|
|
679
707
|
dataGet,
|
|
680
708
|
dataQuery,
|
|
709
|
+
dataAggregate,
|
|
681
710
|
dataGetMinimal
|
|
682
711
|
}
|
|
683
712
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { serializeFieldValueForStorage } from './storage-mapping.js'
|
|
3
3
|
import { getPolymorphicLinkage } from '../querying-writing/relationship-contracts.js'
|
|
4
|
+
import { INDEXED_STRING_LENGTH } from '../anyapi/schema-utils.js'
|
|
5
|
+
import { RestApiValidationError } from '../../../../lib/rest-api-errors.js'
|
|
4
6
|
|
|
5
7
|
/** @import { CanonicalDescriptor, StorageRow, StorageSchemaInfo, StorageWriteOptions } from './storage-types.js' */
|
|
6
8
|
|
|
@@ -34,6 +36,15 @@ export const translateCanonicalAttributesForStorage = (attributes = {}, descript
|
|
|
34
36
|
textStorage: slot.slotType === 'string'
|
|
35
37
|
})
|
|
36
38
|
row[slot.slot] = definition?.type === 'id' && serialized != null ? String(serialized) : serialized
|
|
39
|
+
const stored = row[slot.slot]
|
|
40
|
+
// Databases count code points; one surrogate pair is one character.
|
|
41
|
+
if (slot.indexed && typeof stored === 'string' && stored.length > INDEXED_STRING_LENGTH && [...stored].length > INDEXED_STRING_LENGTH) {
|
|
42
|
+
const field = `data.attributes.${fieldName}`
|
|
43
|
+
throw new RestApiValidationError(`Field '${fieldName}' holds at most ${INDEXED_STRING_LENGTH} characters in its indexed slot.`, {
|
|
44
|
+
fields: [field],
|
|
45
|
+
violations: [{ field, rule: 'storage_range', message: `Indexed string slots hold at most ${INDEXED_STRING_LENGTH} characters.` }]
|
|
46
|
+
})
|
|
47
|
+
}
|
|
37
48
|
}
|
|
38
49
|
|
|
39
50
|
if (slot.slotType === 'belongsTo') {
|
|
@@ -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 { runAggregateQuery } from '../querying/aggregate-query.js'
|
|
12
13
|
import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
|
|
13
14
|
import { getOperationDiagnosticContext } from '../../../../lib/error-context.js'
|
|
14
15
|
import { applyInsertReturning } from '../querying-writing/database-capabilities.js'
|
|
@@ -505,6 +506,29 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
505
506
|
return buildJsonApiResponse(scope, records, included, false, scopeName, context)
|
|
506
507
|
}
|
|
507
508
|
|
|
509
|
+
/** @type {OrdinaryDataReadHelpers['dataAggregate']} */
|
|
510
|
+
const dataAggregate = async ({ scopeName, context, runHooks, plan }) => {
|
|
511
|
+
const storageAdapter = getScopeStorageAdapter(scopeName)
|
|
512
|
+
context.storageAdapter = storageAdapter
|
|
513
|
+
const tableName = storageAdapter.getTableName()
|
|
514
|
+
const db = context.db || api.knex.instance
|
|
515
|
+
|
|
516
|
+
const filteredState = await withQueryFilteringContext(context, {
|
|
517
|
+
query: db(tableName),
|
|
518
|
+
filters: context.queryParams.filters,
|
|
519
|
+
schemaInfo: context.schemaInfo,
|
|
520
|
+
scopeName,
|
|
521
|
+
tableName,
|
|
522
|
+
db,
|
|
523
|
+
queryPurpose: 'aggregate',
|
|
524
|
+
adapter: storageAdapter,
|
|
525
|
+
storageAdapter,
|
|
526
|
+
}, async () => {
|
|
527
|
+
await runHooks('knexQueryFiltering')
|
|
528
|
+
})
|
|
529
|
+
return runAggregateQuery({ source: filteredState.query, sourceTable: tableName, storageAdapter, db, plan })
|
|
530
|
+
}
|
|
531
|
+
|
|
508
532
|
/** @type {OrdinaryDataWriteHelpers['dataPost']} */
|
|
509
533
|
const dataPost = async ({ scopeName, context }) => {
|
|
510
534
|
const storageAdapter = getScopeStorageAdapter(scopeName)
|
|
@@ -701,6 +725,7 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
701
725
|
dataExists,
|
|
702
726
|
dataGet,
|
|
703
727
|
dataQuery,
|
|
728
|
+
dataAggregate,
|
|
704
729
|
dataPost,
|
|
705
730
|
dataPut,
|
|
706
731
|
dataPatch,
|
|
@@ -79,6 +79,8 @@ export interface StorageInfo {
|
|
|
79
79
|
export interface CanonicalFieldSlot {
|
|
80
80
|
slot: string
|
|
81
81
|
slotType: string
|
|
82
|
+
// The slot has a (tenant_id, resource, slot) index; belongsTo slots always do.
|
|
83
|
+
indexed?: boolean
|
|
82
84
|
alias?: string
|
|
83
85
|
}
|
|
84
86
|
|
|
@@ -307,6 +309,27 @@ export interface DataReadHelpers<GetResult> {
|
|
|
307
309
|
queryParams: NonNullable<DataReadContext['queryParams']>
|
|
308
310
|
}
|
|
309
311
|
}) => Promise<DataDocument<DataResource[]>>
|
|
312
|
+
// The plan comes from compileAggregateRequest; filters are in context.queryParams.
|
|
313
|
+
dataAggregate: (request: DataReadRequest & {
|
|
314
|
+
runHooks: NonNullable<DataReadRequest['runHooks']>
|
|
315
|
+
context: DataReadContext & {
|
|
316
|
+
queryParams: NonNullable<DataReadContext['queryParams']>
|
|
317
|
+
}
|
|
318
|
+
plan: AggregatePlan
|
|
319
|
+
}) => Promise<AggregateResult>
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Compiled by compileAggregateRequest: outputs carry SQL aliases.
|
|
323
|
+
export interface AggregatePlan {
|
|
324
|
+
scopeName: string
|
|
325
|
+
groups: Array<{ name: string; field: string; definition: StorageFieldDefinition; alias: string }>
|
|
326
|
+
measures: Array<{ name: string; fn: 'count' | 'countDistinct' | 'sum' | 'avg' | 'min' | 'max'; field: string; definition: StorageFieldDefinition | null; alias: string }>
|
|
327
|
+
sort: Array<{ alias: string; direction: 'asc' | 'desc' }>
|
|
328
|
+
page: { size: number; number: number } | null
|
|
329
|
+
}
|
|
330
|
+
export interface AggregateResult {
|
|
331
|
+
data: Array<Record<string, unknown>>
|
|
332
|
+
meta?: { page: { size: number; number: number; hasMore: boolean } }
|
|
310
333
|
}
|
|
311
334
|
|
|
312
335
|
export type OrdinaryDataReadHelpers = DataReadHelpers<DataDocument<DataResource>>
|
|
@@ -6,18 +6,25 @@ import { createEnhancedLogger } from '../../lib/enhanced-logger.js'
|
|
|
6
6
|
import { ensureAnyApiSchema } from './lib/anyapi/schema-utils.js'
|
|
7
7
|
import { AnyapiRegistry } from './lib/anyapi/anyapi-registry.js'
|
|
8
8
|
import { composeResourceOptions, relatedResources } from './lib/anyapi/stored-definition.js'
|
|
9
|
-
import { RestApiResourceError } from '../../lib/rest-api-errors.js'
|
|
9
|
+
import { RestApiResourceError, RestApiValidationError } from '../../lib/rest-api-errors.js'
|
|
10
10
|
import { compileSchemas } from './lib/querying-writing/compile-schemas.js'
|
|
11
11
|
import { snapshotResourceConfiguration } from './lib/querying-writing/schema-helpers.js'
|
|
12
12
|
import { assertFieldNameMap } from './lib/querying-writing/field-utils.js'
|
|
13
13
|
import { polymorphicFiltersHook, crossTableFiltersHook, basicFiltersHook } from './lib/querying/knex-query-helpers.js'
|
|
14
14
|
import { createStorageAdapterLookup } from './lib/storage/storage-adapter.js'
|
|
15
|
+
import { translateCanonicalAttributesForStorage } from './lib/storage/canonical-storage-mapping.js'
|
|
16
|
+
import { applyDatabaseReadOptions, normalizeStoredFieldValue } from './lib/querying-writing/database-value-normalizers.js'
|
|
15
17
|
|
|
16
18
|
const DEFAULT_TENANT = 'default'
|
|
19
|
+
const MIGRATION_BATCH_SIZE = 500
|
|
17
20
|
// Methods that read a published schema; schema checks wrap these.
|
|
18
|
-
const REQUEST_METHODS = ['query', 'get', 'post', 'put', 'patch', 'delete', 'getRelated', 'getRelationship', 'postRelationship', 'patchRelationship', 'deleteRelationship']
|
|
21
|
+
const REQUEST_METHODS = ['query', 'aggregate', 'get', 'post', 'put', 'patch', 'delete', 'getRelated', 'getRelationship', 'postRelationship', 'patchRelationship', 'deleteRelationship']
|
|
19
22
|
|
|
20
|
-
const optionsSignature = options => JSON.stringify([
|
|
23
|
+
const optionsSignature = options => JSON.stringify([
|
|
24
|
+
Object.keys(options.schema || {}).sort(),
|
|
25
|
+
Object.keys(options.searchSchema || {}).sort(),
|
|
26
|
+
Object.entries(options.schema || {}).filter(([, definition]) => definition?.retired === true).map(([name]) => name).sort()
|
|
27
|
+
])
|
|
21
28
|
|
|
22
29
|
// A validation failure may come from a schema another server already changed.
|
|
23
30
|
// Writes that may have committed are never repeated.
|
|
@@ -32,7 +39,7 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
32
39
|
const options = pluginOptions || {}
|
|
33
40
|
const knex = options.knex
|
|
34
41
|
const tenantId = options.tenantId || DEFAULT_TENANT
|
|
35
|
-
const { ensureSchema = true, ownsKnex = true, onSchemaChanged, schemaCheckInterval, slots } = options
|
|
42
|
+
const { ensureSchema = true, ownsKnex = true, onSchemaChanged, schemaCheckInterval, slots, indexedSlots } = options
|
|
36
43
|
|
|
37
44
|
if (!knex) {
|
|
38
45
|
throw new Error('AnyapiKnexPlugin requires a knex instance')
|
|
@@ -48,7 +55,7 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
const registry = options.registry || new AnyapiRegistry({ knex, log })
|
|
51
|
-
if (ensureSchema) await registry.ensureSchema({ slots })
|
|
58
|
+
if (ensureSchema) await registry.ensureSchema({ slots, indexedSlots })
|
|
52
59
|
|
|
53
60
|
api.anyapi = api.anyapi || {}
|
|
54
61
|
api.anyapi.registry = registry
|
|
@@ -127,6 +134,15 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
127
134
|
tenant: tenantId, scopeName, fields: hidden, filters: hiddenFilters
|
|
128
135
|
})
|
|
129
136
|
}
|
|
137
|
+
// Fields never move on their own; migrateField copies one into an indexed slot.
|
|
138
|
+
const unindexed = Object.entries(scopeOptions.schema || {})
|
|
139
|
+
.filter(([name, definition]) => definition?.indexed === true && descriptor.fields[name]?.indexed === false)
|
|
140
|
+
.map(([name]) => name)
|
|
141
|
+
if (unindexed.length) {
|
|
142
|
+
await log.warn(`AnyAPI resource '${scopeName}' declares indexed fields stored in unindexed slots`, {
|
|
143
|
+
tenant: tenantId, scopeName, fields: unindexed
|
|
144
|
+
})
|
|
145
|
+
}
|
|
130
146
|
const signature = optionsSignature(scopeOptions)
|
|
131
147
|
let schemaInfo = scope.vars.schemaInfo
|
|
132
148
|
if (compiled?.signature === signature) schemaInfo = compiled.schemaInfo
|
|
@@ -252,6 +268,7 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
252
268
|
for (const { resource } of await registry.listResources(tenantId)) await loadResource(resource)
|
|
253
269
|
return api.resources
|
|
254
270
|
}
|
|
271
|
+
api.anyapi.getSchemaVersion = resource => registry.getSchemaVersion(tenantId, resource)
|
|
255
272
|
api.anyapi.handleSchemaChange = async ({ tenant, resource, version } = {}) => {
|
|
256
273
|
if ((tenant !== undefined && tenant !== tenantId) || typeof resource !== 'string' || !Object.hasOwn(api.resources, resource)) return false
|
|
257
274
|
if (version !== undefined && publishedVersion(resource) >= version) return false
|
|
@@ -322,7 +339,7 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
322
339
|
})
|
|
323
340
|
|
|
324
341
|
addResourceMethod('createKnexTable', async ({ scopeName }) => {
|
|
325
|
-
if (ensureSchema) await ensureAnyApiSchema(knex, { slots })
|
|
342
|
+
if (ensureSchema) await ensureAnyApiSchema(knex, { slots, indexedSlots })
|
|
326
343
|
const descriptor = await registry.getDescriptor(tenantId, scopeName)
|
|
327
344
|
if (!descriptor) throw new Error(`Descriptor not found for resource '${scopeName}'`)
|
|
328
345
|
await publish(scopeName, descriptor)
|
|
@@ -330,49 +347,141 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
330
347
|
|
|
331
348
|
addResourceMethod('reloadSchema', async ({ scopeName }) => reload(scopeName))
|
|
332
349
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
}
|
|
337
|
-
assertFieldNameMap(params.fields, `added fields in '${scopeName}'`)
|
|
338
|
-
assertFieldNameMap(params.searchSchema, `added search schema in '${scopeName}'`)
|
|
339
|
-
assertFieldNameMap(params.canonicalFieldsMap, `added canonical fields in '${scopeName}'`)
|
|
340
|
-
const scope = api.resources[scopeName]
|
|
341
|
-
const unknown = Object.keys(params.canonicalFieldsMap || {}).filter(field => !Object.hasOwn(params.fields, field))
|
|
342
|
-
if (unknown.length) {
|
|
343
|
-
throw new Error(`canonicalFieldsMap contains unknown fields for addKnexFields: ${unknown}`)
|
|
344
|
-
}
|
|
345
|
-
const fields = snapshotResourceConfiguration(params.fields)
|
|
346
|
-
const searchSchema = snapshotResourceConfiguration(params.searchSchema || {})
|
|
347
|
-
const current = composeOptions(scopeName, scope.vars.schemaInfo.descriptor).options
|
|
350
|
+
// Compile the resource with the additions, so allocation stores enriched definitions.
|
|
351
|
+
const prepareAdditions = async (scopeName, { fields, searchSchema = {}, canonicalFieldsMap }) => {
|
|
352
|
+
const current = composeOptions(scopeName, api.resources[scopeName].vars.schemaInfo.descriptor).options
|
|
348
353
|
const candidate = snapshotResourceConfiguration({
|
|
349
354
|
...current,
|
|
350
355
|
schema: { ...current.schema, ...fields },
|
|
351
356
|
searchSchema: { ...current.searchSchema, ...searchSchema }
|
|
352
357
|
})
|
|
353
358
|
const schemaInfo = await compileOptions(scopeName, candidate)
|
|
354
|
-
|
|
359
|
+
const allocation = {
|
|
355
360
|
tenant: tenantId,
|
|
356
361
|
resource: scopeName,
|
|
357
362
|
fields: Object.fromEntries(Object.keys(fields).map(fieldName => [fieldName, {
|
|
358
363
|
definition: schemaInfo.schemaStructure[fieldName] || schemaInfo.computed[fieldName],
|
|
359
364
|
declaration: fields[fieldName],
|
|
360
|
-
canonicalField:
|
|
365
|
+
canonicalField: canonicalFieldsMap?.[fieldName]
|
|
361
366
|
}])),
|
|
362
367
|
searchSchema
|
|
363
|
-
}
|
|
368
|
+
}
|
|
369
|
+
return { candidate, schemaInfo, allocation }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const publishChange = async (scopeName, { fields = {}, searchSchema = {}, compiled } = {}) => {
|
|
364
373
|
registry.invalidateDescriptor(tenantId, scopeName)
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
374
|
+
if (Object.keys(fields).length || Object.keys(searchSchema).length) {
|
|
375
|
+
const local = runtimeDeclarations.get(scopeName) || {}
|
|
376
|
+
runtimeDeclarations.set(scopeName, {
|
|
377
|
+
schema: { ...local.schema, ...fields },
|
|
378
|
+
searchSchema: { ...local.searchSchema, ...searchSchema }
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
// The fresh descriptor also carries changes other servers made meanwhile.
|
|
371
382
|
const descriptor = await registry.getDescriptor(tenantId, scopeName)
|
|
372
|
-
await publish(scopeName, descriptor, { compiled
|
|
383
|
+
await publish(scopeName, descriptor, { compiled })
|
|
373
384
|
await notifySchemaChanged(scopeName, descriptor.schemaVersion)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
addResourceMethod('addKnexFields', async ({ scopeName, params }) => {
|
|
388
|
+
if (!params?.fields || typeof params.fields !== 'object' || Array.isArray(params.fields)) {
|
|
389
|
+
throw new Error('fields parameter is required for addKnexFields')
|
|
390
|
+
}
|
|
391
|
+
assertFieldNameMap(params.fields, `added fields in '${scopeName}'`)
|
|
392
|
+
assertFieldNameMap(params.searchSchema, `added search schema in '${scopeName}'`)
|
|
393
|
+
assertFieldNameMap(params.canonicalFieldsMap, `added canonical fields in '${scopeName}'`)
|
|
394
|
+
const unknown = Object.keys(params.canonicalFieldsMap || {}).filter(field => !Object.hasOwn(params.fields, field))
|
|
395
|
+
if (unknown.length) {
|
|
396
|
+
throw new Error(`canonicalFieldsMap contains unknown fields for addKnexFields: ${unknown}`)
|
|
397
|
+
}
|
|
398
|
+
const fields = snapshotResourceConfiguration(params.fields)
|
|
399
|
+
const searchSchema = snapshotResourceConfiguration(params.searchSchema || {})
|
|
400
|
+
const { candidate, schemaInfo, allocation } = await prepareAdditions(scopeName, { fields, searchSchema, canonicalFieldsMap: params.canonicalFieldsMap })
|
|
401
|
+
await api.transaction(transaction => registry.allocateFields(allocation, { transaction }))
|
|
402
|
+
await publishChange(scopeName, { fields, searchSchema, compiled: { signature: optionsSignature(candidate), schemaInfo } })
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
const retirable = (scopeName, fieldName) => {
|
|
406
|
+
const definition = api.resources[scopeName].vars.schemaInfo.schemaStructure[fieldName]
|
|
407
|
+
if (!definition || fieldName === 'id' || definition.virtual === true || definition.belongsTo) {
|
|
408
|
+
throw new Error(`'${scopeName}.${fieldName}' is not a stored attribute`)
|
|
409
|
+
}
|
|
410
|
+
return definition
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
addResourceMethod('retireKnexFields', async ({ scopeName, params }) => {
|
|
414
|
+
const fields = Array.isArray(params) ? params : params?.fields
|
|
415
|
+
if (!Array.isArray(fields) || !fields.length) throw new Error('retireKnexFields requires an array of field names')
|
|
416
|
+
fields.forEach(fieldName => retirable(scopeName, fieldName))
|
|
417
|
+
await registry.retireFields({ tenant: tenantId, resource: scopeName, fields })
|
|
418
|
+
await publishChange(scopeName)
|
|
374
419
|
})
|
|
375
420
|
|
|
421
|
+
// Change a field's type: copy its values into a new field, then retire it.
|
|
422
|
+
addResourceMethod('migrateField', async ({ scopeName, params }) => {
|
|
423
|
+
const { from, to, transform } = params || {}
|
|
424
|
+
const { name, ...declaration } = to || {}
|
|
425
|
+
if (typeof from !== 'string' || typeof name !== 'string' || !name) {
|
|
426
|
+
throw new Error('migrateField requires from and to: { name, ...definition }')
|
|
427
|
+
}
|
|
428
|
+
if (transform !== undefined && typeof transform !== 'function') throw new Error('migrateField transform must be a function')
|
|
429
|
+
const fromDefinition = retirable(scopeName, from)
|
|
430
|
+
if (fromDefinition.retired) throw new Error(`Field '${scopeName}.${from}' is already retired`)
|
|
431
|
+
if (declaration.computed || declaration.virtual || declaration.belongsTo || declaration.getter || declaration.setter || declaration.storage) {
|
|
432
|
+
throw new Error(`migrateField target '${name}' must be a plain stored attribute; add other fields with addKnexFields`)
|
|
433
|
+
}
|
|
434
|
+
assertFieldNameMap({ [name]: declaration }, `migrated field in '${scopeName}'`)
|
|
435
|
+
const fields = snapshotResourceConfiguration({ [name]: declaration })
|
|
436
|
+
const { schemaInfo, allocation } = await prepareAdditions(scopeName, { fields })
|
|
437
|
+
|
|
438
|
+
await api.transaction(async (transaction) => {
|
|
439
|
+
const descriptor = await registry.allocateFields(allocation, { transaction })
|
|
440
|
+
await copyFieldValues({ transaction, descriptor, schemaInfo, from, fromDefinition, to: name, transform })
|
|
441
|
+
await registry.retireFields({ tenant: tenantId, resource: scopeName, fields: [from] }, { transaction })
|
|
442
|
+
})
|
|
443
|
+
await publishChange(scopeName, { fields })
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
const copyFieldValues = async ({ transaction, descriptor, schemaInfo, from, fromDefinition, to, transform }) => {
|
|
447
|
+
const { canonical } = descriptor
|
|
448
|
+
const source = descriptor.fields[from].slot
|
|
449
|
+
const target = descriptor.fields[to].slot
|
|
450
|
+
const idColumn = canonical.logicalIdColumn
|
|
451
|
+
for (let lastId = null; ;) {
|
|
452
|
+
const query = transaction(canonical.tableName)
|
|
453
|
+
.where(canonical.tenantColumn, tenantId)
|
|
454
|
+
.where(canonical.resourceColumn, descriptor.resource)
|
|
455
|
+
.orderBy('id')
|
|
456
|
+
.limit(MIGRATION_BATCH_SIZE)
|
|
457
|
+
.select('id', idColumn, source)
|
|
458
|
+
if (lastId !== null) query.where('id', '>', lastId)
|
|
459
|
+
const rows = await applyDatabaseReadOptions(query)
|
|
460
|
+
if (!rows.length) return
|
|
461
|
+
for (const row of rows) {
|
|
462
|
+
lastId = row.id
|
|
463
|
+
const value = normalizeStoredFieldValue(row[source], fromDefinition, { fieldName: from, resourceType: descriptor.resource })
|
|
464
|
+
const next = transform ? await transform(value, { id: String(row[idColumn]) }) : value
|
|
465
|
+
const rejected = reason => {
|
|
466
|
+
const message = `Record '${row[idColumn]}' cannot migrate '${from}' to '${to}': ${reason}`
|
|
467
|
+
return new RestApiValidationError(message, { fields: [to], violations: [{ field: to, rule: 'field_migration', message }] })
|
|
468
|
+
}
|
|
469
|
+
const { validatedObject, errors } = await schemaInfo.schemaInstance.patch({ [to]: next ?? null })
|
|
470
|
+
if (Object.keys(errors).length) throw rejected(Object.values(errors)[0].message)
|
|
471
|
+
let stored
|
|
472
|
+
try {
|
|
473
|
+
stored = translateCanonicalAttributesForStorage({ [to]: validatedObject[to] ?? null }, descriptor, {
|
|
474
|
+
schemaInfo, databaseClient: knex.client.config.client
|
|
475
|
+
})
|
|
476
|
+
} catch (error) {
|
|
477
|
+
if (error?.type === 'rest_api_validation') throw rejected(error.message)
|
|
478
|
+
throw error
|
|
479
|
+
}
|
|
480
|
+
await transaction(canonical.tableName).where('id', row.id).update({ [target]: stored[target] ?? null })
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
376
485
|
addResourceMethod('alterKnexFields', async () => {
|
|
377
486
|
throw new Error('alterKnexFields is not supported by AnyAPI Knex plugin yet')
|
|
378
487
|
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
|
|
2
|
+
import { getEffectiveSortableFields } from '../lib/querying/query-field-sort-helpers.js'
|
|
3
|
+
import { compileAggregateRequest } from '../lib/querying/aggregate-query.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Group a filtered collection and compute measures for each group.
|
|
7
|
+
* Filters are validated like query filters; permissions are checked as a
|
|
8
|
+
* query, and row policies and autofilters see queryPurpose 'aggregate'.
|
|
9
|
+
* Returns plain rows in either format; grouped results are paged.
|
|
10
|
+
*/
|
|
11
|
+
export default async function aggregateMethod ({ params, context, vars, helpers, scope, runHooks, scopeName, api }) {
|
|
12
|
+
context.method = 'aggregate'
|
|
13
|
+
context.scopeName = scopeName
|
|
14
|
+
context.schemaInfo = scope.vars.schemaInfo
|
|
15
|
+
|
|
16
|
+
const plan = compileAggregateRequest({
|
|
17
|
+
params,
|
|
18
|
+
schemaInfo: context.schemaInfo,
|
|
19
|
+
scopeName,
|
|
20
|
+
defaultLimit: vars.queryDefaultLimit,
|
|
21
|
+
maxLimit: vars.queryMaxLimit
|
|
22
|
+
})
|
|
23
|
+
const requestContracts = getRequestContracts({
|
|
24
|
+
scopeName,
|
|
25
|
+
schemaInfo: context.schemaInfo,
|
|
26
|
+
includeDepthLimit: vars.includeDepthLimit,
|
|
27
|
+
sortableFields: getEffectiveSortableFields(vars)
|
|
28
|
+
})
|
|
29
|
+
const validated = validateRequestContractOrThrow(
|
|
30
|
+
requestContracts.query,
|
|
31
|
+
{ queryParams: { filters: params.queryParams?.filters ?? {} } },
|
|
32
|
+
'Aggregate filters are invalid'
|
|
33
|
+
)
|
|
34
|
+
context.queryParams = { filters: validated.queryParams?.filters || {} }
|
|
35
|
+
|
|
36
|
+
context.transaction = params.transaction
|
|
37
|
+
context.db = context.transaction || api.knex.instance
|
|
38
|
+
|
|
39
|
+
await scope.checkPermissions({ method: 'query', originalContext: context })
|
|
40
|
+
return helpers.dataAggregate({ scopeName, context, runHooks, plan })
|
|
41
|
+
}
|
|
@@ -213,7 +213,7 @@ export const validateCompleteReplacePayload = ({
|
|
|
213
213
|
if (!fieldDef) continue
|
|
214
214
|
if (fieldName === idProperty) continue
|
|
215
215
|
if (Object.hasOwn(belongsToUpdates, fieldName)) continue
|
|
216
|
-
if (fieldDef.computed === true || fieldDef.virtual === true) continue
|
|
216
|
+
if (fieldDef.computed === true || fieldDef.virtual === true || fieldDef.retired === true) continue
|
|
217
217
|
if (fieldDef.type === undefined) continue
|
|
218
218
|
|
|
219
219
|
if (fieldDef.belongsTo && fieldDef.as) {
|
|
@@ -296,8 +296,16 @@ export const validateResourceAttributesBeforeWrite = async ({
|
|
|
296
296
|
/** @type {StorageRow} */
|
|
297
297
|
const attributesForValidation = { ...attributesToValidate }
|
|
298
298
|
const foreignKeyViolations = []
|
|
299
|
+
const retiredViolations = []
|
|
299
300
|
for (const [field, value] of Object.entries(attributesToValidate)) {
|
|
300
301
|
const definition = schemaStructure[field]
|
|
302
|
+
if (definition?.retired === true && value !== undefined) {
|
|
303
|
+
retiredViolations.push({
|
|
304
|
+
field: `data.attributes.${field}`,
|
|
305
|
+
rule: 'retired_field',
|
|
306
|
+
message: `Field '${field}' is retired and accepts no new values`
|
|
307
|
+
})
|
|
308
|
+
}
|
|
301
309
|
if (!definition?.belongsTo || !definition.as || Object.hasOwn(belongsToUpdates, field)) continue
|
|
302
310
|
delete attributesForValidation[field]
|
|
303
311
|
if (value === undefined) continue
|
|
@@ -307,6 +315,13 @@ export const validateResourceAttributesBeforeWrite = async ({
|
|
|
307
315
|
message: `Foreign key field '${field}' should not be in attributes. Use 'data.relationships.${definition.as}' instead.`
|
|
308
316
|
})
|
|
309
317
|
}
|
|
318
|
+
const [firstRetired] = retiredViolations
|
|
319
|
+
if (firstRetired) {
|
|
320
|
+
throw new RestApiValidationError(firstRetired.message, {
|
|
321
|
+
fields: retiredViolations.map(violation => violation.field),
|
|
322
|
+
violations: retiredViolations
|
|
323
|
+
})
|
|
324
|
+
}
|
|
310
325
|
if (foreignKeyViolations.length) {
|
|
311
326
|
throw new RestApiValidationError('Foreign key fields cannot be set directly in attributes', {
|
|
312
327
|
fields: foreignKeyViolations.map(violation => violation.field),
|
|
@@ -10,6 +10,7 @@ import registerRelationshipRoutes from './rest-api-plugin-hooks/register-relatio
|
|
|
10
10
|
|
|
11
11
|
// Import method functions
|
|
12
12
|
import queryMethod from './rest-api-plugin-methods/query.js'
|
|
13
|
+
import aggregateMethod from './rest-api-plugin-methods/aggregate.js'
|
|
13
14
|
import getMethod from './rest-api-plugin-methods/get.js'
|
|
14
15
|
import postMethod from './rest-api-plugin-methods/post.js'
|
|
15
16
|
import putMethod from './rest-api-plugin-methods/put.js'
|
|
@@ -90,6 +91,7 @@ export const RestApiPlugin = {
|
|
|
90
91
|
|
|
91
92
|
// Main REST methods
|
|
92
93
|
addResourceMethod('query', withAvailableContext(queryMethod))
|
|
94
|
+
addResourceMethod('aggregate', withAvailableContext(aggregateMethod))
|
|
93
95
|
addResourceMethod('get', withAvailableContext(getMethod))
|
|
94
96
|
addResourceMethod('post', withWriteOutcome(postMethod))
|
|
95
97
|
addResourceMethod('put', withWriteOutcome(putMethod))
|
package/types/anyapi.d.ts
CHANGED
|
@@ -2,6 +2,15 @@ import type { Knex } from 'knex'
|
|
|
2
2
|
|
|
3
3
|
export type AnyapiSlotPool = 'string' | 'number' | 'boolean' | 'date' | 'json' | 'belongsTo'
|
|
4
4
|
export type AnyapiSlotCounts = Partial<Record<AnyapiSlotPool, number>>
|
|
5
|
+
// Pools whose slots can carry a (tenant_id, resource, slot) index.
|
|
6
|
+
export type AnyapiIndexedSlotPool = 'string' | 'number' | 'boolean' | 'date'
|
|
7
|
+
export type AnyapiIndexedSlotCounts = Partial<Record<AnyapiIndexedSlotPool, number>>
|
|
8
|
+
|
|
9
|
+
export interface AnyapiSchemaOptions {
|
|
10
|
+
slots?: AnyapiSlotCounts
|
|
11
|
+
// Fields declared `indexed: true` take these slots.
|
|
12
|
+
indexedSlots?: AnyapiIndexedSlotCounts
|
|
13
|
+
}
|
|
5
14
|
|
|
6
15
|
export interface AnyapiSchemaChange {
|
|
7
16
|
tenant: string
|
|
@@ -21,10 +30,12 @@ export class AnyapiRegistry {
|
|
|
21
30
|
constructor(options: AnyapiRegistryOptions)
|
|
22
31
|
readonly knex: Knex
|
|
23
32
|
readonly cacheSize: number
|
|
24
|
-
ensureSchema(options?:
|
|
33
|
+
ensureSchema(options?: AnyapiSchemaOptions): Promise<void>
|
|
25
34
|
getSchemaVersion(tenant: string, resource: string, options?: { transaction?: Knex.Transaction }): Promise<number | null>
|
|
35
|
+
// Retired fields keep their slots and values and reject new writes.
|
|
36
|
+
retireFields(params: { tenant: string; resource: string; fields: string[] }, options?: { transaction?: Knex.Transaction }): Promise<Record<string, unknown>>
|
|
26
37
|
invalidateDescriptor(tenant: string, resource: string): void
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
/** Create or upgrade the canonical tables; counts only ever add slot columns. */
|
|
30
|
-
export function ensureAnyApiSchema(knex: Knex, options?:
|
|
41
|
+
export function ensureAnyApiSchema(knex: Knex, options?: AnyapiSchemaOptions): Promise<void>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { DirectResourceId, ResourceFormat, WriteReturning } from './representations.js'
|
|
2
2
|
import type { RemovedResourceOptions } from './resource-methods.js'
|
|
3
3
|
import type { Knex } from 'knex'
|
|
4
|
-
import type { AnyapiRegistry, AnyapiSchemaChange, AnyapiSlotCounts } from './anyapi.js'
|
|
4
|
+
import type { AnyapiIndexedSlotCounts, AnyapiRegistry, AnyapiSchemaChange, AnyapiSlotCounts } from './anyapi.js'
|
|
5
5
|
|
|
6
6
|
export type RestApiPluginOptions = RemovedResourceOptions & {
|
|
7
7
|
format?: ResourceFormat
|
|
@@ -50,6 +50,7 @@ export interface RestApiAnyapiKnexPluginOptions extends RestApiKnexPluginOptions
|
|
|
50
50
|
// false leaves a shared Knex instance open when this API is released.
|
|
51
51
|
ownsKnex?: boolean
|
|
52
52
|
slots?: AnyapiSlotCounts
|
|
53
|
+
indexedSlots?: AnyapiIndexedSlotCounts
|
|
53
54
|
// Called after a committed schema change, for example to notify other servers.
|
|
54
55
|
onSchemaChanged?: (change: AnyapiSchemaChange) => void | Promise<void>
|
|
55
56
|
// Milliseconds between schema version checks; 0 checks on every request.
|
|
@@ -23,6 +23,24 @@ export interface QueryParams extends SelectionParams {
|
|
|
23
23
|
sort?: string[]
|
|
24
24
|
page?: PageParams
|
|
25
25
|
}
|
|
26
|
+
export type AggregateFunction = 'count' | 'countDistinct' | 'sum' | 'avg' | 'min' | 'max'
|
|
27
|
+
// One function per measure; only count accepts '*'.
|
|
28
|
+
export type AggregateMeasure = { count: string } | { [Function in Exclude<AggregateFunction, 'count'>]: { [Name in Function]: string } }[Exclude<AggregateFunction, 'count'>]
|
|
29
|
+
export interface AggregateParams extends TransactionParam {
|
|
30
|
+
groupBy?: string[]
|
|
31
|
+
measures: Record<string, AggregateMeasure>
|
|
32
|
+
queryParams?: {
|
|
33
|
+
filters?: Record<string, unknown>
|
|
34
|
+
// Grouped field or measure names, '-' for descending.
|
|
35
|
+
sort?: string[]
|
|
36
|
+
// Grouped results only.
|
|
37
|
+
page?: { size?: number; number?: number }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export interface AggregateResult {
|
|
41
|
+
data: Array<Record<string, unknown>>
|
|
42
|
+
meta?: { page: { size: number; number: number; hasMore: boolean } }
|
|
43
|
+
}
|
|
26
44
|
export interface TransactionParam {
|
|
27
45
|
transaction?: Knex.Transaction
|
|
28
46
|
}
|
|
@@ -78,6 +96,7 @@ export interface ResourceCoreMethods<
|
|
|
78
96
|
query<Format extends ResourceFormat = DefaultFormat>(
|
|
79
97
|
params?: RemovedResourceOptions & TransactionParam & { format?: Format; queryParams?: QueryParams }, context?: Context
|
|
80
98
|
): Promise<CollectionResult<Format, Fields, Type>>
|
|
99
|
+
aggregate(params: AggregateParams, context?: Context): Promise<AggregateResult>
|
|
81
100
|
post<Format extends ResourceFormat = DefaultFormat, Returning extends WriteReturning = DefaultReturning>(
|
|
82
101
|
params: WriteOptions<Format, Returning> & ResourceWriteInput<Input, Type> & {
|
|
83
102
|
expectedVersion?: never
|