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.
- package/docs/API.md +80 -0
- package/docs/GUIDE/17-row-policies.md +1 -0
- package/docs/GUIDE/21-schema-and-migrations.md +154 -14
- package/docs/GUIDE/30-backend-capabilities.md +8 -5
- package/docs/GUIDE/33-migrating-to-v2.md +27 -7
- package/index.d.ts +1 -0
- package/index.js +2 -0
- package/lib/knex-transaction.js +10 -4
- package/package.json +1 -1
- package/plugins/core/lib/anyapi/anyapi-registry.js +569 -408
- package/plugins/core/lib/anyapi/schema-utils.js +101 -51
- package/plugins/core/lib/anyapi/stored-definition.js +112 -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 +331 -54
- 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 +32 -0
- package/types/plugin-options.d.ts +12 -0
- package/types/resource-methods.d.ts +19 -0
- package/types/resource-schema.d.ts +5 -3
|
@@ -1,19 +1,42 @@
|
|
|
1
1
|
import { hasKnexTableIndex } from '../dbIntrospection.js'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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 (
|
|
69
|
-
|
|
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
|
-
.
|
|
105
|
-
if (columns.length
|
|
106
|
-
throw new Error('AnyAPI temporal storage migration required: any_records
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
}
|