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.
- package/README.md +7 -2
- package/docs/API.md +262 -54
- package/docs/GUIDE/01-getting-started.md +1 -1
- package/docs/GUIDE/07-polymorphic-relationships.md +4 -2
- package/docs/GUIDE/09-pagination-and-sorting.md +4 -2
- package/docs/GUIDE/13-hooks-and-lifecycle.md +34 -6
- package/docs/GUIDE/14-custom-resource-methods.md +3 -2
- package/docs/GUIDE/16-autofiltering.md +54 -2
- package/docs/GUIDE/17-row-policies.md +15 -5
- package/docs/GUIDE/19-managed-transactions.md +8 -9
- package/docs/GUIDE/20-transaction-outcomes.md +3 -4
- package/docs/GUIDE/21-schema-and-migrations.md +7 -1
- package/docs/GUIDE/22-version-field-migrations.md +3 -3
- package/docs/GUIDE/27-many-row-writes.md +458 -0
- package/docs/GUIDE/28-socketio.md +10 -2
- package/docs/GUIDE/30-backend-capabilities.md +42 -2
- package/docs/GUIDE/31-positioning.md +3 -1
- package/docs/GUIDE/33-migrating-to-v2.md +2 -2
- package/docs/GUIDE/34-scoped-reads-and-locks.md +464 -0
- package/docs/GUIDE/35-migrating-to-v3.md +325 -0
- package/docs/GUIDE/index.md +5 -2
- package/docs/QUICKSTART.md +2 -2
- package/docs/contributing.md +1 -1
- package/index.d.ts +8 -1
- package/index.js +4 -0
- package/lib/rest-api-errors.js +79 -0
- package/lib/runtime/json-rest-api.js +18 -3
- package/lib/system-context.js +42 -0
- package/package.json +3 -2
- package/plugins/core/bulk-operations-plugin.js +54 -436
- package/plugins/core/connectors/lib/transport-http-helpers.js +34 -3
- package/plugins/core/file-handling-plugin.js +17 -2
- package/plugins/core/lib/anyapi/anyapi-registry.js +3 -2
- package/plugins/core/lib/anyapi/canonical-link-store.js +5 -2
- package/plugins/core/lib/anyapi/canonical-relationship-reader.js +56 -60
- package/plugins/core/lib/dbIntrospection.js +106 -6
- package/plugins/core/lib/querying/aggregate-query.js +215 -22
- package/plugins/core/lib/querying/include-query-helpers.js +11 -13
- package/plugins/core/lib/querying/include-to-one.js +54 -59
- package/plugins/core/lib/querying/knex-pagination-helpers.js +9 -2
- package/plugins/core/lib/querying/query-constraint.d.ts +11 -0
- package/plugins/core/lib/querying/query-constraint.js +45 -0
- package/plugins/core/lib/querying/read-only-query.js +92 -0
- package/plugins/core/lib/querying-writing/compile-schemas.js +14 -0
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
- package/plugins/core/lib/storage/canonical-data-helpers.js +103 -39
- package/plugins/core/lib/storage/many-row-data-helpers.js +738 -0
- package/plugins/core/lib/storage/ordinary-data-helpers.js +63 -79
- package/plugins/core/lib/storage/single-row-writes.js +71 -0
- package/plugins/core/lib/storage/storage-adapter.js +18 -8
- package/plugins/core/lib/storage/storage-mapping.js +4 -1
- package/plugins/core/lib/storage/storage-types.d.ts +21 -3
- package/plugins/core/lib/writing/many-to-many-manipulations.js +10 -5
- package/plugins/core/lib/writing/relationship-processor.js +33 -37
- package/plugins/core/rest-api-anyapi-knex-plugin.js +3 -0
- package/plugins/core/rest-api-autofilter-plugin.js +137 -10
- package/plugins/core/rest-api-knex-plugin.js +2 -0
- package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
- package/plugins/core/rest-api-plugin-methods/common.js +144 -84
- package/plugins/core/rest-api-plugin-methods/delete-many.js +132 -0
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +22 -0
- package/plugins/core/rest-api-plugin-methods/get.js +48 -42
- package/plugins/core/rest-api-plugin-methods/many-row-common.js +274 -0
- package/plugins/core/rest-api-plugin-methods/patch-many.js +333 -0
- package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
- package/plugins/core/rest-api-plugin-methods/post-many.js +295 -0
- package/plugins/core/rest-api-plugin-methods/put.js +5 -1
- package/plugins/core/rest-api-plugin-methods/scoped-reads.js +221 -0
- package/plugins/core/rest-api-plugin.js +17 -0
- package/plugins/core/rest-api-positioning-plugin.js +22 -4
- package/plugins/core/rest-api-query-projections-plugin.js +9 -1
- package/plugins/core/rest-api-row-policy-plugin.js +4 -0
- package/plugins/core/socketio-plugin.js +80 -2
- package/types/autofilter.d.ts +5 -1
- package/types/data-access-methods.d.ts +141 -0
- package/types/errors.d.ts +45 -0
- package/types/hook-context.d.ts +21 -0
- package/types/plugin-options.d.ts +3 -1
- package/types/resource-methods.d.ts +27 -4
- package/types/row-policy.d.ts +3 -0
- package/types/runtime.d.ts +7 -1
- package/docs/GUIDE/27-bulk-operations.md +0 -277
- 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
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1058
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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)
|
|
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
|
})
|