json-rest-api 2.0.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.
Files changed (29) hide show
  1. package/docs/API.md +80 -0
  2. package/docs/GUIDE/17-row-policies.md +1 -0
  3. package/docs/GUIDE/21-schema-and-migrations.md +154 -14
  4. package/docs/GUIDE/30-backend-capabilities.md +8 -5
  5. package/docs/GUIDE/33-migrating-to-v2.md +27 -7
  6. package/index.d.ts +1 -0
  7. package/index.js +2 -0
  8. package/lib/knex-transaction.js +10 -4
  9. package/package.json +1 -1
  10. package/plugins/core/lib/anyapi/anyapi-registry.js +569 -408
  11. package/plugins/core/lib/anyapi/schema-utils.js +101 -51
  12. package/plugins/core/lib/anyapi/stored-definition.js +112 -0
  13. package/plugins/core/lib/querying/aggregate-query.js +170 -0
  14. package/plugins/core/lib/querying-writing/compile-schemas.js +17 -0
  15. package/plugins/core/lib/querying-writing/database-value-normalizers.js +16 -0
  16. package/plugins/core/lib/querying-writing/default-data-helpers.js +4 -0
  17. package/plugins/core/lib/querying-writing/field-types.js +43 -0
  18. package/plugins/core/lib/querying-writing/request-contracts.js +1 -1
  19. package/plugins/core/lib/storage/canonical-data-helpers.js +29 -0
  20. package/plugins/core/lib/storage/ordinary-data-helpers.js +25 -0
  21. package/plugins/core/lib/storage/storage-types.d.ts +21 -0
  22. package/plugins/core/rest-api-anyapi-knex-plugin.js +331 -54
  23. package/plugins/core/rest-api-plugin-methods/aggregate.js +41 -0
  24. package/plugins/core/rest-api-plugin-methods/common.js +16 -1
  25. package/plugins/core/rest-api-plugin.js +2 -0
  26. package/types/anyapi.d.ts +32 -0
  27. package/types/plugin-options.d.ts +12 -0
  28. package/types/resource-methods.d.ts +19 -0
  29. package/types/resource-schema.d.ts +5 -3
@@ -1,19 +1,42 @@
1
1
  import { hasKnexTableIndex } from '../dbIntrospection.js'
2
2
 
3
- const STRING_SLOT_COUNT = 10
4
- const NUMBER_SLOT_COUNT = 10
5
- const BOOLEAN_SLOT_COUNT = 5
6
- const DATE_SLOT_COUNT = 5
7
- const JSON_SLOT_COUNT = 5
8
- const BELONGS_TO_SLOT_COUNT = 5
9
-
10
- export const SLOT_LIMITS = {
11
- string: STRING_SLOT_COUNT,
12
- number: NUMBER_SLOT_COUNT,
13
- boolean: BOOLEAN_SLOT_COUNT,
14
- date: DATE_SLOT_COUNT,
15
- json: JSON_SLOT_COUNT,
16
- belongsTo: BELONGS_TO_SLOT_COUNT,
3
+ // Default layout of a new any_records table. The registry reads the actual
4
+ // columns, so a migration that adds slots raises the limits for every server.
5
+ export const SLOT_LIMITS = Object.freeze({
6
+ string: 10,
7
+ number: 10,
8
+ boolean: 5,
9
+ date: 5,
10
+ json: 5,
11
+ belongsTo: 5,
12
+ })
13
+
14
+ const SLOT_COLUMNS = {
15
+ string: index => [`string_${index}`],
16
+ number: index => [`number_${index}`],
17
+ boolean: index => [`boolean_${index}`],
18
+ date: index => [`date_${index}`],
19
+ json: index => [`json_${index}`],
20
+ belongsTo: index => [`rel_${index}_id`, `rel_${index}_type`],
21
+ }
22
+
23
+ const addSlotColumn = (table, column) => {
24
+ if (column.startsWith('number_')) table.double(column)
25
+ else if (column.startsWith('boolean_')) table.boolean(column)
26
+ else if (column.startsWith('date_')) table.dateTime(column, { precision: 6 })
27
+ else if (column.startsWith('rel_')) table.string(column)
28
+ else table.text(column)
29
+ }
30
+
31
+ const resolveSlotCounts = (slots = {}) => {
32
+ if (!slots || typeof slots !== 'object' || Array.isArray(slots)) {
33
+ throw new Error('AnyAPI slots must be an object of slot counts')
34
+ }
35
+ for (const [pool, count] of Object.entries(slots)) {
36
+ if (!Object.hasOwn(SLOT_LIMITS, pool)) throw new Error(`Unknown AnyAPI slot pool '${pool}'`)
37
+ if (!Number.isInteger(count) || count < 1) throw new Error(`AnyAPI ${pool} slot count must be a positive integer`)
38
+ }
39
+ return { ...SLOT_LIMITS, ...slots }
17
40
  }
18
41
 
19
42
  export const DEFAULT_CANONICAL_CONFIG = {
@@ -58,32 +81,23 @@ const ensureUniqueIndex = async (knex, tableName, columns, indexName) => {
58
81
  }
59
82
  }
60
83
 
61
- export const ensureAnyApiSchema = async (knex) => {
84
+ /**
85
+ * Create or upgrade the shared canonical tables. Run it from a migration, or let
86
+ * the plugin run it at start-up. `slots` only adds missing slot columns; lowering
87
+ * a count never drops columns that may hold data.
88
+ * @param {import('knex').Knex} knex
89
+ * @param {{ slots?: Partial<Record<keyof typeof SLOT_LIMITS, number>> }} [options]
90
+ */
91
+ export const ensureAnyApiSchema = async (knex, { slots } = {}) => {
92
+ const counts = resolveSlotCounts(slots)
62
93
  await ensureTable(knex, 'any_records', (table) => {
63
94
  table.increments('id').primary()
64
95
  table.string('logical_id')
65
96
  table.string('tenant_id').notNullable()
66
97
  table.string('resource').notNullable()
67
98
 
68
- for (let index = 1; index <= STRING_SLOT_COUNT; index += 1) {
69
- table.text(`string_${index}`)
70
- }
71
- for (let index = 1; index <= NUMBER_SLOT_COUNT; index += 1) {
72
- table.double(`number_${index}`)
73
- }
74
- for (let index = 1; index <= BOOLEAN_SLOT_COUNT; index += 1) {
75
- table.boolean(`boolean_${index}`)
76
- }
77
- for (let index = 1; index <= DATE_SLOT_COUNT; index += 1) {
78
- table.dateTime(`date_${index}`, { precision: 6 })
79
- }
80
- for (let index = 1; index <= JSON_SLOT_COUNT; index += 1) {
81
- table.text(`json_${index}`)
82
- }
83
-
84
- for (let index = 1; index <= BELONGS_TO_SLOT_COUNT; index += 1) {
85
- table.string(`rel_${index}_id`)
86
- table.string(`rel_${index}_type`)
99
+ for (const [pool, count] of Object.entries(counts)) {
100
+ for (let index = 1; index <= count; index += 1) SLOT_COLUMNS[pool](index).forEach(column => addSlotColumn(table, column))
87
101
  }
88
102
 
89
103
  table.dateTime('created_at').defaultTo(knex.fn.now()).notNullable()
@@ -95,15 +109,25 @@ export const ensureAnyApiSchema = async (knex) => {
95
109
  table.index(['resource'])
96
110
  })
97
111
 
112
+ if (slots) {
113
+ const existing = new Set(Object.keys(await knex('any_records').columnInfo()))
114
+ const missing = Object.entries(counts).flatMap(([pool, count]) => Array.from({ length: count }, (_, index) => SLOT_COLUMNS[pool](index + 1)))
115
+ .flat()
116
+ .filter(column => !existing.has(column))
117
+ if (missing.length) {
118
+ await knex.schema.alterTable('any_records', (table) => missing.forEach(column => addSlotColumn(table, column)))
119
+ }
120
+ }
121
+
98
122
  const client = knex.client.config.client
99
123
  if (['pg', 'postgresql', 'mysql', 'mysql2'].includes(client)) {
100
- const columns = await knex('information_schema.columns')
124
+ const columns = (await knex('information_schema.columns')
101
125
  .select({ column: 'column_name', precision: 'datetime_precision' })
102
126
  .where('table_schema', knex.raw(client === 'pg' || client === 'postgresql' ? 'current_schema()' : 'DATABASE()'))
103
- .where('table_name', 'any_records')
104
- .whereIn('column_name', Array.from({ length: DATE_SLOT_COUNT }, (_, index) => `date_${index + 1}`))
105
- if (columns.length !== DATE_SLOT_COUNT || columns.some(column => Number(column.precision) !== 6)) {
106
- throw new Error('AnyAPI temporal storage migration required: any_records.date_1 through date_5 must use timestamp/datetime precision 6. See 33-migrating-to-v2.md.')
127
+ .where('table_name', 'any_records'))
128
+ .filter(column => /^date_\d+$/.test(column.column))
129
+ if (!columns.length || columns.some(column => Number(column.precision) !== 6)) {
130
+ throw new Error('AnyAPI temporal storage migration required: every any_records date slot must use timestamp/datetime precision 6. See 33-migrating-to-v2.md.')
107
131
  }
108
132
  }
109
133
 
@@ -154,11 +178,20 @@ export const ensureAnyApiSchema = async (knex) => {
154
178
  table.string('resource').notNullable()
155
179
  table.text('schema_json').notNullable()
156
180
  table.text('relationships_json').notNullable()
181
+ // Holds the search schema and runtime additions too: MySQL TEXT stops at 64 KB.
182
+ table.text('definition_json', 'longtext')
183
+ table.integer('schema_version').notNullable().defaultTo(1)
157
184
  table.dateTime('created_at').defaultTo(knex.fn.now()).notNullable()
158
185
  table.dateTime('updated_at').defaultTo(knex.fn.now()).notNullable()
159
186
 
160
187
  table.unique(['tenant_id', 'resource'])
161
188
  })
189
+ await ensureColumn(knex, 'any_resource_configs', 'definition_json', (table) => {
190
+ table.text('definition_json', 'longtext')
191
+ })
192
+ await ensureColumn(knex, 'any_resource_configs', 'schema_version', (table) => {
193
+ table.integer('schema_version').notNullable().defaultTo(1)
194
+ })
162
195
 
163
196
  await ensureTable(knex, 'any_field_configs', (table) => {
164
197
  table.increments('id').primary()
@@ -177,8 +210,20 @@ export const ensureAnyApiSchema = async (knex) => {
177
210
  table.dateTime('updated_at').defaultTo(knex.fn.now()).notNullable()
178
211
 
179
212
  table.unique(['resource_config_id', 'field_name'])
213
+ table.unique(['resource_config_id', 'slot_column'], 'any_field_configs_resource_slot_unique')
180
214
  table.index(['resource_config_id'])
181
215
  })
216
+ if (!await hasKnexTableIndex(knex, 'any_field_configs', 'any_field_configs_resource_slot_unique')) {
217
+ const shared = await knex('any_field_configs')
218
+ .select('resource_config_id', 'slot_column')
219
+ .groupBy('resource_config_id', 'slot_column')
220
+ .havingRaw('count(*) > 1')
221
+ .first()
222
+ if (shared) {
223
+ throw new Error(`AnyAPI slot metadata repair required: several fields of resource config ${shared.resource_config_id} share ${shared.slot_column}. Reassign them before upgrading.`)
224
+ }
225
+ await ensureUniqueIndex(knex, 'any_field_configs', ['resource_config_id', 'slot_column'], 'any_field_configs_resource_slot_unique')
226
+ }
182
227
 
183
228
  await ensureTable(knex, 'any_relationship_configs', (table) => {
184
229
  table.increments('id').primary()
@@ -204,33 +249,38 @@ export const ensureAnyApiSchema = async (knex) => {
204
249
  })
205
250
  }
206
251
 
207
- export const SLOT_POOLS = {
208
- string: Array.from({ length: STRING_SLOT_COUNT }, (_, index) => `string_${index + 1}`),
209
- number: Array.from({ length: NUMBER_SLOT_COUNT }, (_, index) => `number_${index + 1}`),
210
- boolean: Array.from({ length: BOOLEAN_SLOT_COUNT }, (_, index) => `boolean_${index + 1}`),
211
- date: Array.from({ length: DATE_SLOT_COUNT }, (_, index) => `date_${index + 1}`),
212
- json: Array.from({ length: JSON_SLOT_COUNT }, (_, index) => `json_${index + 1}`),
213
- belongsTo: Array.from({ length: BELONGS_TO_SLOT_COUNT }, (_, index) => index + 1),
252
+ const buildSlotPools = (hasColumns) => Object.fromEntries(Object.keys(SLOT_LIMITS).map(pool => {
253
+ const indexes = []
254
+ for (let index = 1; hasColumns(pool, index); index += 1) indexes.push(index)
255
+ return [pool, pool === 'belongsTo' ? indexes : indexes.map(index => SLOT_COLUMNS[pool](index)[0])]
256
+ }))
257
+
258
+ export const SLOT_POOLS = buildSlotPools((pool, index) => index <= SLOT_LIMITS[pool])
259
+
260
+ /**
261
+ * Read the slot columns any_records actually has. Slots are numbered from 1
262
+ * without gaps; a pool ends at its first missing column.
263
+ * @param {import('knex').Knex | import('knex').Knex.Transaction} db
264
+ */
265
+ export const readSlotPools = async (db) => {
266
+ const columns = new Set(Object.keys(await db('any_records').columnInfo()))
267
+ if (!columns.size) throw new Error('AnyAPI storage tables are missing; run ensureAnyApiSchema in a migration')
268
+ return buildSlotPools((pool, index) => SLOT_COLUMNS[pool](index).every(column => columns.has(column)))
214
269
  }
215
270
 
216
271
  export const TYPE_TO_POOL = new Map([
217
272
  ['string', 'string'],
218
- ['text', 'string'],
219
273
  ['uuid', 'string'],
220
274
  ['email', 'string'],
221
275
  ['file', 'string'],
222
276
  ['number', 'number'],
223
277
  ['integer', 'number'],
224
- ['float', 'number'],
225
- ['decimal', 'number'],
226
278
  ['boolean', 'boolean'],
227
279
  ['date', 'string'],
228
- ['datetime', 'date'],
229
280
  ['dateTime', 'date'],
230
281
  ['time', 'string'],
231
282
  ['epochMilliseconds', 'number'],
232
283
  ['epochSeconds', 'number'],
233
- ['json', 'json'],
234
284
  ['object', 'json'],
235
285
  ['array', 'json'],
236
286
  ])
@@ -0,0 +1,112 @@
1
+ // Stored resource definitions keep JSON values only. The paths of values JSON
2
+ // cannot hold (callbacks, class instances, RegExps) are recorded, so a server
3
+ // loading the definition knows which fields need code it may not have.
4
+
5
+ const isPlainObject = value => {
6
+ const prototype = Object.getPrototypeOf(value)
7
+ return prototype === Object.prototype || prototype === null
8
+ }
9
+
10
+ /**
11
+ * @param {unknown} value
12
+ * @returns {{ json: any, callbacks: Array<Array<string|number>> }}
13
+ */
14
+ export const toStoredJson = (value) => {
15
+ const callbacks = []
16
+ const visit = (current, path) => {
17
+ if (current === undefined) return undefined
18
+ if (current === null || typeof current === 'string' || typeof current === 'boolean') return current
19
+ if (typeof current === 'number' && Number.isFinite(current)) return current
20
+ if (Array.isArray(current)) return current.map((item, index) => visit(item, [...path, index]) ?? null)
21
+ if (typeof current === 'object' && isPlainObject(current)) {
22
+ const result = {}
23
+ for (const [key, item] of Object.entries(current)) {
24
+ const stored = visit(item, [...path, key])
25
+ if (stored !== undefined) result[key] = stored
26
+ }
27
+ return result
28
+ }
29
+ callbacks.push(path)
30
+ return undefined
31
+ }
32
+ return { json: visit(value, []), callbacks }
33
+ }
34
+
35
+ const targetsOf = definition => definition?.oneOf || [definition?.actualField].filter(Boolean)
36
+
37
+ /**
38
+ * Combine a resource's declaration with the fields other servers added at run
39
+ * time. Declared fields win; runtime fields this server added keep its local
40
+ * callbacks. A field whose callbacks exist in no available code is hidden
41
+ * rather than served without them, because a missing serializer or setter
42
+ * would read or write the wrong values.
43
+ * @param {{ declaration?: Record<string, any>, stored?: Record<string, any> | null, local?: { schema?: Record<string, any>, searchSchema?: Record<string, any> }, fromStorage?: boolean }} input
44
+ */
45
+ export const composeResourceOptions = ({ declaration = {}, stored = null, local = {}, fromStorage = false }) => {
46
+ const hidden = new Set()
47
+ const hiddenFilters = new Set()
48
+ const declaredSchema = declaration.schema || {}
49
+ const declaredSearch = declaration.searchSchema || {}
50
+ const schema = { ...declaredSchema }
51
+ const searchSchema = { ...declaredSearch }
52
+
53
+ if (fromStorage) {
54
+ for (const path of stored?.callbacks || []) {
55
+ if (path[0] === 'schema' && path.length > 1) hidden.add(path[1])
56
+ else if (path[0] === 'searchSchema' && path.length > 1) hiddenFilters.add(path[1])
57
+ else throw new Error(`Resource option '${path.join('.')}' cannot be loaded from storage; declare the resource in code`)
58
+ }
59
+ }
60
+
61
+ const runtime = stored?.runtime || {}
62
+ const needsCode = new Set((runtime.callbacks || []).map(path => `${path[0]}.${path[1]}`))
63
+ const merge = (kind, target, declared, localDefinitions = {}, hide) => {
64
+ for (const [name, definition] of Object.entries(runtime[kind] || {})) {
65
+ if (Object.hasOwn(declared, name)) continue
66
+ if (Object.hasOwn(localDefinitions, name)) target[name] = localDefinitions[name]
67
+ else if (needsCode.has(`${kind}.${name}`)) hide.add(name)
68
+ else target[name] = definition
69
+ }
70
+ }
71
+ merge('schema', schema, declaredSchema, local.schema, hidden)
72
+ merge('searchSchema', searchSchema, declaredSearch, local.searchSchema, hiddenFilters)
73
+
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
+ }
78
+ for (const [name, definition] of Object.entries(searchSchema)) {
79
+ if (hiddenFilters.has(name) || targetsOf(definition).some(target => hidden.has(target))) {
80
+ hiddenFilters.add(name)
81
+ delete searchSchema[name]
82
+ }
83
+ }
84
+
85
+ const options = { ...declaration, schema }
86
+ if (Object.keys(searchSchema).length || declaration.searchSchema) options.searchSchema = searchSchema
87
+ if (hidden.size && Array.isArray(declaration.sortableFields)) {
88
+ options.sortableFields = declaration.sortableFields.filter(field => !hidden.has(String(field).replace(/^-/, '')))
89
+ }
90
+ return { options, hidden: [...hidden], hiddenFilters: [...hiddenFilters] }
91
+ }
92
+
93
+ /**
94
+ * Resources a stored declaration refers to. Polymorphic targets must be
95
+ * registered before the resource itself; the others can follow.
96
+ * @param {Record<string, any>} options
97
+ */
98
+ export const relatedResources = (options = {}) => {
99
+ const before = new Set()
100
+ const after = new Set()
101
+ for (const definition of Object.values(options.schema || {})) {
102
+ if (definition?.belongsTo) after.add(definition.belongsTo)
103
+ }
104
+ for (const [name, definition] of Object.entries(options.relationships || {})) {
105
+ for (const type of definition?.belongsToPolymorphic?.types || []) before.add(type)
106
+ if (definition?.type === 'hasMany' || definition?.type === 'manyToMany' || definition?.hasMany) {
107
+ after.add(definition.target || definition.hasMany || name)
108
+ }
109
+ if (definition?.through) after.add(definition.through)
110
+ }
111
+ return { before: [...before], after: [...after].filter(name => !before.has(name)) }
112
+ }
@@ -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
  }