json-rest-api 2.4.0 → 3.0.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 (83) hide show
  1. package/README.md +7 -2
  2. package/docs/API.md +262 -54
  3. package/docs/GUIDE/01-getting-started.md +1 -1
  4. package/docs/GUIDE/07-polymorphic-relationships.md +4 -2
  5. package/docs/GUIDE/09-pagination-and-sorting.md +4 -2
  6. package/docs/GUIDE/13-hooks-and-lifecycle.md +34 -6
  7. package/docs/GUIDE/14-custom-resource-methods.md +3 -2
  8. package/docs/GUIDE/16-autofiltering.md +54 -2
  9. package/docs/GUIDE/17-row-policies.md +15 -5
  10. package/docs/GUIDE/19-managed-transactions.md +8 -9
  11. package/docs/GUIDE/20-transaction-outcomes.md +3 -4
  12. package/docs/GUIDE/21-schema-and-migrations.md +7 -1
  13. package/docs/GUIDE/22-version-field-migrations.md +3 -3
  14. package/docs/GUIDE/27-many-row-writes.md +458 -0
  15. package/docs/GUIDE/28-socketio.md +10 -2
  16. package/docs/GUIDE/30-backend-capabilities.md +42 -2
  17. package/docs/GUIDE/31-positioning.md +3 -1
  18. package/docs/GUIDE/33-migrating-to-v2.md +2 -2
  19. package/docs/GUIDE/34-scoped-reads-and-locks.md +464 -0
  20. package/docs/GUIDE/35-migrating-to-v3.md +325 -0
  21. package/docs/GUIDE/index.md +5 -2
  22. package/docs/QUICKSTART.md +2 -2
  23. package/docs/contributing.md +1 -1
  24. package/index.d.ts +8 -1
  25. package/index.js +4 -0
  26. package/lib/rest-api-errors.js +79 -0
  27. package/lib/runtime/json-rest-api.js +18 -3
  28. package/lib/system-context.js +42 -0
  29. package/package.json +3 -2
  30. package/plugins/core/bulk-operations-plugin.js +54 -436
  31. package/plugins/core/connectors/lib/transport-http-helpers.js +34 -3
  32. package/plugins/core/file-handling-plugin.js +17 -2
  33. package/plugins/core/lib/anyapi/anyapi-registry.js +3 -2
  34. package/plugins/core/lib/anyapi/canonical-link-store.js +5 -2
  35. package/plugins/core/lib/anyapi/canonical-relationship-reader.js +56 -60
  36. package/plugins/core/lib/dbIntrospection.js +106 -6
  37. package/plugins/core/lib/querying/aggregate-query.js +215 -22
  38. package/plugins/core/lib/querying/include-query-helpers.js +11 -13
  39. package/plugins/core/lib/querying/include-to-one.js +54 -59
  40. package/plugins/core/lib/querying/knex-pagination-helpers.js +9 -2
  41. package/plugins/core/lib/querying/query-constraint.d.ts +11 -0
  42. package/plugins/core/lib/querying/query-constraint.js +45 -0
  43. package/plugins/core/lib/querying/read-only-query.js +92 -0
  44. package/plugins/core/lib/querying-writing/compile-schemas.js +14 -0
  45. package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
  46. package/plugins/core/lib/storage/canonical-data-helpers.js +103 -39
  47. package/plugins/core/lib/storage/many-row-data-helpers.js +738 -0
  48. package/plugins/core/lib/storage/ordinary-data-helpers.js +63 -79
  49. package/plugins/core/lib/storage/single-row-writes.js +71 -0
  50. package/plugins/core/lib/storage/storage-adapter.js +18 -8
  51. package/plugins/core/lib/storage/storage-mapping.js +4 -1
  52. package/plugins/core/lib/storage/storage-types.d.ts +21 -3
  53. package/plugins/core/lib/writing/many-to-many-manipulations.js +10 -5
  54. package/plugins/core/lib/writing/relationship-processor.js +33 -37
  55. package/plugins/core/rest-api-anyapi-knex-plugin.js +3 -0
  56. package/plugins/core/rest-api-autofilter-plugin.js +137 -10
  57. package/plugins/core/rest-api-knex-plugin.js +2 -0
  58. package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
  59. package/plugins/core/rest-api-plugin-methods/common.js +144 -84
  60. package/plugins/core/rest-api-plugin-methods/delete-many.js +132 -0
  61. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +22 -0
  62. package/plugins/core/rest-api-plugin-methods/get.js +48 -42
  63. package/plugins/core/rest-api-plugin-methods/many-row-common.js +274 -0
  64. package/plugins/core/rest-api-plugin-methods/patch-many.js +333 -0
  65. package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
  66. package/plugins/core/rest-api-plugin-methods/post-many.js +295 -0
  67. package/plugins/core/rest-api-plugin-methods/put.js +5 -1
  68. package/plugins/core/rest-api-plugin-methods/scoped-reads.js +221 -0
  69. package/plugins/core/rest-api-plugin.js +17 -0
  70. package/plugins/core/rest-api-positioning-plugin.js +22 -4
  71. package/plugins/core/rest-api-query-projections-plugin.js +9 -1
  72. package/plugins/core/rest-api-row-policy-plugin.js +4 -0
  73. package/plugins/core/socketio-plugin.js +80 -2
  74. package/types/autofilter.d.ts +5 -1
  75. package/types/data-access-methods.d.ts +141 -0
  76. package/types/errors.d.ts +45 -0
  77. package/types/hook-context.d.ts +21 -0
  78. package/types/plugin-options.d.ts +3 -1
  79. package/types/resource-methods.d.ts +27 -4
  80. package/types/row-policy.d.ts +3 -0
  81. package/types/runtime.d.ts +7 -1
  82. package/docs/GUIDE/27-bulk-operations.md +0 -277
  83. package/types/bulk-methods.d.ts +0 -63
@@ -1,7 +1,7 @@
1
1
  import { whereInIdentifiers } from '../querying/identifier-query.js'
2
2
  import { loadIncludedParentMap, parseIncludeTree } from '../querying/include-query-helpers.js'
3
3
  import { getResourceFieldset, parseFieldset } from '../querying-writing/field-utils.js'
4
- import { COMPUTED_DEPENDENCIES_KEY, RELATIONSHIP_READ_BATCH_SIZE } from '../querying-writing/knex-constants.js'
4
+ import { COMPUTED_DEPENDENCIES_KEY } from '../querying-writing/knex-constants.js'
5
5
  import { buildResourceUrl, buildRelationshipUrl } from '../querying/url-helpers.js'
6
6
  import { normalizeId } from './utils/descriptor-helpers.js'
7
7
  import { applyFieldSelectionToQuery, buildFieldSelection } from '../querying-writing/knex-field-helpers.js'
@@ -177,36 +177,34 @@ export function createCanonicalRelationshipReader ({ api, knex, getDescriptor, g
177
177
  const targetDescriptor = getDescriptor(belongsToInfo.target)
178
178
 
179
179
  const nestedResources = []
180
- for (let offset = 0; offset < ids.length; offset += RELATIONSHIP_READ_BATCH_SIZE) {
181
- let query = db(targetDescriptor.canonical.tableName)
182
- .where(targetDescriptor.canonical.tenantColumn, targetDescriptor.tenant)
183
- .where(targetDescriptor.canonical.resourceColumn, targetDescriptor.resource)
184
- .whereIn(getCanonicalResourceIdColumn(targetDescriptor), ids.slice(offset, offset + RELATIONSHIP_READ_BATCH_SIZE))
185
- const selectionState = await applyIncludeFieldSelection({
186
- query,
187
- resourceName: targetDescriptor.resource,
188
- tableName: targetDescriptor.canonical.tableName,
189
- context
190
- })
191
- query = selectionState.query
192
- const scopedQueryState = await applyTargetScopeFilters({
193
- query,
194
- resourceName: targetDescriptor.resource,
195
- tableName: targetDescriptor.canonical.tableName,
196
- context
197
- })
198
- query = scopedQueryState.query
199
- const rows = await applyDatabaseReadOptions(query)
180
+ let query = db(targetDescriptor.canonical.tableName)
181
+ .where(targetDescriptor.canonical.tenantColumn, targetDescriptor.tenant)
182
+ .where(targetDescriptor.canonical.resourceColumn, targetDescriptor.resource)
183
+ .modify(whereInIdentifiers, getCanonicalResourceIdColumn(targetDescriptor), ids)
184
+ const selectionState = await applyIncludeFieldSelection({
185
+ query,
186
+ resourceName: targetDescriptor.resource,
187
+ tableName: targetDescriptor.canonical.tableName,
188
+ context
189
+ })
190
+ query = selectionState.query
191
+ const scopedQueryState = await applyTargetScopeFilters({
192
+ query,
193
+ resourceName: targetDescriptor.resource,
194
+ tableName: targetDescriptor.canonical.tableName,
195
+ context
196
+ })
197
+ query = scopedQueryState.query
198
+ const rows = await applyDatabaseReadOptions(query)
200
199
 
201
- for (const row of rows) {
202
- const includeResource = includeRow({
203
- row,
204
- descriptor: targetDescriptor,
205
- context,
206
- fieldSelectionInfo: selectionState.fieldSelectionInfo
207
- }, includes)
208
- nestedResources.push(includeResource)
209
- }
200
+ for (const row of rows) {
201
+ const includeResource = includeRow({
202
+ row,
203
+ descriptor: targetDescriptor,
204
+ context,
205
+ fieldSelectionInfo: selectionState.fieldSelectionInfo
206
+ }, includes)
207
+ nestedResources.push(includeResource)
210
208
  }
211
209
 
212
210
  if (childKeys.length > 0 && nestedResources.length > 0) {
@@ -244,36 +242,34 @@ export function createCanonicalRelationshipReader ({ api, knex, getDescriptor, g
244
242
  const targetDescriptor = getDescriptor(targetType)
245
243
 
246
244
  const ids = [...idSet]
247
- for (let offset = 0; offset < ids.length; offset += RELATIONSHIP_READ_BATCH_SIZE) {
248
- let query = db(targetDescriptor.canonical.tableName)
249
- .where(targetDescriptor.canonical.tenantColumn, targetDescriptor.tenant)
250
- .where(targetDescriptor.canonical.resourceColumn, targetDescriptor.resource)
251
- .whereIn(getCanonicalResourceIdColumn(targetDescriptor), ids.slice(offset, offset + RELATIONSHIP_READ_BATCH_SIZE))
252
- const selectionState = await applyIncludeFieldSelection({
253
- query,
254
- resourceName: targetDescriptor.resource,
255
- tableName: targetDescriptor.canonical.tableName,
256
- context
257
- })
258
- query = selectionState.query
259
- const scopedQueryState = await applyTargetScopeFilters({
260
- query,
261
- resourceName: targetDescriptor.resource,
262
- tableName: targetDescriptor.canonical.tableName,
263
- context
264
- })
265
- query = scopedQueryState.query
266
- const rows = await applyDatabaseReadOptions(query)
267
-
268
- for (const row of rows) {
269
- const includeResource = includeRow({
270
- row,
271
- descriptor: targetDescriptor,
272
- context,
273
- fieldSelectionInfo: selectionState.fieldSelectionInfo
274
- }, includes)
275
- nestedResources.push(includeResource)
276
- }
245
+ let query = db(targetDescriptor.canonical.tableName)
246
+ .where(targetDescriptor.canonical.tenantColumn, targetDescriptor.tenant)
247
+ .where(targetDescriptor.canonical.resourceColumn, targetDescriptor.resource)
248
+ .modify(whereInIdentifiers, getCanonicalResourceIdColumn(targetDescriptor), ids)
249
+ const selectionState = await applyIncludeFieldSelection({
250
+ query,
251
+ resourceName: targetDescriptor.resource,
252
+ tableName: targetDescriptor.canonical.tableName,
253
+ context
254
+ })
255
+ query = selectionState.query
256
+ const scopedQueryState = await applyTargetScopeFilters({
257
+ query,
258
+ resourceName: targetDescriptor.resource,
259
+ tableName: targetDescriptor.canonical.tableName,
260
+ context
261
+ })
262
+ query = scopedQueryState.query
263
+ const rows = await applyDatabaseReadOptions(query)
264
+
265
+ for (const row of rows) {
266
+ const includeResource = includeRow({
267
+ row,
268
+ descriptor: targetDescriptor,
269
+ context,
270
+ fieldSelectionInfo: selectionState.fieldSelectionInfo
271
+ }, includes)
272
+ nestedResources.push(includeResource)
277
273
  }
278
274
  }
279
275
 
@@ -117,8 +117,17 @@ function parseEnumLikeValues (columnType = '', typeName = 'enum', backslashEscap
117
117
  return values
118
118
  }
119
119
 
120
- function normalizeMysqlDefault (row) {
120
+ // MariaDB (10.2.7+) writes COLUMN_DEFAULT as SQL: a literal is quoted, NULL is
121
+ // the word NULL and an expression is bare, where MySQL gives the bare literal.
122
+ function normalizeMariadbDefault (value) {
123
+ if (typeof value !== 'string') return value
124
+ if (value === 'NULL') return null
125
+ return /^'(?:''|\\.|[^'\\])*'$/s.test(value) ? parseEnumLikeValues(`enum(${value})`, 'enum', true)[0] : value
126
+ }
127
+
128
+ function normalizeMysqlDefault (row, mariadb) {
121
129
  const value = Object.hasOwn(row, 'columnDefault') ? row.columnDefault : row.column_default
130
+ if (mariadb) return normalizeMariadbDefault(value)
122
131
  if (row.dataType !== 'json' || typeof value !== 'string') return value
123
132
  let expression = value.trim()
124
133
  if (expression.startsWith('(') && expression.endsWith(')')) expression = expression.slice(1, -1).trim()
@@ -917,8 +926,9 @@ function normalizeDbSchemaName (rows = []) {
917
926
  }
918
927
 
919
928
  async function introspectMysqlTableSnapshot (knex, { tableName, idColumn }) {
920
- const schemaRows = normalizeRows(await knex.raw('SELECT DATABASE() AS schemaName'))
929
+ const schemaRows = normalizeRows(await knex.raw('SELECT DATABASE() AS schemaName, VERSION() AS version'))
921
930
  const schemaName = normalizeDbSchemaName(schemaRows)
931
+ const mariadb = /mariadb/i.test(normalizeText(schemaRows[0]?.version))
922
932
 
923
933
  const tableRows = normalizeRows(
924
934
  await knex.raw(
@@ -1052,12 +1062,17 @@ async function introspectMysqlTableSnapshot (knex, { tableName, idColumn }) {
1052
1062
  )
1053
1063
  )
1054
1064
 
1065
+ // MariaDB stores JSON as LONGTEXT with a column check json_valid(`column`):
1066
+ // that pair is a JSON column, and its check is part of the type.
1067
+ const jsonCheck = (/** @type {any} */ row) => mariadb && /^json_valid\(`((?:``|[^`])+)`\)$/i.exec(normalizeText(row.checkClause))?.[1].replace(/``/g, '`')
1068
+ const jsonColumns = new Set(checkConstraintRows.map(jsonCheck).filter(Boolean))
1055
1069
  const columns = columnRows.map((row) => buildColumnSnapshot({
1056
1070
  name: row.columnName,
1057
- dataType: row.dataType,
1058
- columnType: row.columnType,
1071
+ ...(jsonColumns.has(row.columnName) && normalizeText(row.dataType).toLowerCase() === 'longtext'
1072
+ ? { dataType: 'json', columnType: 'json' }
1073
+ : { dataType: row.dataType, columnType: row.columnType }),
1059
1074
  nullable: normalizeText(row.isNullable).toUpperCase() === 'YES',
1060
- defaultValue: normalizeMysqlDefault(row),
1075
+ defaultValue: normalizeMysqlDefault(row, mariadb),
1061
1076
  defaultIsLiteral: true,
1062
1077
  extra: row.extra,
1063
1078
  autoIncrement: normalizeText(row.extra).toLowerCase().includes('auto_increment'),
@@ -1088,7 +1103,7 @@ async function introspectMysqlTableSnapshot (knex, { tableName, idColumn }) {
1088
1103
  indexes: normalizeIndexes(indexRows),
1089
1104
  foreignKeys: normalizeForeignKeys(foreignKeyRows, [], tableName),
1090
1105
  checkConstraints: normalizeCheckConstraints(
1091
- checkConstraintRows.map((row) => ({
1106
+ checkConstraintRows.filter(row => !jsonColumns.has(jsonCheck(row))).map((row) => ({
1092
1107
  name: row.constraintName,
1093
1108
  clause: row.checkClause
1094
1109
  })),
@@ -1265,6 +1280,91 @@ export async function listKnexTableIndexes (knex, tableName) {
1265
1280
  return new Map(rows.map(row => [row.name, { valid: row.valid !== false, building: row.building === true }]))
1266
1281
  }
1267
1282
 
1283
+ /**
1284
+ * The schema a MySQL or MariaDB connection is using, from `database()`, a
1285
+ * statement that reads no table.
1286
+ * @param {any} knex
1287
+ * @returns {Promise<string>}
1288
+ */
1289
+ export async function currentKnexMysqlSchema (knex) {
1290
+ return normalizeRows(await knex.raw('select database() as name'))[0]?.name
1291
+ }
1292
+
1293
+ /**
1294
+ * The indexes of a MySQL or MariaDB table that the optimizer may use, unique
1295
+ * or not, the primary key included: each with its columns in key order and
1296
+ * the length of a column prefix (`prefix`, null for the whole column). A
1297
+ * column list ends before its first functional key part, which names no
1298
+ * column. Invisible (MySQL) and ignored (MariaDB) indexes are left out.
1299
+ * @param {any} knex
1300
+ * @param {string} schema
1301
+ * @param {string} tableName
1302
+ * @returns {Promise<Array<{ name: string, columns: Array<{ name: string, prefix: number | null }> }>>}
1303
+ */
1304
+ export async function listKnexMysqlIndexes (knex, schema, tableName) {
1305
+ const rows = normalizeRows(await knex.raw(`
1306
+ select * from information_schema.statistics
1307
+ where table_schema = ? and table_name = ?
1308
+ order by index_name, seq_in_index`, [schema, tableName]))
1309
+ const byIndex = new Map()
1310
+ for (const raw of rows) {
1311
+ const row = Object.fromEntries(Object.entries(raw).map(([key, value]) => [key.toLowerCase(), value]))
1312
+ if (row.is_visible === 'NO' || row.ignored === 'YES') continue
1313
+ if (!byIndex.has(row.index_name)) byIndex.set(row.index_name, [])
1314
+ byIndex.get(row.index_name).push(row.column_name == null ? null : { name: row.column_name, prefix: row.sub_part == null ? null : Number(row.sub_part) })
1315
+ }
1316
+ return [...byIndex].map(([name, columns]) => ({ name, columns: columns.includes(null) ? columns.slice(0, columns.indexOf(null)) : columns }))
1317
+ }
1318
+
1319
+ /**
1320
+ * The column lists of a table's unique indexes and constraints, primary key
1321
+ * included, that guarantee uniqueness for every row: partial, expression and
1322
+ * invalid indexes are left out.
1323
+ * @param {any} knex
1324
+ * @param {string} tableName
1325
+ * @returns {Promise<string[][]>}
1326
+ */
1327
+ export async function listKnexUniqueKeyColumns (knex, tableName) {
1328
+ const dialect = detectDialect(knex)
1329
+ if (dialect === 'sqlite') {
1330
+ const keys = []
1331
+ for (const index of normalizeRows(await knex.raw('PRAGMA index_list(??)', [tableName]))) {
1332
+ if (Number(index.unique) !== 1 || Number(index.partial) === 1) continue
1333
+ const columns = normalizeRows(await knex.raw('PRAGMA index_info(??)', [index.name]))
1334
+ if (columns.length && columns.every(column => column.name)) keys.push(columns.sort((a, b) => a.seqno - b.seqno).map(column => column.name))
1335
+ }
1336
+ // An INTEGER PRIMARY KEY is the rowid and has no index entry.
1337
+ const primary = normalizeRows(await knex.raw('PRAGMA table_info(??)', [tableName])).filter(column => Number(column.pk) > 0)
1338
+ if (primary.length) keys.push(primary.sort((a, b) => a.pk - b.pk).map(column => column.name))
1339
+ return keys
1340
+ }
1341
+ const rows = dialect === 'mysql2'
1342
+ ? normalizeRows(await knex.raw(`
1343
+ select index_name as indexName, column_name as columnName, seq_in_index as position
1344
+ from information_schema.statistics
1345
+ where table_schema = database() and table_name = ? and non_unique = 0
1346
+ order by index_name, seq_in_index`, [tableName]))
1347
+ : normalizeRows(await knex.raw(`
1348
+ select c.relname as "indexName", a.attname as "columnName", k.position
1349
+ from pg_index i
1350
+ join pg_class t on t.oid = i.indrelid
1351
+ join pg_namespace n on n.oid = t.relnamespace
1352
+ join pg_class c on c.oid = i.indexrelid
1353
+ cross join lateral unnest(i.indkey) with ordinality as k(num, position)
1354
+ left join pg_attribute a on a.attrelid = t.oid and a.attnum = k.num
1355
+ where t.relname = ? and n.nspname = current_schema() and i.indisunique and i.indisvalid
1356
+ and i.indpred is null and i.indexprs is null and k.position <= i.indnkeyatts
1357
+ order by c.relname, k.position`, [tableName]))
1358
+ const byIndex = new Map()
1359
+ for (const row of rows) {
1360
+ const name = row.indexName ?? row.INDEX_NAME
1361
+ if (!byIndex.has(name)) byIndex.set(name, [])
1362
+ byIndex.get(name).push(row.columnName ?? row.COLUMN_NAME ?? null)
1363
+ }
1364
+ // A functional index (MySQL) has no column name.
1365
+ return [...byIndex.values()].filter(columns => columns.every(Boolean))
1366
+ }
1367
+
1268
1368
  // Column-only ALTER must not impose the resource snapshot's ID or index restrictions.
1269
1369
  export async function introspectKnexColumnConstraints (knex, tableName) {
1270
1370
  requireKnexRaw(knex)
@@ -1,5 +1,6 @@
1
1
  import { RestApiValidationError } from '../../../../lib/rest-api-errors.js'
2
2
  import { applyDatabaseReadOptions, normalizeStoredFieldValue } from '../querying-writing/database-value-normalizers.js'
3
+ import { databaseFamily } from '../storage/many-row-data-helpers.js'
3
4
 
4
5
  const NUMERIC_TYPES = new Set(['number', 'integer', 'epochMilliseconds', 'epochSeconds'])
5
6
  const ORDERED_TYPES = new Set([...NUMERIC_TYPES, 'string', 'email', 'uuid', 'date', 'dateTime', 'time'])
@@ -17,6 +18,13 @@ const PARAMS = new Set(['groupBy', 'measures', 'queryParams', 'transaction'])
17
18
  const QUERY_PARAMS = new Set(['filters', 'sort', 'page'])
18
19
  const OUTPUT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
19
20
  const AGGREGATE_ALIAS = '__jra_aggregate'
21
+ const TEMPORAL_TYPES = new Set(['date', 'dateTime'])
22
+ const BUCKETS = new Set(['day', 'week', 'month', 'year'])
23
+ const UTC_NAMES = new Set(['UTC', 'Etc/UTC', '+00:00', '-00:00'])
24
+ const OFFSET = /^([+-])(\d{2}):(\d{2})$/
25
+ const ZONE_NAME = /^[A-Za-z][A-Za-z0-9_+\-/]*$/
26
+ // Comparison operators of a measure's where, by SQL operator.
27
+ const WHERE_OPERATORS = { eq: '=', ne: '<>', gt: '>', gte: '>=', lt: '<', lte: '<=' }
20
28
 
21
29
  const invalid = (field, message) => {
22
30
  throw new RestApiValidationError(message, { fields: [field], violations: [{ field, rule: 'aggregate', message }] })
@@ -36,9 +44,13 @@ const positiveInteger = (value, field) => {
36
44
  * Validate an aggregate request against a resource's compiled schema.
37
45
  * Only stored scalar fields whose stored values are their public values qualify:
38
46
  * 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
47
+ * A system context may also group by, and count distinct, a hidden ownership
48
+ * field, so a job can find which owners have rows. A scoped caller would see
49
+ * only its own owner, and one whose optional owner filter did not resolve
50
+ * would see every owner's hidden value, so neither may.
51
+ * @param {{ params: Record<string, any>, schemaInfo: Record<string, any>, scopeName: string, defaultLimit: number, maxLimit: number, system?: boolean }} request
40
52
  */
41
- export function compileAggregateRequest ({ params, schemaInfo, scopeName, defaultLimit, maxLimit }) {
53
+ export function compileAggregateRequest ({ params, schemaInfo, scopeName, defaultLimit, maxLimit, system = false }) {
42
54
  if (!isPlainObject(params)) invalid('params', 'aggregate requires a parameter object')
43
55
  for (const key of Object.keys(params)) {
44
56
  if (!PARAMS.has(key)) invalid(key, `Unknown aggregate parameter '${key}'`)
@@ -50,44 +62,54 @@ export function compileAggregateRequest ({ params, schemaInfo, scopeName, defaul
50
62
  }
51
63
 
52
64
  const fields = schemaInfo.schemaStructure || {}
53
- const fieldDefinition = (name, parameter) => {
65
+ // A storage serializer changes what SQL sees; date and dateTime fields may still be
66
+ // bucketed and take min/max, as SQL orders and truncates their stored temporal values.
67
+ const owners = new Set(system ? schemaInfo.ownershipFields || [] : [])
68
+ const fieldDefinition = (name, parameter, { serializedTemporal = false, owner = false } = {}) => {
54
69
  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) {
70
+ const hidden = definition?.hidden === true && !(owner && owners.has(name))
71
+ if (!definition || name === 'id' || name === schemaInfo.idProperty || definition.virtual === true || hidden) {
56
72
  invalid(parameter, `'${name}' is not an attribute of '${scopeName}' that can be aggregated`)
57
73
  }
58
- if (typeof definition.getter === 'function' || definition.storage?.serialize) {
74
+ const serialized = Boolean(definition.storage?.serialize) && !(serializedTemporal && TEMPORAL_TYPES.has(definition.type))
75
+ if (typeof definition.getter === 'function' || serialized) {
59
76
  invalid(parameter, `Field '${name}' transforms its stored values, so it cannot be aggregated`)
60
77
  }
61
78
  return definition
62
79
  }
63
80
 
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}` }
81
+ if (!Array.isArray(groupBy)) invalid('groupBy', 'groupBy must be an array of field names or buckets')
82
+ const names = new Set()
83
+ const groups = groupBy.map((entry, index) => {
84
+ const parameter = `groupBy.${index}`
85
+ if (isPlainObject(entry)) return compileBucket(entry, { parameter, alias: `g${index}`, fieldDefinition, names })
86
+ const definition = fieldDefinition(entry, parameter, { owner: true })
87
+ if (!SCALAR_TYPES.has(definition.type)) invalid(parameter, `Field '${entry}' has type '${definition.type}' and cannot be grouped`)
88
+ if (names.has(entry)) invalid('groupBy', 'groupBy lists a field twice')
89
+ names.add(entry)
90
+ return { name: entry, field: entry, definition, alias: `g${index}` }
69
91
  })
70
- if (new Set(groupBy).size !== groupBy.length) invalid('groupBy', 'groupBy lists a field twice')
71
92
 
72
93
  if (!isPlainObject(measures) || !Object.keys(measures).length) invalid('measures', 'measures must name at least one measure')
73
- const names = new Set(groupBy)
74
94
  const measureList = Object.entries(measures).map(([name, spec], index) => {
75
95
  const parameter = `measures.${name}`
76
96
  if (!OUTPUT_NAME.test(name)) invalid(parameter, `Measure name '${name}' must be a letter or underscore followed by letters, digits or underscores`)
77
97
  if (names.has(name)) invalid(parameter, `Measure '${name}' has the name of a grouped field`)
78
98
  names.add(name)
79
- const entries = isPlainObject(spec) ? Object.entries(spec) : []
99
+ const { where, ...functions } = isPlainObject(spec) ? spec : {}
100
+ const entries = Object.entries(functions)
80
101
  if (entries.length !== 1 || !Object.hasOwn(MEASURES, entries[0][0])) {
81
- invalid(parameter, `Measure '${name}' must be one of { ${Object.keys(MEASURES).join(' | ')}: field }`)
102
+ invalid(parameter, `Measure '${name}' must be one of { ${Object.keys(MEASURES).join(' | ')}: field, where? }`)
82
103
  }
83
104
  const [fn, field] = entries[0]
105
+ const condition = where === undefined ? null : compileWhere(where, { parameter: `${parameter}.where`, fieldDefinition })
84
106
  if (field === '*') {
85
107
  if (fn !== 'count') invalid(parameter, "Only count accepts '*'")
86
- return { name, fn, field, definition: null, alias: `m${index}` }
108
+ return { name, fn, field, definition: null, alias: `m${index}`, where: condition }
87
109
  }
88
- const definition = fieldDefinition(field, parameter)
110
+ const definition = fieldDefinition(field, parameter, { serializedTemporal: fn === 'min' || fn === 'max', owner: fn === 'countDistinct' })
89
111
  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}` }
112
+ return { name, fn, field, definition, alias: `m${index}`, where: condition }
91
113
  })
92
114
 
93
115
  const outputs = new Map([...groups, ...measureList].map(output => [output.name, output.alias]))
@@ -121,6 +143,155 @@ export function compileAggregateRequest ({ params, schemaInfo, scopeName, defaul
121
143
  return { groups, measures: measureList, sort, page, scopeName }
122
144
  }
123
145
 
146
+ /**
147
+ * A time bucket: { field, bucket: day | week | month | year, timezone?, as? }.
148
+ * Buckets come out as the 'YYYY-MM-DD' of their first day (weeks start on Monday).
149
+ */
150
+ function compileBucket (entry, { parameter, alias, fieldDefinition, names }) {
151
+ const { field, bucket, timezone, as, ...unknown } = entry
152
+ if (Object.keys(unknown).length) invalid(parameter, `Unknown bucket options: ${Object.keys(unknown).join(', ')}`)
153
+ if (!BUCKETS.has(bucket)) invalid(`${parameter}.bucket`, `bucket must be one of ${[...BUCKETS].join(', ')}`)
154
+ const definition = fieldDefinition(field, `${parameter}.field`, { serializedTemporal: true })
155
+ if (!TEMPORAL_TYPES.has(definition.type)) invalid(`${parameter}.field`, `Field '${field}' has type '${definition.type}'; buckets need a date or dateTime field`)
156
+ const zone = compileTimezone(timezone, `${parameter}.timezone`)
157
+ if (definition.type === 'date' && zone.kind !== 'utc') invalid(`${parameter}.timezone`, 'A date field has no time of day, so it takes no timezone')
158
+ const name = as ?? field
159
+ if (typeof name !== 'string' || !OUTPUT_NAME.test(name)) invalid(`${parameter}.as`, 'as must be a letter or underscore followed by letters, digits or underscores')
160
+ if (names.has(name)) invalid(parameter, `groupBy names '${name}' twice; give the bucket another name with as`)
161
+ names.add(name)
162
+ return { name, field, definition, alias, bucket, timezone: zone }
163
+ }
164
+
165
+ // 'UTC' (default), a fixed offset '±HH:MM', or an IANA zone name.
166
+ function compileTimezone (timezone, parameter) {
167
+ if (timezone === undefined || UTC_NAMES.has(timezone)) return { kind: 'utc' }
168
+ if (typeof timezone !== 'string') invalid(parameter, 'timezone must be a string')
169
+ const offset = OFFSET.exec(timezone)
170
+ if (offset) {
171
+ const [, sign, hours, minutes] = offset
172
+ if (Number(hours) > 14 || Number(minutes) > 59) invalid(parameter, `'${timezone}' is not a UTC offset`)
173
+ return { kind: 'offset', minutes: (sign === '-' ? -1 : 1) * (Number(hours) * 60 + Number(minutes)) }
174
+ }
175
+ let known = ZONE_NAME.test(timezone)
176
+ try {
177
+ // eslint-disable-next-line no-new
178
+ new Intl.DateTimeFormat('en-US', { timeZone: timezone })
179
+ } catch {
180
+ known = false
181
+ }
182
+ if (!known) invalid(parameter, `'${timezone}' is not a time zone: use 'UTC', an offset such as '+10:00', or an IANA name`)
183
+ return { kind: 'named', name: timezone }
184
+ }
185
+
186
+ /**
187
+ * A measure's where: { field: value | [values] | null | { eq, ne, gt, gte, lt, lte, in, notIn } },
188
+ * every entry required. Compiled to the condition of a CASE, so one query computes
189
+ * conditional and unconditional measures together.
190
+ */
191
+ function compileWhere (where, { parameter, fieldDefinition }) {
192
+ if (!isPlainObject(where) || !Object.keys(where).length) invalid(parameter, 'where must map fields to conditions')
193
+ const conditions = []
194
+ for (const [field, condition] of Object.entries(where)) {
195
+ const definition = fieldDefinition(field, `${parameter}.${field}`)
196
+ if (!SCALAR_TYPES.has(definition.type)) invalid(`${parameter}.${field}`, `Field '${field}' has type '${definition.type}' and cannot be compared`)
197
+ const add = (operator, value) => {
198
+ if (operator === 'in' || operator === 'notIn') {
199
+ if (!Array.isArray(value) || !value.length || value.some(item => item === null || typeof item === 'object')) {
200
+ invalid(`${parameter}.${field}.${operator}`, `${operator} takes a non-empty array of scalar values`)
201
+ }
202
+ } else if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
203
+ invalid(`${parameter}.${field}`, 'Conditions compare scalar values')
204
+ } else if (value === null && operator !== 'eq' && operator !== 'ne') {
205
+ invalid(`${parameter}.${field}.${operator}`, `${operator} cannot compare with null`)
206
+ }
207
+ conditions.push({ field, definition, operator, value })
208
+ }
209
+ if (Array.isArray(condition)) add('in', condition)
210
+ else if (isPlainObject(condition)) {
211
+ const entries = Object.entries(condition)
212
+ if (!entries.length) invalid(`${parameter}.${field}`, 'A condition needs an operator')
213
+ for (const [operator, value] of entries) {
214
+ if (!Object.hasOwn(WHERE_OPERATORS, operator) && operator !== 'in' && operator !== 'notIn') {
215
+ invalid(`${parameter}.${field}.${operator}`, `Unknown operator '${operator}': use ${[...Object.keys(WHERE_OPERATORS), 'in', 'notIn'].join(', ')}`)
216
+ }
217
+ add(operator, value)
218
+ }
219
+ } else add('eq', condition)
220
+ }
221
+ return conditions
222
+ }
223
+
224
+ /** SQL and bindings for a compiled where, against qualified columns. */
225
+ function whereSql (conditions, { column, storageAdapter }) {
226
+ const parts = []
227
+ const bindings = []
228
+ for (const { field, operator, value } of conditions) {
229
+ const target = column(field)
230
+ if (value === null) {
231
+ parts.push(operator === 'eq' ? '?? is null' : '?? is not null')
232
+ bindings.push(target)
233
+ } else if (operator === 'in' || operator === 'notIn') {
234
+ const values = storageAdapter.translateFilterValue(field, value)
235
+ parts.push(`?? ${operator === 'in' ? 'in' : 'not in'} (${values.map(() => '?').join(', ')})`)
236
+ bindings.push(target, ...values)
237
+ } else {
238
+ parts.push(`?? ${WHERE_OPERATORS[operator]} ?`)
239
+ bindings.push(target, storageAdapter.translateFilterValue(field, value))
240
+ }
241
+ }
242
+ return { sql: parts.join(' and '), bindings }
243
+ }
244
+
245
+ /**
246
+ * The bucket of a stored date or dateTime, as 'YYYY-MM-DD' text. Values are
247
+ * stored in UTC: SQLite holds epoch milliseconds (or ISO text), MySQL DATETIME
248
+ * and PostgreSQL timestamptz or date. Offsets and zone names are validated and
249
+ * written into the SQL, so GROUP BY repeats the selected expression exactly
250
+ * (PostgreSQL cannot match two expressions that differ in bound parameters).
251
+ * The only bindings are the column identifier.
252
+ */
253
+ function bucketSql ({ family, column, bucket, timezone, type }) {
254
+ const minutes = timezone.kind === 'offset' ? timezone.minutes : 0
255
+ if (family === 'pg') {
256
+ let local = type === 'date' ? 'cast(?? as timestamp)' : "(?? at time zone 'UTC')"
257
+ if (timezone.kind === 'offset') local = `(${local} + interval '${minutes} minutes')`
258
+ if (timezone.kind === 'named') local = `(?? at time zone '${timezone.name}')`
259
+ return { sql: `to_char(date_trunc('${bucket}', ${local}), 'YYYY-MM-DD')`, bindings: [column] }
260
+ }
261
+ if (family === 'mysql') {
262
+ let local = '??'
263
+ if (timezone.kind === 'offset') local = `(?? + interval ${minutes} minute)`
264
+ if (timezone.kind === 'named') local = `convert_tz(??, '+00:00', '${timezone.name}')`
265
+ if (bucket === 'week') return { sql: `date_format(${local} - interval weekday(${local}) day, '%Y-%m-%d')`, bindings: [column, column] }
266
+ const format = { day: '%Y-%m-%d', month: '%Y-%m-01', year: '%Y-01-01' }[bucket]
267
+ return { sql: `date_format(${local}, '${format}')`, bindings: [column] }
268
+ }
269
+ // SQLite: epoch milliseconds (the drivers bind dates as numbers) or ISO text.
270
+ let local = "(case when typeof(??) in ('integer', 'real') then datetime(?? / 1000.0, 'unixepoch') else datetime(??) end)"
271
+ if (timezone.kind === 'offset') local = `datetime(${local}, '${minutes >= 0 ? '+' : ''}${minutes} minutes')`
272
+ const bindings = [column, column, column]
273
+ if (bucket === 'week') return { sql: `date(${local}, '-6 days', 'weekday 1')`, bindings }
274
+ const format = { day: '%Y-%m-%d', month: '%Y-%m-01', year: '%Y-01-01' }[bucket]
275
+ return { sql: `strftime('${format}', ${local})`, bindings }
276
+ }
277
+
278
+ // Zone names need the server's zone data: PostgreSQL has it; MySQL only with
279
+ // loaded time zone tables (checked once per name); SQLite has none.
280
+ async function checkNamedZones ({ db, family, plan }) {
281
+ const names = [...new Set(plan.groups.filter(group => group.timezone?.kind === 'named').map(group => group.timezone.name))]
282
+ for (const name of names) {
283
+ if (family === 'sqlite') {
284
+ invalid('groupBy', `SQLite has no time zone data: use 'UTC' or a fixed offset instead of '${name}'`)
285
+ }
286
+ if (family === 'mysql') {
287
+ const [rows] = await db.raw("select convert_tz('2000-01-01 00:00:00', '+00:00', ?) as converted", [name])
288
+ if (rows?.[0]?.converted == null) {
289
+ invalid('groupBy', `The MySQL server has no time zone data for '${name}': load its time zone tables or use a fixed offset`)
290
+ }
291
+ }
292
+ }
293
+ }
294
+
124
295
  const measureValue = (value, measure, resourceType) => {
125
296
  if (measure.fn === 'count' || measure.fn === 'countDistinct') return Number(value ?? 0)
126
297
  if (value == null) return null
@@ -133,9 +304,14 @@ const measureValue = (value, measure, resourceType) => {
133
304
  * Run a compiled aggregate over the rows a filtered query selects.
134
305
  * The filtered query only supplies identifiers, so joins added by filters or
135
306
  * 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
307
+ * @param {{ source: import('knex').Knex.QueryBuilder, sourceTable: string, storageAdapter: Record<string, any>, db: import('knex').Knex | import('knex').Knex.Transaction, plan: import('../storage/storage-types.js').AggregatePlan }} request
137
308
  */
138
309
  export async function runAggregateQuery ({ source, sourceTable, storageAdapter, db, plan }) {
310
+ const family = databaseFamily(db)
311
+ if (plan.groups.some(group => group.bucket) && storageAdapter.isCanonical()) {
312
+ invalid('groupBy', 'Date buckets need table storage; AnyAPI resources group by stored values only')
313
+ }
314
+ await checkNamedZones({ db, family, plan })
139
315
  const idColumn = storageAdapter.getIdColumn()
140
316
  const ids = source.clearSelect().clearOrder().select(`${sourceTable}.${idColumn}`)
141
317
  const query = storageAdapter.buildBaseQuery({ transaction: db, tableAlias: AGGREGATE_ALIAS })
@@ -143,13 +319,26 @@ export async function runAggregateQuery ({ source, sourceTable, storageAdapter,
143
319
  const column = field => `${AGGREGATE_ALIAS}.${storageAdapter.translateColumn(field)}`
144
320
 
145
321
  for (const group of plan.groups) {
146
- query.select({ [group.alias]: column(group.field) }).groupBy(column(group.field))
322
+ if (group.bucket) {
323
+ const { sql, bindings } = bucketSql({ family, column: column(group.field), bucket: group.bucket, timezone: group.timezone, type: group.definition.type })
324
+ query.select(db.raw(`${sql} as ??`, [...bindings, group.alias])).groupByRaw(sql, bindings)
325
+ } else {
326
+ query.select({ [group.alias]: column(group.field) }).groupBy(column(group.field))
327
+ }
147
328
  }
148
329
  for (const measure of plan.measures) {
149
- query[measure.fn]({ [measure.alias]: measure.field === '*' ? '*' : column(measure.field) })
330
+ if (!measure.where) {
331
+ query[measure.fn]({ [measure.alias]: measure.field === '*' ? '*' : column(measure.field) })
332
+ continue
333
+ }
334
+ // Conditional measures aggregate a CASE that is NULL outside the condition.
335
+ const condition = whereSql(measure.where, { column, storageAdapter })
336
+ const value = measure.field === '*' ? { sql: '1', bindings: [] } : { sql: '??', bindings: [column(measure.field)] }
337
+ const fn = { countDistinct: 'count(distinct ', count: 'count(', sum: 'sum(', avg: 'avg(', min: 'min(', max: 'max(' }[measure.fn]
338
+ query.select(db.raw(`${fn}case when ${condition.sql} then ${value.sql} end) as ??`, [...condition.bindings, ...value.bindings, measure.alias]))
150
339
  }
151
340
  // NULL sorts as the lowest value, as MySQL and SQLite order it by default.
152
- const pg = ['pg', 'postgresql'].includes(db.client.config.client)
341
+ const pg = family === 'pg'
153
342
  for (const { alias, direction } of plan.sort) {
154
343
  if (pg) query.orderBy(alias, direction, direction === 'asc' ? 'first' : 'last')
155
344
  else query.orderBy(alias, direction)
@@ -162,7 +351,11 @@ export async function runAggregateQuery ({ source, sourceTable, storageAdapter,
162
351
 
163
352
  const data = rows.map(row => {
164
353
  const result = {}
165
- for (const group of plan.groups) result[group.name] = normalizeStoredFieldValue(row[group.alias], group.definition, { fieldName: group.field, resourceType: plan.scopeName })
354
+ for (const group of plan.groups) {
355
+ result[group.name] = group.bucket
356
+ ? (row[group.alias] == null ? null : String(row[group.alias]))
357
+ : normalizeStoredFieldValue(row[group.alias], group.definition, { fieldName: group.field, resourceType: plan.scopeName })
358
+ }
166
359
  for (const measure of plan.measures) result[measure.name] = measureValue(row[measure.alias], measure, plan.scopeName)
167
360
  return result
168
361
  })