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.
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 +99 -2
  4. package/docs/GUIDE/30-backend-capabilities.md +3 -1
  5. package/docs/GUIDE/33-migrating-to-v2.md +20 -0
  6. package/package.json +1 -1
  7. package/plugins/core/lib/anyapi/anyapi-registry.js +97 -42
  8. package/plugins/core/lib/anyapi/schema-utils.js +168 -53
  9. package/plugins/core/lib/anyapi/stored-definition.js +3 -0
  10. package/plugins/core/lib/dbIntrospection.js +28 -0
  11. package/plugins/core/lib/querying/aggregate-query.js +170 -0
  12. package/plugins/core/lib/querying/knex-query-helpers.js +14 -4
  13. package/plugins/core/lib/querying-writing/compile-schemas.js +17 -0
  14. package/plugins/core/lib/querying-writing/database-value-normalizers.js +16 -0
  15. package/plugins/core/lib/querying-writing/default-data-helpers.js +4 -0
  16. package/plugins/core/lib/querying-writing/field-types.js +43 -0
  17. package/plugins/core/lib/querying-writing/request-contracts.js +1 -1
  18. package/plugins/core/lib/storage/canonical-data-helpers.js +29 -0
  19. package/plugins/core/lib/storage/canonical-storage-mapping.js +11 -0
  20. package/plugins/core/lib/storage/ordinary-data-helpers.js +25 -0
  21. package/plugins/core/lib/storage/storage-types.d.ts +23 -0
  22. package/plugins/core/rest-api-anyapi-knex-plugin.js +140 -31
  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 +13 -2
  27. package/types/plugin-options.d.ts +2 -1
  28. package/types/resource-methods.d.ts +19 -0
  29. 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 a new any_records table. The registry reads the actual
4
- // columns, so a migration that adds slots raises the limits for every server.
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
- 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)
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('AnyAPI slots must be an object of slot counts')
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(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`)
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 { ...SLOT_LIMITS, ...slots }
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
- const ensureTable = async (knex, tableName, tableBuilder) => {
57
- const exists = await knex.schema.hasTable(tableName)
58
- if (!exists) {
59
- await knex.schema.createTable(tableName, tableBuilder)
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 = await knex.schema.hasColumn(tableName, columnName)
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 = await hasKnexTableIndex(knex, tableName, indexName)
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. `slots` only adds missing slot columns; lowering
87
- * a count never drops columns that may hold data.
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 {{ slots?: Partial<Record<keyof typeof SLOT_LIMITS, number>> }} [options]
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
- await ensureTable(knex, 'any_records', (table) => {
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
- 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))
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
- 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
- }
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
- 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)
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
- return buildSlotPools((pool, index) => SLOT_COLUMNS[pool](index).every(column => columns.has(column)))
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;