json-rest-api 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/API.md +80 -0
- package/docs/GUIDE/17-row-policies.md +1 -0
- package/docs/GUIDE/21-schema-and-migrations.md +99 -2
- package/docs/GUIDE/30-backend-capabilities.md +3 -1
- package/docs/GUIDE/33-migrating-to-v2.md +20 -0
- package/package.json +1 -1
- package/plugins/core/lib/anyapi/anyapi-registry.js +97 -42
- package/plugins/core/lib/anyapi/schema-utils.js +168 -53
- package/plugins/core/lib/anyapi/stored-definition.js +3 -0
- package/plugins/core/lib/dbIntrospection.js +28 -0
- package/plugins/core/lib/querying/aggregate-query.js +170 -0
- package/plugins/core/lib/querying/knex-query-helpers.js +14 -4
- package/plugins/core/lib/querying-writing/compile-schemas.js +17 -0
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +16 -0
- package/plugins/core/lib/querying-writing/default-data-helpers.js +4 -0
- package/plugins/core/lib/querying-writing/field-types.js +43 -0
- package/plugins/core/lib/querying-writing/request-contracts.js +1 -1
- package/plugins/core/lib/storage/canonical-data-helpers.js +29 -0
- package/plugins/core/lib/storage/canonical-storage-mapping.js +11 -0
- package/plugins/core/lib/storage/ordinary-data-helpers.js +25 -0
- package/plugins/core/lib/storage/storage-types.d.ts +23 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +140 -31
- package/plugins/core/rest-api-plugin-methods/aggregate.js +41 -0
- package/plugins/core/rest-api-plugin-methods/common.js +16 -1
- package/plugins/core/rest-api-plugin.js +2 -0
- package/types/anyapi.d.ts +13 -2
- package/types/plugin-options.d.ts +2 -1
- package/types/resource-methods.d.ts +19 -0
- package/types/resource-schema.d.ts +5 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { hasKnexTableIndex } from '../dbIntrospection.js'
|
|
1
|
+
import { hasKnexTableIndex, listKnexTableIndexes } from '../dbIntrospection.js'
|
|
2
2
|
|
|
3
|
-
// Default layout of
|
|
4
|
-
//
|
|
3
|
+
// Default layout of any_records. The registry reads the actual columns, so a
|
|
4
|
+
// migration that adds slots raises the limits for every server.
|
|
5
5
|
export const SLOT_LIMITS = Object.freeze({
|
|
6
6
|
string: 10,
|
|
7
7
|
number: 10,
|
|
@@ -11,6 +11,18 @@ export const SLOT_LIMITS = Object.freeze({
|
|
|
11
11
|
belongsTo: 5,
|
|
12
12
|
})
|
|
13
13
|
|
|
14
|
+
// Indexed slots carry a (tenant_id, resource, slot) index. Fields declared
|
|
15
|
+
// `indexed: true` take them; every index costs each write to any_records.
|
|
16
|
+
export const INDEXED_SLOT_LIMITS = Object.freeze({
|
|
17
|
+
string: 5,
|
|
18
|
+
number: 3,
|
|
19
|
+
boolean: 0,
|
|
20
|
+
date: 2,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// Indexed string slots are VARCHAR so MySQL can index them whole.
|
|
24
|
+
export const INDEXED_STRING_LENGTH = 255
|
|
25
|
+
|
|
14
26
|
const SLOT_COLUMNS = {
|
|
15
27
|
string: index => [`string_${index}`],
|
|
16
28
|
number: index => [`number_${index}`],
|
|
@@ -20,25 +32,37 @@ const SLOT_COLUMNS = {
|
|
|
20
32
|
belongsTo: index => [`rel_${index}_id`, `rel_${index}_type`],
|
|
21
33
|
}
|
|
22
34
|
|
|
35
|
+
export const indexedSlotColumn = (pool, index) => `indexed_${pool}_${index}`
|
|
36
|
+
|
|
23
37
|
const addSlotColumn = (table, column) => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
else if (
|
|
27
|
-
else if (
|
|
38
|
+
const [, indexed, pool] = /^(indexed_)?([a-z]+)_/.exec(column)
|
|
39
|
+
if (pool === 'number') table.double(column)
|
|
40
|
+
else if (pool === 'boolean') table.boolean(column)
|
|
41
|
+
else if (pool === 'date') table.dateTime(column, { precision: 6 })
|
|
42
|
+
else if (pool === 'rel' || indexed) table.string(column, INDEXED_STRING_LENGTH)
|
|
28
43
|
else table.text(column)
|
|
29
44
|
}
|
|
30
45
|
|
|
31
|
-
const resolveSlotCounts = (slots = {}) => {
|
|
46
|
+
const resolveSlotCounts = (slots = {}, limits = SLOT_LIMITS, { label = 'slot', minimum = 1 } = {}) => {
|
|
32
47
|
if (!slots || typeof slots !== 'object' || Array.isArray(slots)) {
|
|
33
|
-
throw new Error(
|
|
48
|
+
throw new Error(`AnyAPI ${label}s must be an object of slot counts`)
|
|
34
49
|
}
|
|
35
50
|
for (const [pool, count] of Object.entries(slots)) {
|
|
36
|
-
if (!Object.hasOwn(
|
|
37
|
-
if (!Number.isInteger(count) || count <
|
|
51
|
+
if (!Object.hasOwn(limits, pool)) throw new Error(`Unknown AnyAPI ${label} pool '${pool}'`)
|
|
52
|
+
if (!Number.isInteger(count) || count < minimum) {
|
|
53
|
+
throw new Error(`AnyAPI ${pool} ${label} count must be ${minimum ? 'a positive' : 'a non-negative'} integer`)
|
|
54
|
+
}
|
|
38
55
|
}
|
|
39
|
-
return { ...
|
|
56
|
+
return { ...limits, ...slots }
|
|
40
57
|
}
|
|
41
58
|
|
|
59
|
+
const layoutColumns = (counts, indexedCounts) => [
|
|
60
|
+
...Object.entries(counts).flatMap(([pool, count]) => Array.from({ length: count }, (_, index) => SLOT_COLUMNS[pool](index + 1)).flat()),
|
|
61
|
+
...Object.entries(indexedCounts).flatMap(([pool, count]) => Array.from({ length: count }, (_, index) => indexedSlotColumn(pool, index + 1)))
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
const slotIndexName = column => `any_records_${column}_idx`
|
|
65
|
+
|
|
42
66
|
export const DEFAULT_CANONICAL_CONFIG = {
|
|
43
67
|
scopeName: 'anyapi_records',
|
|
44
68
|
tableName: 'any_records',
|
|
@@ -53,52 +77,121 @@ export const DEFAULT_CANONICAL_CONFIG = {
|
|
|
53
77
|
rightResourceColumn: 'right_resource',
|
|
54
78
|
}
|
|
55
79
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
await
|
|
80
|
+
// Several servers may start at once: DDL another one completed first is success.
|
|
81
|
+
const tolerateConcurrentDdl = async (run, done) => {
|
|
82
|
+
try {
|
|
83
|
+
await run()
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (!await done().catch(() => false)) throw error
|
|
60
86
|
}
|
|
61
87
|
}
|
|
62
88
|
|
|
89
|
+
// Returns whether the table was missing, so its indexes can be built offline.
|
|
90
|
+
const ensureTable = async (knex, tableName, tableBuilder) => {
|
|
91
|
+
if (await knex.schema.hasTable(tableName)) return false
|
|
92
|
+
await tolerateConcurrentDdl(() => knex.schema.createTable(tableName, tableBuilder), () => knex.schema.hasTable(tableName))
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
|
|
63
96
|
const ensureColumn = async (knex, tableName, columnName, columnBuilder) => {
|
|
64
97
|
const exists = await knex.schema.hasTable(tableName)
|
|
65
98
|
if (!exists) return
|
|
66
99
|
|
|
67
|
-
const hasColumn =
|
|
68
|
-
if (!hasColumn) {
|
|
69
|
-
await knex.schema.table(tableName, columnBuilder)
|
|
100
|
+
const hasColumn = () => knex.schema.hasColumn(tableName, columnName)
|
|
101
|
+
if (!await hasColumn()) {
|
|
102
|
+
await tolerateConcurrentDdl(() => knex.schema.table(tableName, columnBuilder), hasColumn)
|
|
70
103
|
}
|
|
71
104
|
}
|
|
72
105
|
|
|
73
106
|
const ensureUniqueIndex = async (knex, tableName, columns, indexName) => {
|
|
74
107
|
const exists = await knex.schema.hasTable(tableName)
|
|
75
108
|
if (!exists) return
|
|
76
|
-
const hasIndex =
|
|
77
|
-
if (!hasIndex) {
|
|
78
|
-
await knex.schema.table(tableName, (table) => {
|
|
109
|
+
const hasIndex = () => hasKnexTableIndex(knex, tableName, indexName)
|
|
110
|
+
if (!await hasIndex()) {
|
|
111
|
+
await tolerateConcurrentDdl(() => knex.schema.table(tableName, (table) => {
|
|
79
112
|
table.unique(columns, indexName)
|
|
80
|
-
})
|
|
113
|
+
}), hasIndex)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Create missing secondary indexes. On an existing table they are built without
|
|
119
|
+
* blocking writes: in place on MySQL, and concurrently on PostgreSQL unless the
|
|
120
|
+
* pool has a single connection. Either way the build reads the whole table, so
|
|
121
|
+
* on a big table run it from a migration before the deploy.
|
|
122
|
+
*/
|
|
123
|
+
const ensureIndexes = async (knex, tableName, indexes, { online }) => {
|
|
124
|
+
const client = knex.client.config.client
|
|
125
|
+
const pg = ['pg', 'postgresql'].includes(client)
|
|
126
|
+
const mysql = ['mysql', 'mysql2'].includes(client)
|
|
127
|
+
// The build lock needs a connection of its own beside the builds.
|
|
128
|
+
const concurrently = online && pg && !knex.isTransaction && (knex.client.pool?.max ?? 0) > 1
|
|
129
|
+
const missing = async () => {
|
|
130
|
+
const existing = await listKnexTableIndexes(knex, tableName)
|
|
131
|
+
return indexes.filter(({ name }) => !existing.get(name)?.valid && !existing.get(name)?.building)
|
|
132
|
+
.map(index => ({ ...index, invalid: existing.has(index.name) }))
|
|
133
|
+
}
|
|
134
|
+
const build = async () => {
|
|
135
|
+
for (const { name, columns, invalid } of await missing()) {
|
|
136
|
+
const list = columns.map(() => '??').join(', ')
|
|
137
|
+
if (invalid) await knex.raw(`drop index ${concurrently ? 'concurrently ' : ''}if exists ??`, [name])
|
|
138
|
+
if (mysql) {
|
|
139
|
+
await tolerateConcurrentDdl(
|
|
140
|
+
() => knex.raw(`alter table ?? add index ?? (${list})${online ? ', algorithm=inplace, lock=none' : ''}`, [tableName, name, ...columns]),
|
|
141
|
+
() => hasKnexTableIndex(knex, tableName, name)
|
|
142
|
+
)
|
|
143
|
+
} else {
|
|
144
|
+
await tolerateConcurrentDdl(
|
|
145
|
+
() => knex.raw(`create index ${concurrently ? 'concurrently ' : ''}if not exists ?? on ?? (${list})`, [name, tableName, ...columns]),
|
|
146
|
+
async () => (await listKnexTableIndexes(knex, tableName)).has(name)
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!(await missing()).length) return
|
|
152
|
+
if (concurrently) await withPgIndexBuildLock(knex, tableName, build)
|
|
153
|
+
else await build()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Concurrent builds on one table deadlock each other, so one server builds at a
|
|
157
|
+
// time. The lock lives on an idle session, which concurrent builds never wait
|
|
158
|
+
// for. A server that finds it taken carries on: the holder builds the same indexes.
|
|
159
|
+
const withPgIndexBuildLock = async (knex, tableName, build) => {
|
|
160
|
+
const connection = await knex.client.acquireConnection()
|
|
161
|
+
const lock = name => knex.select(knex.raw(`${name}(hashtext(?)) as result`, [`json-rest-api:indexes:${tableName}`]))
|
|
162
|
+
.connection(connection).first()
|
|
163
|
+
try {
|
|
164
|
+
if (!(await lock('pg_try_advisory_lock')).result) return
|
|
165
|
+
try {
|
|
166
|
+
await build()
|
|
167
|
+
} finally {
|
|
168
|
+
await lock('pg_advisory_unlock')
|
|
169
|
+
}
|
|
170
|
+
} finally {
|
|
171
|
+
await knex.client.releaseConnection(connection)
|
|
81
172
|
}
|
|
82
173
|
}
|
|
83
174
|
|
|
84
175
|
/**
|
|
85
176
|
* Create or upgrade the shared canonical tables. Run it from a migration, or let
|
|
86
|
-
* the plugin run it at start-up.
|
|
87
|
-
*
|
|
177
|
+
* the plugin run it at start-up. Counts only add missing slot columns, never
|
|
178
|
+
* drop them; any_records always has at least the default layout.
|
|
88
179
|
* @param {import('knex').Knex} knex
|
|
89
|
-
* @param {{
|
|
180
|
+
* @param {{
|
|
181
|
+
* slots?: Partial<Record<keyof typeof SLOT_LIMITS, number>>,
|
|
182
|
+
* indexedSlots?: Partial<Record<keyof typeof INDEXED_SLOT_LIMITS, number>>
|
|
183
|
+
* }} [options]
|
|
90
184
|
*/
|
|
91
|
-
export const ensureAnyApiSchema = async (knex, { slots } = {}) => {
|
|
185
|
+
export const ensureAnyApiSchema = async (knex, { slots, indexedSlots } = {}) => {
|
|
92
186
|
const counts = resolveSlotCounts(slots)
|
|
93
|
-
|
|
187
|
+
const indexedCounts = resolveSlotCounts(indexedSlots, INDEXED_SLOT_LIMITS, { label: 'indexed slot', minimum: 0 })
|
|
188
|
+
const created = await ensureTable(knex, 'any_records', (table) => {
|
|
94
189
|
table.increments('id').primary()
|
|
95
190
|
table.string('logical_id')
|
|
96
191
|
table.string('tenant_id').notNullable()
|
|
97
192
|
table.string('resource').notNullable()
|
|
98
193
|
|
|
99
|
-
|
|
100
|
-
for (let index = 1; index <= count; index += 1) SLOT_COLUMNS[pool](index).forEach(column => addSlotColumn(table, column))
|
|
101
|
-
}
|
|
194
|
+
layoutColumns(counts, indexedCounts).forEach(column => addSlotColumn(table, column))
|
|
102
195
|
|
|
103
196
|
table.dateTime('created_at').defaultTo(knex.fn.now()).notNullable()
|
|
104
197
|
table.dateTime('updated_at').defaultTo(knex.fn.now()).notNullable()
|
|
@@ -109,14 +202,18 @@ export const ensureAnyApiSchema = async (knex, { slots } = {}) => {
|
|
|
109
202
|
table.index(['resource'])
|
|
110
203
|
})
|
|
111
204
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
205
|
+
const readColumns = async () => new Set(Object.keys(await knex('any_records').columnInfo()))
|
|
206
|
+
let recordColumns = await readColumns()
|
|
207
|
+
const missing = layoutColumns(counts, indexedCounts).filter(column => !recordColumns.has(column))
|
|
208
|
+
if (missing.length) {
|
|
209
|
+
await tolerateConcurrentDdl(
|
|
210
|
+
() => knex.schema.alterTable('any_records', (table) => missing.forEach(column => addSlotColumn(table, column))),
|
|
211
|
+
async () => {
|
|
212
|
+
const columns = await readColumns()
|
|
213
|
+
return missing.every(column => columns.has(column))
|
|
214
|
+
}
|
|
215
|
+
)
|
|
216
|
+
recordColumns = await readColumns()
|
|
120
217
|
}
|
|
121
218
|
|
|
122
219
|
const client = knex.client.config.client
|
|
@@ -125,7 +222,7 @@ export const ensureAnyApiSchema = async (knex, { slots } = {}) => {
|
|
|
125
222
|
.select({ column: 'column_name', precision: 'datetime_precision' })
|
|
126
223
|
.where('table_schema', knex.raw(client === 'pg' || client === 'postgresql' ? 'current_schema()' : 'DATABASE()'))
|
|
127
224
|
.where('table_name', 'any_records'))
|
|
128
|
-
.filter(column => /^date_\d+$/.test(column.column))
|
|
225
|
+
.filter(column => /^(indexed_)?date_\d+$/.test(column.column))
|
|
129
226
|
if (!columns.length || columns.some(column => Number(column.precision) !== 6)) {
|
|
130
227
|
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.')
|
|
131
228
|
}
|
|
@@ -151,7 +248,7 @@ export const ensureAnyApiSchema = async (knex, { slots } = {}) => {
|
|
|
151
248
|
.update({ logical_id: String(row.id) })
|
|
152
249
|
}
|
|
153
250
|
|
|
154
|
-
await ensureTable(knex, 'any_links', (table) => {
|
|
251
|
+
const linksCreated = await ensureTable(knex, 'any_links', (table) => {
|
|
155
252
|
table.increments('id').primary()
|
|
156
253
|
table.string('tenant_id').notNullable()
|
|
157
254
|
table.string('relationship').notNullable()
|
|
@@ -172,6 +269,17 @@ export const ensureAnyApiSchema = async (knex, { slots } = {}) => {
|
|
|
172
269
|
table.index(['inverse_relationship'])
|
|
173
270
|
})
|
|
174
271
|
|
|
272
|
+
// Filters on indexed fields, child lookups by parent id and link lookups by
|
|
273
|
+
// record id all stay inside one tenant's rows.
|
|
274
|
+
const recordIndexes = [...recordColumns]
|
|
275
|
+
.filter(column => /^(indexed_[a-z]+_\d+|rel_\d+_id)$/.test(column))
|
|
276
|
+
.map(column => ({ name: slotIndexName(column), columns: ['tenant_id', 'resource', column] }))
|
|
277
|
+
await ensureIndexes(knex, 'any_records', recordIndexes, { online: !created })
|
|
278
|
+
await ensureIndexes(knex, 'any_links', [
|
|
279
|
+
{ name: 'any_links_tenant_left_id_idx', columns: ['tenant_id', 'left_id'] },
|
|
280
|
+
{ name: 'any_links_tenant_right_id_idx', columns: ['tenant_id', 'right_id'] }
|
|
281
|
+
], { online: !linksCreated })
|
|
282
|
+
|
|
175
283
|
await ensureTable(knex, 'any_resource_configs', (table) => {
|
|
176
284
|
table.increments('id').primary()
|
|
177
285
|
table.string('tenant_id').notNullable()
|
|
@@ -249,43 +357,50 @@ export const ensureAnyApiSchema = async (knex, { slots } = {}) => {
|
|
|
249
357
|
})
|
|
250
358
|
}
|
|
251
359
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
360
|
+
// The default unindexed layout, for migrations written against it.
|
|
361
|
+
export const SLOT_POOLS = Object.fromEntries(Object.entries(SLOT_LIMITS).map(([pool, count]) => {
|
|
362
|
+
const indexes = Array.from({ length: count }, (_, index) => index + 1)
|
|
255
363
|
return [pool, pool === 'belongsTo' ? indexes : indexes.map(index => SLOT_COLUMNS[pool](index)[0])]
|
|
256
364
|
}))
|
|
257
365
|
|
|
258
|
-
export const SLOT_POOLS = buildSlotPools((pool, index) => index <= SLOT_LIMITS[pool])
|
|
259
|
-
|
|
260
366
|
/**
|
|
261
367
|
* Read the slot columns any_records actually has. Slots are numbered from 1
|
|
262
|
-
* without gaps; a pool ends at its first missing column.
|
|
368
|
+
* without gaps; a pool ends at its first missing column. `indexed` lists the
|
|
369
|
+
* indexed columns of each pool that has them.
|
|
263
370
|
* @param {import('knex').Knex | import('knex').Knex.Transaction} db
|
|
264
371
|
*/
|
|
265
372
|
export const readSlotPools = async (db) => {
|
|
266
373
|
const columns = new Set(Object.keys(await db('any_records').columnInfo()))
|
|
267
374
|
if (!columns.size) throw new Error('AnyAPI storage tables are missing; run ensureAnyApiSchema in a migration')
|
|
268
|
-
|
|
375
|
+
const run = columnsAt => {
|
|
376
|
+
const indexes = []
|
|
377
|
+
for (let index = 1; columnsAt(index).every(column => columns.has(column)); index += 1) indexes.push(index)
|
|
378
|
+
return indexes
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
...Object.fromEntries(Object.keys(SLOT_LIMITS).map(pool => {
|
|
382
|
+
const indexes = run(SLOT_COLUMNS[pool])
|
|
383
|
+
return [pool, pool === 'belongsTo' ? indexes : indexes.map(index => SLOT_COLUMNS[pool](index)[0])]
|
|
384
|
+
})),
|
|
385
|
+
indexed: Object.fromEntries(Object.keys(INDEXED_SLOT_LIMITS).map(pool => [
|
|
386
|
+
pool, run(index => [indexedSlotColumn(pool, index)]).map(index => indexedSlotColumn(pool, index))
|
|
387
|
+
]))
|
|
388
|
+
}
|
|
269
389
|
}
|
|
270
390
|
|
|
271
391
|
export const TYPE_TO_POOL = new Map([
|
|
272
392
|
['string', 'string'],
|
|
273
|
-
['text', 'string'],
|
|
274
393
|
['uuid', 'string'],
|
|
275
394
|
['email', 'string'],
|
|
276
395
|
['file', 'string'],
|
|
277
396
|
['number', 'number'],
|
|
278
397
|
['integer', 'number'],
|
|
279
|
-
['float', 'number'],
|
|
280
|
-
['decimal', 'number'],
|
|
281
398
|
['boolean', 'boolean'],
|
|
282
399
|
['date', 'string'],
|
|
283
|
-
['datetime', 'date'],
|
|
284
400
|
['dateTime', 'date'],
|
|
285
401
|
['time', 'string'],
|
|
286
402
|
['epochMilliseconds', 'number'],
|
|
287
403
|
['epochSeconds', 'number'],
|
|
288
|
-
['json', 'json'],
|
|
289
404
|
['object', 'json'],
|
|
290
405
|
['array', 'json'],
|
|
291
406
|
])
|
|
@@ -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)
|
|
@@ -1237,6 +1237,34 @@ export async function hasKnexTableIndex (knex, tableName, indexName) {
|
|
|
1237
1237
|
throw new Error(`Unsupported knex client '${client}' for hasKnexTableIndex`)
|
|
1238
1238
|
}
|
|
1239
1239
|
|
|
1240
|
+
/**
|
|
1241
|
+
* Index names of one table. On PostgreSQL an interrupted concurrent build leaves
|
|
1242
|
+
* an invalid index; `building` tells it from one another session is building.
|
|
1243
|
+
* @returns {Promise<Map<string, { valid: boolean, building: boolean }>>}
|
|
1244
|
+
*/
|
|
1245
|
+
export async function listKnexTableIndexes (knex, tableName) {
|
|
1246
|
+
const client = knex.client.config.client
|
|
1247
|
+
let rows
|
|
1248
|
+
if (['sqlite3', 'better-sqlite3'].includes(client)) {
|
|
1249
|
+
rows = normalizeRows(await knex.raw('PRAGMA index_list(??)', [tableName])).map(row => ({ name: row.name }))
|
|
1250
|
+
} else if (['mysql', 'mysql2'].includes(client)) {
|
|
1251
|
+
rows = (await knex('information_schema.statistics').distinct({ name: 'INDEX_NAME' })
|
|
1252
|
+
.where({ TABLE_SCHEMA: knex.raw('DATABASE()'), TABLE_NAME: tableName }))
|
|
1253
|
+
} else if (['pg', 'postgresql'].includes(client)) {
|
|
1254
|
+
rows = normalizeRows(await knex.raw(`
|
|
1255
|
+
select c.relname as name, i.indisvalid as valid,
|
|
1256
|
+
exists (select 1 from pg_stat_progress_create_index p where p.index_relid = i.indexrelid) as building
|
|
1257
|
+
from pg_index i
|
|
1258
|
+
join pg_class c on c.oid = i.indexrelid
|
|
1259
|
+
join pg_class t on t.oid = i.indrelid
|
|
1260
|
+
join pg_namespace n on n.oid = t.relnamespace
|
|
1261
|
+
where t.relname = ? and n.nspname = current_schema()`, [tableName]))
|
|
1262
|
+
} else {
|
|
1263
|
+
throw new Error(`Unsupported knex client '${client}' for listKnexTableIndexes`)
|
|
1264
|
+
}
|
|
1265
|
+
return new Map(rows.map(row => [row.name, { valid: row.valid !== false, building: row.building === true }]))
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1240
1268
|
// Column-only ALTER must not impose the resource snapshot's ID or index restrictions.
|
|
1241
1269
|
export async function introspectKnexColumnConstraints (knex, tableName) {
|
|
1242
1270
|
requireKnexRaw(knex)
|
|
@@ -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
|
+
}
|
|
@@ -7,6 +7,8 @@ import { resolveSortField } from './query-field-sort-helpers.js'
|
|
|
7
7
|
import { databaseIdentityExpression } from '../querying-writing/database-value-normalizers.js'
|
|
8
8
|
|
|
9
9
|
const referenceVisibility = Symbol('referenceVisibility')
|
|
10
|
+
// SQL never matches null against a non-null operand with these.
|
|
11
|
+
const NULL_REJECTING_OPERATORS = new Set(['=', '==', '!=', '<>', '<', '<=', '>', '>=', 'in', 'between', 'like', 'contains', 'startswith', 'endswith'])
|
|
10
12
|
|
|
11
13
|
async function buildSearchJoinQuery ({ scopeName, scopes, context, adapterUtils, db }) {
|
|
12
14
|
const scope = scopes[scopeName]
|
|
@@ -135,12 +137,20 @@ export function applyWhereForOperator ({ builder, columnRef, operator, value, kn
|
|
|
135
137
|
if (selections.length === 0) this.whereRaw('1 = 0')
|
|
136
138
|
for (const selection of selections) this.orWhereExists(selection)
|
|
137
139
|
}
|
|
140
|
+
const visibleMatch = function () {
|
|
141
|
+
this.where(visible)
|
|
142
|
+
applyWhereForOperator({ builder: this, columnRef: columnRef.column, operator, value, knex })
|
|
143
|
+
}
|
|
144
|
+
// A hidden reference reads as null. When null cannot match, leave that branch
|
|
145
|
+
// out: an OR with it keeps planners from using an index on the column.
|
|
146
|
+
const values = Array.isArray(value) ? value : [value]
|
|
147
|
+
if (NULL_REJECTING_OPERATORS.has(String(operator || '=').toLowerCase()) && values.length && values.every(item => item != null)) {
|
|
148
|
+
builder[method](visibleMatch)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
138
151
|
// Compare the column itself to preserve driver affinity and collation.
|
|
139
152
|
builder[method](function () {
|
|
140
|
-
this.where(function () {
|
|
141
|
-
this.where(visible)
|
|
142
|
-
applyWhereForOperator({ builder: this, columnRef: columnRef.column, operator, value, knex })
|
|
143
|
-
}).orWhere(function () {
|
|
153
|
+
this.where(visibleMatch).orWhere(function () {
|
|
144
154
|
this.whereNot(visible)
|
|
145
155
|
applyWhereForOperator({ builder: this, columnRef: knex.raw('null'), operator, value, knex })
|
|
146
156
|
})
|
|
@@ -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;
|