json-rest-api 2.1.0 → 2.2.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 +42 -1
- package/docs/GUIDE/33-migrating-to-v2.md +9 -0
- package/package.json +1 -1
- package/plugins/core/lib/anyapi/anyapi-registry.js +59 -17
- package/plugins/core/lib/anyapi/schema-utils.js +0 -5
- package/plugins/core/lib/anyapi/stored-definition.js +3 -0
- package/plugins/core/lib/querying/aggregate-query.js +170 -0
- 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/ordinary-data-helpers.js +25 -0
- package/plugins/core/lib/storage/storage-types.d.ts +21 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +121 -28
- 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 +2 -0
- package/types/resource-methods.d.ts +19 -0
- package/types/resource-schema.d.ts +5 -3
package/docs/API.md
CHANGED
|
@@ -78,6 +78,7 @@ a separate context for each enlisted operation. See [transaction support](#trans
|
|
|
78
78
|
- [PUT - Replace Resource](#put---replace-resource)
|
|
79
79
|
- [PATCH - Update Resource](#patch---update-resource)
|
|
80
80
|
- [DELETE - Remove Resource](#delete---remove-resource)
|
|
81
|
+
- [AGGREGATE - Grouped Totals](#aggregate---grouped-totals)
|
|
81
82
|
3. [Relationship Methods](#relationship-methods)
|
|
82
83
|
- [getRelated - Retrieve Related Resources](#getrelated---retrieve-related-resources)
|
|
83
84
|
- [getRelationship - Retrieve Relationship Identifiers](#getrelationship---retrieve-relationship-identifiers)
|
|
@@ -1136,6 +1137,68 @@ savepoints cannot own library writes.
|
|
|
1136
1137
|
|
|
1137
1138
|
---
|
|
1138
1139
|
|
|
1140
|
+
### AGGREGATE - Grouped Totals
|
|
1141
|
+
|
|
1142
|
+
Groups the records a collection query would select and computes measures for
|
|
1143
|
+
each group, on ordinary and AnyAPI storage alike.
|
|
1144
|
+
|
|
1145
|
+
#### Method Signature
|
|
1146
|
+
|
|
1147
|
+
```javascript
|
|
1148
|
+
await api.resources.sales.aggregate(params, context)
|
|
1149
|
+
```
|
|
1150
|
+
|
|
1151
|
+
#### Parameters
|
|
1152
|
+
|
|
1153
|
+
| Parameter | Type | Description |
|
|
1154
|
+
|-----------|------|-------------|
|
|
1155
|
+
| `groupBy` | string[] | Fields to group by. Omit for one row over all selected records. |
|
|
1156
|
+
| `measures` | object | Output names mapped to `{ count \| countDistinct \| sum \| avg \| min \| max: field }`. Only `count` accepts `'*'`. |
|
|
1157
|
+
| `queryParams.filters` | object | Search schema filters, validated as for `query`. |
|
|
1158
|
+
| `queryParams.sort` | string[] | Grouped field or measure names, `-` for descending. Grouped fields break ties. |
|
|
1159
|
+
| `queryParams.page` | object | `size` and `number`, for grouped results only. `size` defaults to `queryDefaultLimit` and cannot exceed `queryMaxLimit`. |
|
|
1160
|
+
| `transaction` | Knex transaction | Optional transaction to read in. |
|
|
1161
|
+
|
|
1162
|
+
`sum` and `avg` take numeric fields; `min` and `max` also take strings, dates and
|
|
1163
|
+
times. Grouping and counting accept any scalar stored field, including belongs-to
|
|
1164
|
+
foreign keys. Fields that are `hidden`, virtual, computed, or that have a getter or
|
|
1165
|
+
`storage.serialize` are rejected, because SQL would group or sum values other than
|
|
1166
|
+
the ones clients read. `normallyHidden` fields are allowed.
|
|
1167
|
+
|
|
1168
|
+
#### Return Value
|
|
1169
|
+
|
|
1170
|
+
```javascript
|
|
1171
|
+
{
|
|
1172
|
+
data: [{ salesperson: 'Ann', total: 150.5, count: 2 }],
|
|
1173
|
+
meta: { page: { size: 20, number: 1, hasMore: false } } // grouped results only
|
|
1174
|
+
}
|
|
1175
|
+
```
|
|
1176
|
+
|
|
1177
|
+
Both formats return these plain rows. Group values and `min`/`max` use the
|
|
1178
|
+
field's public representation; counts are numbers, and `sum`/`avg` are numbers
|
|
1179
|
+
except on decimal fields (`precision` and `scale`), which keep the driver's value.
|
|
1180
|
+
NULL sorts as the lowest value on every database.
|
|
1181
|
+
|
|
1182
|
+
#### Security
|
|
1183
|
+
|
|
1184
|
+
`aggregate` checks permissions as a `query` (`method: 'query'`, with
|
|
1185
|
+
`originalContext.method === 'aggregate'`). Row policies, autofilters and other
|
|
1186
|
+
`knexQueryFiltering` hooks run with `queryPurpose: 'aggregate'`. The aggregate reads
|
|
1187
|
+
only the IDs the filtered query selects, so joins added by filters or policies
|
|
1188
|
+
never count a record twice.
|
|
1189
|
+
|
|
1190
|
+
#### Examples
|
|
1191
|
+
|
|
1192
|
+
```javascript
|
|
1193
|
+
const totals = await api.resources.sales.aggregate({
|
|
1194
|
+
groupBy: ['salesperson'],
|
|
1195
|
+
measures: { total: { sum: 'amount' }, count: { count: '*' }, avg: { avg: 'amount' } },
|
|
1196
|
+
queryParams: { filters: { closed: true }, sort: ['-total'], page: { size: 10 } }
|
|
1197
|
+
}, { subject })
|
|
1198
|
+
```
|
|
1199
|
+
|
|
1200
|
+
---
|
|
1201
|
+
|
|
1139
1202
|
## Relationship Methods
|
|
1140
1203
|
|
|
1141
1204
|
### getRelated - Retrieve Related Resources
|
|
@@ -2039,6 +2102,23 @@ database writes; it is not a persisted UI-state field. Use a setter when a
|
|
|
2039
2102
|
virtual input needs to prepare other stored attributes. A `virtualFields` array
|
|
2040
2103
|
is not the resource declaration contract. See [virtual-field behavior](GUIDE/12-field-transformations.md).
|
|
2041
2104
|
|
|
2105
|
+
#### Email and UUID Fields
|
|
2106
|
+
|
|
2107
|
+
`type: 'email'` and `type: 'uuid'` store trimmed strings and reject values that
|
|
2108
|
+
are not an email address or a UUID. Filters with a text operator (`like`,
|
|
2109
|
+
`contains`, `startsWith`, `endsWith`) accept fragments. Registration rejects any
|
|
2110
|
+
type json-rest-schema cannot cast, so a misspelt type fails at `addResource`
|
|
2111
|
+
rather than on the first write.
|
|
2112
|
+
|
|
2113
|
+
#### Retired Fields
|
|
2114
|
+
|
|
2115
|
+
`retired: true` keeps a stored field readable, filterable and sortable while
|
|
2116
|
+
rejecting every value written to it, including `null`, with the `retired_field`
|
|
2117
|
+
rule. `required` and `defaultTo` no longer apply, and PUT leaves its stored value
|
|
2118
|
+
alone. Only stored attributes can be retired, not belongs-to fields. AnyAPI
|
|
2119
|
+
resources can retire fields at run time with
|
|
2120
|
+
[`retireKnexFields`](GUIDE/21-schema-and-migrations.md#retiring-and-migrating-anyapi-fields).
|
|
2121
|
+
|
|
2042
2122
|
#### Field Transformations
|
|
2043
2123
|
|
|
2044
2124
|
Getters and setters receive a value and an injected context. Computed fields
|
|
@@ -286,6 +286,7 @@ Policies are reused for the resource selections that define externally visible d
|
|
|
286
286
|
| --- | --- |
|
|
287
287
|
| `collection` | Top-level collection query, before sorting and pagination. On AnyAPI this filtered builder is also cloned for the matching count. |
|
|
288
288
|
| `count` | A separate count query, including native offset-pagination counts and standalone count helpers. |
|
|
289
|
+
| `aggregate` | The records a resource `aggregate()` call groups and measures. |
|
|
289
290
|
| `single` | Minimal lookup used by `get`, and by existing-record preflights for `put`, `patch`, and `delete`. |
|
|
290
291
|
| `include` | A target resource loaded into JSON:API `included` data. |
|
|
291
292
|
| `search-join` | A related resource used by a cross-resource or polymorphic search, reference filter, or reference sort, including intermediate search path segments. |
|
|
@@ -297,7 +297,8 @@ This optional map may cover a subset of the new stored fields; remaining fields
|
|
|
297
297
|
use the existing allocator. An initial resource map must still cover all stored
|
|
298
298
|
fields. Invalid, occupied or unknown slots and existing field names reject the
|
|
299
299
|
addition. Computed and virtual fields need no slots and cannot have overrides.
|
|
300
|
-
AnyAPI `alterKnexFields` remains explicitly unsupported
|
|
300
|
+
AnyAPI `alterKnexFields` remains explicitly unsupported; change a field's type with
|
|
301
|
+
[`migrateField`](#retiring-and-migrating-anyapi-fields).
|
|
301
302
|
|
|
302
303
|
One call uses one owned metadata transaction. Allocation failure rolls back the
|
|
303
304
|
batch and leaves the runtime schema unchanged. Added getters, setters, defaults,
|
|
@@ -365,6 +366,7 @@ await api.anyapi.handleSchemaChange(change)
|
|
|
365
366
|
|
|
366
367
|
`handleSchemaChange({ tenant, resource, version })` reloads the resource only when
|
|
367
368
|
the version is newer than the published one, and returns whether it did.
|
|
369
|
+
`api.anyapi.getSchemaVersion('items')` reads the stored version with one query.
|
|
368
370
|
`api.resources.items.reloadSchema()` reloads unconditionally and resolves to the
|
|
369
371
|
published version. Reloads are read-only: they combine the stored definition
|
|
370
372
|
with this server's declaration and never re-register it.
|
|
@@ -384,6 +386,45 @@ of the tenant. A stored resource whose non-field options needed callbacks, such
|
|
|
384
386
|
`normalizeId`, must be declared in code. To load resources on demand over HTTP,
|
|
385
387
|
call `loadResource` from middleware placed before the API router.
|
|
386
388
|
|
|
389
|
+
### Retiring and migrating AnyAPI fields
|
|
390
|
+
|
|
391
|
+
Slots are never released while records exist, so AnyAPI retires fields instead of
|
|
392
|
+
dropping them:
|
|
393
|
+
|
|
394
|
+
```js
|
|
395
|
+
await api.resources.contacts.retireKnexFields(['phone'])
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
A retired field keeps its slot and its values, stays readable, filterable and
|
|
399
|
+
sortable, and rejects writes as a [`retired: true`](../API.md#retired-fields) field
|
|
400
|
+
does. The mark is stored with the resource definition, so a server that registers
|
|
401
|
+
an older declaration still listing the field keeps it retired. Its name stays taken.
|
|
402
|
+
|
|
403
|
+
`migrateField` changes a field's type by copying its values into a new field:
|
|
404
|
+
|
|
405
|
+
```js
|
|
406
|
+
await api.resources.contacts.migrateField({
|
|
407
|
+
from: 'phone',
|
|
408
|
+
to: { name: 'phoneNumber', type: 'number', nullable: true, search: true },
|
|
409
|
+
transform: (value, { id }) => value === null ? null : Number(value.replace(/\D/g, ''))
|
|
410
|
+
})
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
In one transaction it allocates a slot for the new field, converts every record's
|
|
414
|
+
value, and retires the old field. Each converted value must pass the new field's
|
|
415
|
+
validation; without `transform`, that validation casts the old value directly. A
|
|
416
|
+
value it rejects names the record and rolls everything back. The new field must be
|
|
417
|
+
a plain stored attribute: add fields with callbacks with `addKnexFields` and
|
|
418
|
+
backfill them. Values are written directly, so setters, hooks and record versions
|
|
419
|
+
do not run. Records are converted one by one, which suits per-tenant tables
|
|
420
|
+
better than very large ones. Other servers keep accepting writes to the old field
|
|
421
|
+
until the migration commits, and those writes do not reach the new field, so run
|
|
422
|
+
it while nothing writes the field.
|
|
423
|
+
|
|
424
|
+
Both methods bump `schema_version` and call `onSchemaChanged` once after commit.
|
|
425
|
+
`AnyapiRegistry.retireFields({ tenant, resource, fields }, { transaction })` is the
|
|
426
|
+
deep form of retirement.
|
|
427
|
+
|
|
387
428
|
### AnyAPI tenant instances
|
|
388
429
|
|
|
389
430
|
One API instance per tenant stays cheap when the instances share their start-up work:
|
|
@@ -1038,6 +1038,15 @@ Use `HookHandler<Event, AppContext>` for known hooks and
|
|
|
1038
1038
|
`RuntimeHook<PluginContext>` for arbitrary plugin events; see the
|
|
1039
1039
|
[hook guide](13-hooks-and-lifecycle.md).
|
|
1040
1040
|
|
|
1041
|
+
## Field type names
|
|
1042
|
+
|
|
1043
|
+
`addResource` now rejects a field whose type json-rest-schema cannot cast, naming
|
|
1044
|
+
the field and type. Such fields used to register and then fail on their first
|
|
1045
|
+
write with `No casting function for type`. AnyAPI no longer lists `text`, `float`,
|
|
1046
|
+
`decimal`, `datetime` or `json`, which no storage could write; use `string`,
|
|
1047
|
+
`number`, `dateTime` and `object` or `array`. `email` and `uuid` are now real
|
|
1048
|
+
string types with format checks.
|
|
1049
|
+
|
|
1041
1050
|
## Object and array attributes
|
|
1042
1051
|
|
|
1043
1052
|
Declare structured fields using their public JSON type:
|
package/package.json
CHANGED
|
@@ -243,22 +243,16 @@ export class AnyapiRegistry {
|
|
|
243
243
|
})
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const { tenant, resource, fields, searchSchema } = readParams()
|
|
251
|
-
Object.assign(context, { tenant, resource })
|
|
246
|
+
#allocate (options, method, readParams) {
|
|
247
|
+
return this.#changeResource(options, method, () => {
|
|
248
|
+
const params = readParams()
|
|
249
|
+
const { resource, fields, searchSchema } = params
|
|
252
250
|
for (const [fieldName, { definition }] of Object.entries(fields)) {
|
|
253
251
|
validateSchemaFieldNames({ [fieldName]: definition }, resource)
|
|
254
252
|
}
|
|
255
253
|
assertFieldNameMap(searchSchema, `added search schema in '${resource}'`)
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
trx = await beginWriteTransaction(context, options.transaction, ownerContext => createKnexTransaction(this.knex, ownerContext))
|
|
259
|
-
const resourceRow = await this.#lockResourceRow(trx, tenant, resource)
|
|
260
|
-
const descriptor = resourceRow && await this.#loadDescriptor(tenant, resource, trx)
|
|
261
|
-
if (!descriptor) throw new Error(`Resource '${resource}' not registered for tenant '${tenant}'`)
|
|
254
|
+
return params
|
|
255
|
+
}, async ({ trx, resourceRow, descriptor }, { fields, searchSchema }) => {
|
|
262
256
|
const plan = await this.#withSlotPools(trx, pools => this.#planAllocation(descriptor, fields, pools))
|
|
263
257
|
|
|
264
258
|
for (const row of plan.fieldRows) {
|
|
@@ -280,11 +274,53 @@ export class AnyapiRegistry {
|
|
|
280
274
|
runtime.callbacks.push(...callbacks.map(path => [kind, name, ...path]))
|
|
281
275
|
}
|
|
282
276
|
}
|
|
283
|
-
|
|
277
|
+
return {
|
|
284
278
|
schema_json: JSON.stringify({ ...descriptor.schema, ...plan.definitions }),
|
|
285
|
-
definition_json: JSON.stringify({ ...stored, runtime })
|
|
286
|
-
|
|
287
|
-
|
|
279
|
+
definition_json: JSON.stringify({ ...stored, runtime })
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Mark stored fields retired: they keep their slots and values, and the
|
|
286
|
+
* published schema stops accepting writes to them. Registration keeps the mark.
|
|
287
|
+
*/
|
|
288
|
+
async retireFields (params, options = {}) {
|
|
289
|
+
return this.#changeResource(options, 'retireFields', () => {
|
|
290
|
+
const { tenant, resource, fields } = params || {}
|
|
291
|
+
if (!tenant || !resource || !Array.isArray(fields) || !fields.length || fields.some(field => typeof field !== 'string')) {
|
|
292
|
+
throw new Error('retireFields requires tenant, resource, and an array of field names')
|
|
293
|
+
}
|
|
294
|
+
return { tenant, resource, fields }
|
|
295
|
+
}, ({ descriptor }, { fields }) => {
|
|
296
|
+
for (const fieldName of fields) {
|
|
297
|
+
const field = descriptor.fields[fieldName]
|
|
298
|
+
if (!field) throw new Error(`Field '${descriptor.resource}.${fieldName}' has no stored slot to retire`)
|
|
299
|
+
if (field.slotType === 'belongsTo') throw new Error(`Field '${descriptor.resource}.${fieldName}' is a relationship and cannot be retired`)
|
|
300
|
+
}
|
|
301
|
+
const stored = descriptor.definition || { options: null, callbacks: [] }
|
|
302
|
+
const retired = [...new Set([...(stored.retired || []), ...fields])]
|
|
303
|
+
return { definition_json: JSON.stringify({ ...stored, retired }) }
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Run one schema change under the resource's row lock and bump its version once.
|
|
308
|
+
// `change` returns the any_resource_configs values to write.
|
|
309
|
+
async #changeResource (options, method, readParams, change) {
|
|
310
|
+
const context = { method, [readCommittedTransaction]: true }
|
|
311
|
+
let trx; let managed = false
|
|
312
|
+
try {
|
|
313
|
+
const params = readParams()
|
|
314
|
+
const { tenant, resource } = params
|
|
315
|
+
Object.assign(context, { tenant, resource })
|
|
316
|
+
|
|
317
|
+
managed = !options.transaction
|
|
318
|
+
trx = await beginWriteTransaction(context, options.transaction, ownerContext => createKnexTransaction(this.knex, ownerContext))
|
|
319
|
+
const resourceRow = await this.#lockResourceRow(trx, tenant, resource)
|
|
320
|
+
const descriptor = resourceRow && await this.#loadDescriptor(tenant, resource, trx)
|
|
321
|
+
if (!descriptor) throw new Error(`Resource '${resource}' not registered for tenant '${tenant}'`)
|
|
322
|
+
const values = await change({ trx, resourceRow, descriptor }, params)
|
|
323
|
+
await this.#bumpVersion(trx, resourceRow, { ...values, updated_at: this.knex.fn.now() })
|
|
288
324
|
const updated = await this.#loadDescriptor(tenant, resource, trx)
|
|
289
325
|
if (managed) {
|
|
290
326
|
await commitTransaction(trx, context)
|
|
@@ -551,7 +587,13 @@ export class AnyapiRegistry {
|
|
|
551
587
|
const row = {
|
|
552
588
|
schema_json: JSON.stringify(storageSchema),
|
|
553
589
|
relationships_json: JSON.stringify(relationships || {}),
|
|
554
|
-
definition_json: JSON.stringify({
|
|
590
|
+
definition_json: JSON.stringify({
|
|
591
|
+
options: declaration.json,
|
|
592
|
+
callbacks: declaration.callbacks,
|
|
593
|
+
runtime,
|
|
594
|
+
// Retirement outlives declarations that still list the field.
|
|
595
|
+
...(stored?.retired?.length ? { retired: stored.retired } : {})
|
|
596
|
+
}),
|
|
555
597
|
}
|
|
556
598
|
const unchanged = previous &&
|
|
557
599
|
Object.entries(row).every(([column, value]) => previous.resourceRow[column] === value) &&
|
|
@@ -270,22 +270,17 @@ export const readSlotPools = async (db) => {
|
|
|
270
270
|
|
|
271
271
|
export const TYPE_TO_POOL = new Map([
|
|
272
272
|
['string', 'string'],
|
|
273
|
-
['text', 'string'],
|
|
274
273
|
['uuid', 'string'],
|
|
275
274
|
['email', 'string'],
|
|
276
275
|
['file', 'string'],
|
|
277
276
|
['number', 'number'],
|
|
278
277
|
['integer', 'number'],
|
|
279
|
-
['float', 'number'],
|
|
280
|
-
['decimal', 'number'],
|
|
281
278
|
['boolean', 'boolean'],
|
|
282
279
|
['date', 'string'],
|
|
283
|
-
['datetime', 'date'],
|
|
284
280
|
['dateTime', 'date'],
|
|
285
281
|
['time', 'string'],
|
|
286
282
|
['epochMilliseconds', 'number'],
|
|
287
283
|
['epochSeconds', 'number'],
|
|
288
|
-
['json', 'json'],
|
|
289
284
|
['object', 'json'],
|
|
290
285
|
['array', 'json'],
|
|
291
286
|
])
|
|
@@ -72,6 +72,9 @@ export const composeResourceOptions = ({ declaration = {}, stored = null, local
|
|
|
72
72
|
merge('searchSchema', searchSchema, declaredSearch, local.searchSchema, hiddenFilters)
|
|
73
73
|
|
|
74
74
|
for (const name of hidden) delete schema[name]
|
|
75
|
+
for (const name of stored?.retired || []) {
|
|
76
|
+
if (Object.hasOwn(schema, name)) schema[name] = { ...schema[name], retired: true }
|
|
77
|
+
}
|
|
75
78
|
for (const [name, definition] of Object.entries(searchSchema)) {
|
|
76
79
|
if (hiddenFilters.has(name) || targetsOf(definition).some(target => hidden.has(target))) {
|
|
77
80
|
hiddenFilters.add(name)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { RestApiValidationError } from '../../../../lib/rest-api-errors.js'
|
|
2
|
+
import { applyDatabaseReadOptions, normalizeStoredFieldValue } from '../querying-writing/database-value-normalizers.js'
|
|
3
|
+
|
|
4
|
+
const NUMERIC_TYPES = new Set(['number', 'integer', 'epochMilliseconds', 'epochSeconds'])
|
|
5
|
+
const ORDERED_TYPES = new Set([...NUMERIC_TYPES, 'string', 'email', 'uuid', 'date', 'dateTime', 'time'])
|
|
6
|
+
const SCALAR_TYPES = new Set([...ORDERED_TYPES, 'boolean', 'id'])
|
|
7
|
+
// Measure functions and the field types each accepts.
|
|
8
|
+
const MEASURES = {
|
|
9
|
+
count: SCALAR_TYPES,
|
|
10
|
+
countDistinct: SCALAR_TYPES,
|
|
11
|
+
sum: NUMERIC_TYPES,
|
|
12
|
+
avg: NUMERIC_TYPES,
|
|
13
|
+
min: ORDERED_TYPES,
|
|
14
|
+
max: ORDERED_TYPES,
|
|
15
|
+
}
|
|
16
|
+
const PARAMS = new Set(['groupBy', 'measures', 'queryParams', 'transaction'])
|
|
17
|
+
const QUERY_PARAMS = new Set(['filters', 'sort', 'page'])
|
|
18
|
+
const OUTPUT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
19
|
+
const AGGREGATE_ALIAS = '__jra_aggregate'
|
|
20
|
+
|
|
21
|
+
const invalid = (field, message) => {
|
|
22
|
+
throw new RestApiValidationError(message, { fields: [field], violations: [{ field, rule: 'aggregate', message }] })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
26
|
+
|
|
27
|
+
const isDecimal = definition => definition.type === 'number' && definition.precision !== undefined && definition.scale !== undefined
|
|
28
|
+
|
|
29
|
+
const positiveInteger = (value, field) => {
|
|
30
|
+
const number = typeof value === 'string' && value.trim() !== '' ? Number(value) : value
|
|
31
|
+
if (!Number.isSafeInteger(number) || number < 1) invalid(field, `${field} must be a positive integer`)
|
|
32
|
+
return number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validate an aggregate request against a resource's compiled schema.
|
|
37
|
+
* Only stored scalar fields whose stored values are their public values qualify:
|
|
38
|
+
* getters and storage serializers would make SQL group or sum other values than clients see.
|
|
39
|
+
* @param {{ params: Record<string, any>, schemaInfo: Record<string, any>, scopeName: string, defaultLimit: number, maxLimit: number }} request
|
|
40
|
+
*/
|
|
41
|
+
export function compileAggregateRequest ({ params, schemaInfo, scopeName, defaultLimit, maxLimit }) {
|
|
42
|
+
if (!isPlainObject(params)) invalid('params', 'aggregate requires a parameter object')
|
|
43
|
+
for (const key of Object.keys(params)) {
|
|
44
|
+
if (!PARAMS.has(key)) invalid(key, `Unknown aggregate parameter '${key}'`)
|
|
45
|
+
}
|
|
46
|
+
const { groupBy = [], measures, queryParams = {} } = params
|
|
47
|
+
if (!isPlainObject(queryParams)) invalid('queryParams', 'queryParams must be an object')
|
|
48
|
+
for (const key of Object.keys(queryParams)) {
|
|
49
|
+
if (!QUERY_PARAMS.has(key)) invalid(`queryParams.${key}`, `aggregate does not support queryParams.${key}`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const fields = schemaInfo.schemaStructure || {}
|
|
53
|
+
const fieldDefinition = (name, parameter) => {
|
|
54
|
+
const definition = typeof name === 'string' && Object.hasOwn(fields, name) ? fields[name] : null
|
|
55
|
+
if (!definition || name === 'id' || name === schemaInfo.idProperty || definition.virtual === true || definition.hidden === true) {
|
|
56
|
+
invalid(parameter, `'${name}' is not an attribute of '${scopeName}' that can be aggregated`)
|
|
57
|
+
}
|
|
58
|
+
if (typeof definition.getter === 'function' || definition.storage?.serialize) {
|
|
59
|
+
invalid(parameter, `Field '${name}' transforms its stored values, so it cannot be aggregated`)
|
|
60
|
+
}
|
|
61
|
+
return definition
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!Array.isArray(groupBy)) invalid('groupBy', 'groupBy must be an array of field names')
|
|
65
|
+
const groups = groupBy.map((field, index) => {
|
|
66
|
+
const definition = fieldDefinition(field, `groupBy.${index}`)
|
|
67
|
+
if (!SCALAR_TYPES.has(definition.type)) invalid(`groupBy.${index}`, `Field '${field}' has type '${definition.type}' and cannot be grouped`)
|
|
68
|
+
return { name: field, field, definition, alias: `g${index}` }
|
|
69
|
+
})
|
|
70
|
+
if (new Set(groupBy).size !== groupBy.length) invalid('groupBy', 'groupBy lists a field twice')
|
|
71
|
+
|
|
72
|
+
if (!isPlainObject(measures) || !Object.keys(measures).length) invalid('measures', 'measures must name at least one measure')
|
|
73
|
+
const names = new Set(groupBy)
|
|
74
|
+
const measureList = Object.entries(measures).map(([name, spec], index) => {
|
|
75
|
+
const parameter = `measures.${name}`
|
|
76
|
+
if (!OUTPUT_NAME.test(name)) invalid(parameter, `Measure name '${name}' must be a letter or underscore followed by letters, digits or underscores`)
|
|
77
|
+
if (names.has(name)) invalid(parameter, `Measure '${name}' has the name of a grouped field`)
|
|
78
|
+
names.add(name)
|
|
79
|
+
const entries = isPlainObject(spec) ? Object.entries(spec) : []
|
|
80
|
+
if (entries.length !== 1 || !Object.hasOwn(MEASURES, entries[0][0])) {
|
|
81
|
+
invalid(parameter, `Measure '${name}' must be one of { ${Object.keys(MEASURES).join(' | ')}: field }`)
|
|
82
|
+
}
|
|
83
|
+
const [fn, field] = entries[0]
|
|
84
|
+
if (field === '*') {
|
|
85
|
+
if (fn !== 'count') invalid(parameter, "Only count accepts '*'")
|
|
86
|
+
return { name, fn, field, definition: null, alias: `m${index}` }
|
|
87
|
+
}
|
|
88
|
+
const definition = fieldDefinition(field, parameter)
|
|
89
|
+
if (!MEASURES[fn].has(definition.type)) invalid(parameter, `${fn} cannot be applied to field '${field}' of type '${definition.type}'`)
|
|
90
|
+
return { name, fn, field, definition, alias: `m${index}` }
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const outputs = new Map([...groups, ...measureList].map(output => [output.name, output.alias]))
|
|
94
|
+
const sortParam = queryParams.sort ?? []
|
|
95
|
+
if (!Array.isArray(sortParam)) invalid('queryParams.sort', 'queryParams.sort must be an array')
|
|
96
|
+
const sort = sortParam.map((entry, index) => {
|
|
97
|
+
const name = typeof entry === 'string' ? entry.replace(/^-/, '') : null
|
|
98
|
+
if (!outputs.has(name)) invalid(`queryParams.sort.${index}`, `Sort '${entry}' must name a grouped field or a measure`)
|
|
99
|
+
return { alias: outputs.get(name), direction: entry.startsWith('-') ? 'desc' : 'asc' }
|
|
100
|
+
})
|
|
101
|
+
// Grouped fields break ties, so pages never overlap.
|
|
102
|
+
for (const { alias } of groups) {
|
|
103
|
+
if (!sort.some(entry => entry.alias === alias)) sort.push({ alias, direction: 'asc' })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let page = null
|
|
107
|
+
if (groups.length) {
|
|
108
|
+
const pageParam = queryParams.page ?? {}
|
|
109
|
+
if (!isPlainObject(pageParam)) invalid('queryParams.page', 'queryParams.page must be an object')
|
|
110
|
+
for (const key of Object.keys(pageParam)) {
|
|
111
|
+
if (!['size', 'number'].includes(key)) invalid(`queryParams.page.${key}`, `aggregate pages support only size and number, not ${key}`)
|
|
112
|
+
}
|
|
113
|
+
const size = pageParam.size === undefined ? defaultLimit : positiveInteger(pageParam.size, 'queryParams.page.size')
|
|
114
|
+
if (size > maxLimit) invalid('queryParams.page.size', `queryParams.page.size cannot exceed ${maxLimit}`)
|
|
115
|
+
const number = pageParam.number === undefined ? 1 : positiveInteger(pageParam.number, 'queryParams.page.number')
|
|
116
|
+
page = { size, number }
|
|
117
|
+
} else if (queryParams.page !== undefined) {
|
|
118
|
+
invalid('queryParams.page', 'An aggregate without groupBy returns one row and takes no page')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { groups, measures: measureList, sort, page, scopeName }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const measureValue = (value, measure, resourceType) => {
|
|
125
|
+
if (measure.fn === 'count' || measure.fn === 'countDistinct') return Number(value ?? 0)
|
|
126
|
+
if (value == null) return null
|
|
127
|
+
if (measure.fn === 'min' || measure.fn === 'max') return normalizeStoredFieldValue(value, measure.definition, { fieldName: measure.field, resourceType })
|
|
128
|
+
// Drivers return numeric aggregates as strings; decimals keep theirs to avoid rounding.
|
|
129
|
+
return isDecimal(measure.definition) ? value : Number(value)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Run a compiled aggregate over the rows a filtered query selects.
|
|
134
|
+
* The filtered query only supplies identifiers, so joins added by filters or
|
|
135
|
+
* policies cannot repeat a row inside a group.
|
|
136
|
+
* @param {{ source: import('knex').Knex.QueryBuilder, sourceTable: string, storageAdapter: Record<string, any>, db: import('knex').Knex | import('knex').Knex.Transaction, plan: ReturnType<typeof compileAggregateRequest> }} request
|
|
137
|
+
*/
|
|
138
|
+
export async function runAggregateQuery ({ source, sourceTable, storageAdapter, db, plan }) {
|
|
139
|
+
const idColumn = storageAdapter.getIdColumn()
|
|
140
|
+
const ids = source.clearSelect().clearOrder().select(`${sourceTable}.${idColumn}`)
|
|
141
|
+
const query = storageAdapter.buildBaseQuery({ transaction: db, tableAlias: AGGREGATE_ALIAS })
|
|
142
|
+
.whereIn(`${AGGREGATE_ALIAS}.${idColumn}`, ids)
|
|
143
|
+
const column = field => `${AGGREGATE_ALIAS}.${storageAdapter.translateColumn(field)}`
|
|
144
|
+
|
|
145
|
+
for (const group of plan.groups) {
|
|
146
|
+
query.select({ [group.alias]: column(group.field) }).groupBy(column(group.field))
|
|
147
|
+
}
|
|
148
|
+
for (const measure of plan.measures) {
|
|
149
|
+
query[measure.fn]({ [measure.alias]: measure.field === '*' ? '*' : column(measure.field) })
|
|
150
|
+
}
|
|
151
|
+
// NULL sorts as the lowest value, as MySQL and SQLite order it by default.
|
|
152
|
+
const pg = ['pg', 'postgresql'].includes(db.client.config.client)
|
|
153
|
+
for (const { alias, direction } of plan.sort) {
|
|
154
|
+
if (pg) query.orderBy(alias, direction, direction === 'asc' ? 'first' : 'last')
|
|
155
|
+
else query.orderBy(alias, direction)
|
|
156
|
+
}
|
|
157
|
+
if (plan.page) query.limit(plan.page.size + 1).offset((plan.page.number - 1) * plan.page.size)
|
|
158
|
+
|
|
159
|
+
const rows = await applyDatabaseReadOptions(query)
|
|
160
|
+
const hasMore = Boolean(plan.page && rows.length > plan.page.size)
|
|
161
|
+
if (hasMore) rows.pop()
|
|
162
|
+
|
|
163
|
+
const data = rows.map(row => {
|
|
164
|
+
const result = {}
|
|
165
|
+
for (const group of plan.groups) result[group.name] = normalizeStoredFieldValue(row[group.alias], group.definition, { fieldName: group.field, resourceType: plan.scopeName })
|
|
166
|
+
for (const measure of plan.measures) result[measure.name] = measureValue(row[measure.alias], measure, plan.scopeName)
|
|
167
|
+
return result
|
|
168
|
+
})
|
|
169
|
+
return plan.page ? { data, meta: { page: { ...plan.page, hasMore } } } : { data }
|
|
170
|
+
}
|
|
@@ -5,6 +5,7 @@ import { compileQueryFields } from './query-field-helpers.js'
|
|
|
5
5
|
import { buildEffectiveSortList } from '../querying/query-field-sort-helpers.js'
|
|
6
6
|
import { assertScalarQueryField, assertFieldNameMap } from './field-utils.js'
|
|
7
7
|
import { validateRelationshipFieldNames, validateSchemaFieldNames } from './scope-validations.js'
|
|
8
|
+
import { assertKnownFieldTypes, installFieldTypes } from './field-types.js'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Compile one resource and publish its completed metadata in vars.schemaInfo.
|
|
@@ -74,7 +75,22 @@ export async function compileSchemas (scope, deps) {
|
|
|
74
75
|
assertFieldNameMap(schemaContext.queryFields, `query fields in '${scopeName}'`)
|
|
75
76
|
|
|
76
77
|
// Create schema object
|
|
78
|
+
// A retired field keeps its stored values readable and accepts no new ones.
|
|
79
|
+
for (const [fieldName, fieldDef] of Object.entries(schemaContext.fields)) {
|
|
80
|
+
if (fieldDef.retired === undefined) continue
|
|
81
|
+
if (typeof fieldDef.retired !== 'boolean') throw new Error(`Field '${fieldName}' in resource '${scopeName}' has a non-boolean retired option`)
|
|
82
|
+
if (fieldDef.retired && (fieldDef.belongsTo || fieldDef.virtual === true)) {
|
|
83
|
+
throw new Error(`Field '${fieldName}' in resource '${scopeName}' cannot be retired: only stored attributes can`)
|
|
84
|
+
}
|
|
85
|
+
if (fieldDef.retired) {
|
|
86
|
+
delete fieldDef.required
|
|
87
|
+
delete fieldDef.defaultTo
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
installFieldTypes()
|
|
77
92
|
const schemaObject = createSchema(schemaContext.fields)
|
|
93
|
+
assertKnownFieldTypes(schemaObject, `resource '${scopeName}'`)
|
|
78
94
|
|
|
79
95
|
// Generate searchSchema by merging explicit searchSchema with fields marked search:true.
|
|
80
96
|
// This allows two ways to define searchable fields: either mark fields with search:true
|
|
@@ -98,6 +114,7 @@ export async function compileSchemas (scope, deps) {
|
|
|
98
114
|
assertFieldNameMap(searchSchemaContext.fields, `search schema in '${scopeName}'`)
|
|
99
115
|
ensureSearchFieldsAreIndexed(searchSchemaContext.fields)
|
|
100
116
|
const searchSchemaObject = createSchema(searchSchemaContext.fields)
|
|
117
|
+
assertKnownFieldTypes(searchSchemaObject, `the search schema of '${scopeName}'`)
|
|
101
118
|
|
|
102
119
|
// Derived fields must be rebuilt from the current candidate before publication.
|
|
103
120
|
const idProperty = scopeOptions.idProperty || scope.vars.idProperty || 'id'
|
|
@@ -416,6 +416,22 @@ export function normalizeAttributes (attributes, schemaStructure, {
|
|
|
416
416
|
return normalized
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
+
/**
|
|
420
|
+
* Convert one value read from a field's storage column to its public value,
|
|
421
|
+
* without the field's getter.
|
|
422
|
+
* @param {unknown} value
|
|
423
|
+
* @param {Record<string, any>} definition
|
|
424
|
+
* @param {{ fieldName: string, resourceType?: string }} options
|
|
425
|
+
*/
|
|
426
|
+
export function normalizeStoredFieldValue (value, definition, { fieldName, resourceType }) {
|
|
427
|
+
if (value == null) return null
|
|
428
|
+
if (definition.type === 'id') return String(value)
|
|
429
|
+
const decimal = definition.precision !== undefined && definition.scale !== undefined
|
|
430
|
+
// Drivers return some numeric columns as strings; decimals keep theirs to avoid rounding.
|
|
431
|
+
if (['number', 'integer'].includes(definition.type) && typeof value === 'string' && !decimal) return Number(value)
|
|
432
|
+
return normalizeAttributes({ [fieldName]: value }, { [fieldName]: definition }, { resourceType })[fieldName]
|
|
433
|
+
}
|
|
434
|
+
|
|
419
435
|
/**
|
|
420
436
|
* Normalize a JSON:API document or one plain resource using compiled output fields.
|
|
421
437
|
* JSON:API primary/included attributes are replaced on the supplied document;
|
|
@@ -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
|
}
|
|
@@ -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,
|
|
@@ -307,6 +307,27 @@ export interface DataReadHelpers<GetResult> {
|
|
|
307
307
|
queryParams: NonNullable<DataReadContext['queryParams']>
|
|
308
308
|
}
|
|
309
309
|
}) => Promise<DataDocument<DataResource[]>>
|
|
310
|
+
// The plan comes from compileAggregateRequest; filters are in context.queryParams.
|
|
311
|
+
dataAggregate: (request: DataReadRequest & {
|
|
312
|
+
runHooks: NonNullable<DataReadRequest['runHooks']>
|
|
313
|
+
context: DataReadContext & {
|
|
314
|
+
queryParams: NonNullable<DataReadContext['queryParams']>
|
|
315
|
+
}
|
|
316
|
+
plan: AggregatePlan
|
|
317
|
+
}) => Promise<AggregateResult>
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Compiled by compileAggregateRequest: outputs carry SQL aliases.
|
|
321
|
+
export interface AggregatePlan {
|
|
322
|
+
scopeName: string
|
|
323
|
+
groups: Array<{ name: string; field: string; definition: StorageFieldDefinition; alias: string }>
|
|
324
|
+
measures: Array<{ name: string; fn: 'count' | 'countDistinct' | 'sum' | 'avg' | 'min' | 'max'; field: string; definition: StorageFieldDefinition | null; alias: string }>
|
|
325
|
+
sort: Array<{ alias: string; direction: 'asc' | 'desc' }>
|
|
326
|
+
page: { size: number; number: number } | null
|
|
327
|
+
}
|
|
328
|
+
export interface AggregateResult {
|
|
329
|
+
data: Array<Record<string, unknown>>
|
|
330
|
+
meta?: { page: { size: number; number: number; hasMore: boolean } }
|
|
310
331
|
}
|
|
311
332
|
|
|
312
333
|
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.
|
|
@@ -252,6 +259,7 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
252
259
|
for (const { resource } of await registry.listResources(tenantId)) await loadResource(resource)
|
|
253
260
|
return api.resources
|
|
254
261
|
}
|
|
262
|
+
api.anyapi.getSchemaVersion = resource => registry.getSchemaVersion(tenantId, resource)
|
|
255
263
|
api.anyapi.handleSchemaChange = async ({ tenant, resource, version } = {}) => {
|
|
256
264
|
if ((tenant !== undefined && tenant !== tenantId) || typeof resource !== 'string' || !Object.hasOwn(api.resources, resource)) return false
|
|
257
265
|
if (version !== undefined && publishedVersion(resource) >= version) return false
|
|
@@ -330,49 +338,134 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
330
338
|
|
|
331
339
|
addResourceMethod('reloadSchema', async ({ scopeName }) => reload(scopeName))
|
|
332
340
|
|
|
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
|
|
341
|
+
// Compile the resource with the additions, so allocation stores enriched definitions.
|
|
342
|
+
const prepareAdditions = async (scopeName, { fields, searchSchema = {}, canonicalFieldsMap }) => {
|
|
343
|
+
const current = composeOptions(scopeName, api.resources[scopeName].vars.schemaInfo.descriptor).options
|
|
348
344
|
const candidate = snapshotResourceConfiguration({
|
|
349
345
|
...current,
|
|
350
346
|
schema: { ...current.schema, ...fields },
|
|
351
347
|
searchSchema: { ...current.searchSchema, ...searchSchema }
|
|
352
348
|
})
|
|
353
349
|
const schemaInfo = await compileOptions(scopeName, candidate)
|
|
354
|
-
|
|
350
|
+
const allocation = {
|
|
355
351
|
tenant: tenantId,
|
|
356
352
|
resource: scopeName,
|
|
357
353
|
fields: Object.fromEntries(Object.keys(fields).map(fieldName => [fieldName, {
|
|
358
354
|
definition: schemaInfo.schemaStructure[fieldName] || schemaInfo.computed[fieldName],
|
|
359
355
|
declaration: fields[fieldName],
|
|
360
|
-
canonicalField:
|
|
356
|
+
canonicalField: canonicalFieldsMap?.[fieldName]
|
|
361
357
|
}])),
|
|
362
358
|
searchSchema
|
|
363
|
-
}
|
|
359
|
+
}
|
|
360
|
+
return { candidate, schemaInfo, allocation }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const publishChange = async (scopeName, { fields = {}, searchSchema = {}, compiled } = {}) => {
|
|
364
364
|
registry.invalidateDescriptor(tenantId, scopeName)
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
365
|
+
if (Object.keys(fields).length || Object.keys(searchSchema).length) {
|
|
366
|
+
const local = runtimeDeclarations.get(scopeName) || {}
|
|
367
|
+
runtimeDeclarations.set(scopeName, {
|
|
368
|
+
schema: { ...local.schema, ...fields },
|
|
369
|
+
searchSchema: { ...local.searchSchema, ...searchSchema }
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
// The fresh descriptor also carries changes other servers made meanwhile.
|
|
371
373
|
const descriptor = await registry.getDescriptor(tenantId, scopeName)
|
|
372
|
-
await publish(scopeName, descriptor, { compiled
|
|
374
|
+
await publish(scopeName, descriptor, { compiled })
|
|
373
375
|
await notifySchemaChanged(scopeName, descriptor.schemaVersion)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
addResourceMethod('addKnexFields', async ({ scopeName, params }) => {
|
|
379
|
+
if (!params?.fields || typeof params.fields !== 'object' || Array.isArray(params.fields)) {
|
|
380
|
+
throw new Error('fields parameter is required for addKnexFields')
|
|
381
|
+
}
|
|
382
|
+
assertFieldNameMap(params.fields, `added fields in '${scopeName}'`)
|
|
383
|
+
assertFieldNameMap(params.searchSchema, `added search schema in '${scopeName}'`)
|
|
384
|
+
assertFieldNameMap(params.canonicalFieldsMap, `added canonical fields in '${scopeName}'`)
|
|
385
|
+
const unknown = Object.keys(params.canonicalFieldsMap || {}).filter(field => !Object.hasOwn(params.fields, field))
|
|
386
|
+
if (unknown.length) {
|
|
387
|
+
throw new Error(`canonicalFieldsMap contains unknown fields for addKnexFields: ${unknown}`)
|
|
388
|
+
}
|
|
389
|
+
const fields = snapshotResourceConfiguration(params.fields)
|
|
390
|
+
const searchSchema = snapshotResourceConfiguration(params.searchSchema || {})
|
|
391
|
+
const { candidate, schemaInfo, allocation } = await prepareAdditions(scopeName, { fields, searchSchema, canonicalFieldsMap: params.canonicalFieldsMap })
|
|
392
|
+
await api.transaction(transaction => registry.allocateFields(allocation, { transaction }))
|
|
393
|
+
await publishChange(scopeName, { fields, searchSchema, compiled: { signature: optionsSignature(candidate), schemaInfo } })
|
|
374
394
|
})
|
|
375
395
|
|
|
396
|
+
const retirable = (scopeName, fieldName) => {
|
|
397
|
+
const definition = api.resources[scopeName].vars.schemaInfo.schemaStructure[fieldName]
|
|
398
|
+
if (!definition || fieldName === 'id' || definition.virtual === true || definition.belongsTo) {
|
|
399
|
+
throw new Error(`'${scopeName}.${fieldName}' is not a stored attribute`)
|
|
400
|
+
}
|
|
401
|
+
return definition
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
addResourceMethod('retireKnexFields', async ({ scopeName, params }) => {
|
|
405
|
+
const fields = Array.isArray(params) ? params : params?.fields
|
|
406
|
+
if (!Array.isArray(fields) || !fields.length) throw new Error('retireKnexFields requires an array of field names')
|
|
407
|
+
fields.forEach(fieldName => retirable(scopeName, fieldName))
|
|
408
|
+
await registry.retireFields({ tenant: tenantId, resource: scopeName, fields })
|
|
409
|
+
await publishChange(scopeName)
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
// Change a field's type: copy its values into a new field, then retire it.
|
|
413
|
+
addResourceMethod('migrateField', async ({ scopeName, params }) => {
|
|
414
|
+
const { from, to, transform } = params || {}
|
|
415
|
+
const { name, ...declaration } = to || {}
|
|
416
|
+
if (typeof from !== 'string' || typeof name !== 'string' || !name) {
|
|
417
|
+
throw new Error('migrateField requires from and to: { name, ...definition }')
|
|
418
|
+
}
|
|
419
|
+
if (transform !== undefined && typeof transform !== 'function') throw new Error('migrateField transform must be a function')
|
|
420
|
+
const fromDefinition = retirable(scopeName, from)
|
|
421
|
+
if (fromDefinition.retired) throw new Error(`Field '${scopeName}.${from}' is already retired`)
|
|
422
|
+
if (declaration.computed || declaration.virtual || declaration.belongsTo || declaration.getter || declaration.setter || declaration.storage) {
|
|
423
|
+
throw new Error(`migrateField target '${name}' must be a plain stored attribute; add other fields with addKnexFields`)
|
|
424
|
+
}
|
|
425
|
+
assertFieldNameMap({ [name]: declaration }, `migrated field in '${scopeName}'`)
|
|
426
|
+
const fields = snapshotResourceConfiguration({ [name]: declaration })
|
|
427
|
+
const { schemaInfo, allocation } = await prepareAdditions(scopeName, { fields })
|
|
428
|
+
|
|
429
|
+
await api.transaction(async (transaction) => {
|
|
430
|
+
const descriptor = await registry.allocateFields(allocation, { transaction })
|
|
431
|
+
await copyFieldValues({ transaction, descriptor, schemaInfo, from, fromDefinition, to: name, transform })
|
|
432
|
+
await registry.retireFields({ tenant: tenantId, resource: scopeName, fields: [from] }, { transaction })
|
|
433
|
+
})
|
|
434
|
+
await publishChange(scopeName, { fields })
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
const copyFieldValues = async ({ transaction, descriptor, schemaInfo, from, fromDefinition, to, transform }) => {
|
|
438
|
+
const { canonical } = descriptor
|
|
439
|
+
const source = descriptor.fields[from].slot
|
|
440
|
+
const target = descriptor.fields[to].slot
|
|
441
|
+
const idColumn = canonical.logicalIdColumn
|
|
442
|
+
for (let lastId = null; ;) {
|
|
443
|
+
const query = transaction(canonical.tableName)
|
|
444
|
+
.where(canonical.tenantColumn, tenantId)
|
|
445
|
+
.where(canonical.resourceColumn, descriptor.resource)
|
|
446
|
+
.orderBy('id')
|
|
447
|
+
.limit(MIGRATION_BATCH_SIZE)
|
|
448
|
+
.select('id', idColumn, source)
|
|
449
|
+
if (lastId !== null) query.where('id', '>', lastId)
|
|
450
|
+
const rows = await applyDatabaseReadOptions(query)
|
|
451
|
+
if (!rows.length) return
|
|
452
|
+
for (const row of rows) {
|
|
453
|
+
lastId = row.id
|
|
454
|
+
const value = normalizeStoredFieldValue(row[source], fromDefinition, { fieldName: from, resourceType: descriptor.resource })
|
|
455
|
+
const next = transform ? await transform(value, { id: String(row[idColumn]) }) : value
|
|
456
|
+
const { validatedObject, errors } = await schemaInfo.schemaInstance.patch({ [to]: next ?? null })
|
|
457
|
+
if (Object.keys(errors).length) {
|
|
458
|
+
const message = `Record '${row[idColumn]}' cannot migrate '${from}' to '${to}': ${Object.values(errors)[0].message}`
|
|
459
|
+
throw new RestApiValidationError(message, { fields: [to], violations: [{ field: to, rule: 'field_migration', message }] })
|
|
460
|
+
}
|
|
461
|
+
const stored = translateCanonicalAttributesForStorage({ [to]: validatedObject[to] ?? null }, descriptor, {
|
|
462
|
+
schemaInfo, databaseClient: knex.client.config.client
|
|
463
|
+
})
|
|
464
|
+
await transaction(canonical.tableName).where('id', row.id).update({ [target]: stored[target] ?? null })
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
376
469
|
addResourceMethod('alterKnexFields', async () => {
|
|
377
470
|
throw new Error('alterKnexFields is not supported by AnyAPI Knex plugin yet')
|
|
378
471
|
})
|
|
@@ -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
|
@@ -23,6 +23,8 @@ export class AnyapiRegistry {
|
|
|
23
23
|
readonly cacheSize: number
|
|
24
24
|
ensureSchema(options?: { slots?: AnyapiSlotCounts }): Promise<void>
|
|
25
25
|
getSchemaVersion(tenant: string, resource: string, options?: { transaction?: Knex.Transaction }): Promise<number | null>
|
|
26
|
+
// Retired fields keep their slots and values and reject new writes.
|
|
27
|
+
retireFields(params: { tenant: string; resource: string; fields: string[] }, options?: { transaction?: Knex.Transaction }): Promise<Record<string, unknown>>
|
|
26
28
|
invalidateDescriptor(tenant: string, resource: string): void
|
|
27
29
|
}
|
|
28
30
|
|
|
@@ -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
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { StorageFieldDefinition, StorageQuery } from '../plugins/core/lib/storage/storage-types.js'
|
|
2
2
|
import type { FileStorage } from './file-storage.js'
|
|
3
3
|
|
|
4
|
-
export type ResourceFieldType = 'string' | 'number' | 'integer' | 'boolean' | 'id' |
|
|
4
|
+
export type ResourceFieldType = 'string' | 'email' | 'uuid' | 'number' | 'integer' | 'boolean' | 'id' |
|
|
5
5
|
'date' | 'time' | 'dateTime' | 'epochSeconds' | 'epochMilliseconds' |
|
|
6
6
|
'array' | 'object' | 'file' | 'blob' | 'serialize' | 'none'
|
|
7
7
|
|
|
@@ -46,6 +46,8 @@ export interface ResourceFieldOptions {
|
|
|
46
46
|
normallyHidden?: boolean
|
|
47
47
|
virtual?: boolean
|
|
48
48
|
computed?: boolean
|
|
49
|
+
// Readable and filterable, but rejects new values.
|
|
50
|
+
retired?: boolean
|
|
49
51
|
include?: ResourceRelationshipOptions['include']
|
|
50
52
|
search?: boolean | ResourceSearchOptions | Record<string, ResourceSearchOptions>
|
|
51
53
|
dependencies?: readonly string[]
|
|
@@ -113,7 +115,7 @@ type ObjectValue<Field, Mode extends Direction> =
|
|
|
113
115
|
: Field extends { values: infer Values } ? Record<string, NestedValue<Values, Mode>> : Record<string, unknown>
|
|
114
116
|
|
|
115
117
|
type BaseValue<Field, Mode extends Direction> =
|
|
116
|
-
Field extends { type: 'string' | 'file' | 'date' | 'time' | 'dateTime' } ? string
|
|
118
|
+
Field extends { type: 'string' | 'email' | 'uuid' | 'file' | 'date' | 'time' | 'dateTime' } ? string
|
|
117
119
|
: Field extends { type: 'number'; precision: number; scale: number } ? Mode extends 'output' ? string | number : number
|
|
118
120
|
: Field extends { type: 'number' | 'integer' | 'epochSeconds' | 'epochMilliseconds' } ? number
|
|
119
121
|
: Field extends { type: 'boolean' } ? boolean
|
|
@@ -147,7 +149,7 @@ type StoredNull<Field> = Field extends { required: true } | { nullable: false }
|
|
|
147
149
|
// Attribute helpers exclude the conventional ID and relationship backing fields.
|
|
148
150
|
// Hooks/defaults may supply required values, so caller input stays optional.
|
|
149
151
|
export type InferInput<Schema extends object> = {
|
|
150
|
-
-readonly [Key in keyof Schema as Schema[Key] extends { computed: true } ? never : AttributeKey<Schema, Key>]?: FieldValue<Schema[Key], 'input'>
|
|
152
|
+
-readonly [Key in keyof Schema as Schema[Key] extends { computed: true } | { retired: true } ? never : AttributeKey<Schema, Key>]?: FieldValue<Schema[Key], 'input'>
|
|
151
153
|
}
|
|
152
154
|
|
|
153
155
|
// Resource result types add optionality for sparse fields and permissions.
|