measurr 0.1.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/LICENSE +21 -0
- package/README.md +340 -0
- package/dist/analytics.d.ts +133 -0
- package/dist/compile.d.ts +84 -0
- package/dist/dataset.d.ts +303 -0
- package/dist/dialects/postgres.d.ts +16 -0
- package/dist/dialects/postgres.js +51 -0
- package/dist/expr.d.ts +210 -0
- package/dist/guidance.d.ts +5 -0
- package/dist/index-e6k30sfp.js +218 -0
- package/dist/index-m0czpsyv.js +1292 -0
- package/dist/index-nrjdssft.js +200 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +78 -0
- package/dist/period.d.ts +44 -0
- package/dist/plan.d.ts +86 -0
- package/dist/query.d.ts +619 -0
- package/dist/testing.d.ts +38 -0
- package/dist/testing.js +191 -0
- package/package.json +84 -0
- package/src/analytics.ts +663 -0
- package/src/compile.ts +332 -0
- package/src/dataset.ts +783 -0
- package/src/dialects/postgres.ts +74 -0
- package/src/expr.ts +539 -0
- package/src/guidance.ts +10 -0
- package/src/index.ts +74 -0
- package/src/period.ts +212 -0
- package/src/plan.ts +588 -0
- package/src/query.ts +399 -0
- package/src/testing.ts +283 -0
package/src/plan.ts
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Dialect,
|
|
3
|
+
JoinClause,
|
|
4
|
+
SelectItem,
|
|
5
|
+
SelectStatement
|
|
6
|
+
} from './compile.js'
|
|
7
|
+
import type { AnyDataset, AnyDimension, AnyMeasure } from './dataset.js'
|
|
8
|
+
import type { ExprNode, ParamValue } from './expr.js'
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_PERIOD,
|
|
11
|
+
type PeriodInput,
|
|
12
|
+
type ResolvedPeriod
|
|
13
|
+
} from './period.js'
|
|
14
|
+
import { DEFAULT_LIMIT } from './query.js'
|
|
15
|
+
|
|
16
|
+
/** A parsed query with every default filled in and duplicates removed. */
|
|
17
|
+
export type NormalizedQuery = {
|
|
18
|
+
dataset: string
|
|
19
|
+
measures: string[]
|
|
20
|
+
groupBy: string[]
|
|
21
|
+
filters: NormalizedFilter[]
|
|
22
|
+
period: PeriodInput
|
|
23
|
+
time: string
|
|
24
|
+
compareToPrevious: boolean
|
|
25
|
+
sort: { by: string; direction: 'asc' | 'desc' } | null
|
|
26
|
+
limit: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type NormalizedFilter =
|
|
30
|
+
| { dimension: string; op: 'in' | 'notIn'; values: (string | number)[] }
|
|
31
|
+
| {
|
|
32
|
+
dimension: string
|
|
33
|
+
op: 'between'
|
|
34
|
+
from: string | number
|
|
35
|
+
to: string | number
|
|
36
|
+
}
|
|
37
|
+
| { dimension: string; op: 'isEmpty' | 'isNotEmpty' }
|
|
38
|
+
|
|
39
|
+
/** The parsed shape the schema produces (all optional fields possibly absent). */
|
|
40
|
+
export type ParsedQuery = {
|
|
41
|
+
dataset: string
|
|
42
|
+
measures: readonly string[]
|
|
43
|
+
groupBy?: readonly string[] | undefined
|
|
44
|
+
filters?: readonly NormalizedFilter[] | undefined
|
|
45
|
+
period?: PeriodInput | undefined
|
|
46
|
+
time?: string | undefined
|
|
47
|
+
compareToPrevious?: boolean | undefined
|
|
48
|
+
sort?: { by: string; direction?: 'asc' | 'desc' | undefined } | undefined
|
|
49
|
+
limit?: number | undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function normalizeQuery(
|
|
53
|
+
dataset: AnyDataset,
|
|
54
|
+
parsed: ParsedQuery
|
|
55
|
+
): NormalizedQuery {
|
|
56
|
+
const filters = (parsed.filters ?? []).map((filter) =>
|
|
57
|
+
filter.op === 'in' || filter.op === 'notIn'
|
|
58
|
+
? { ...filter, values: [...new Set(filter.values)] }
|
|
59
|
+
: filter
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
dataset: dataset.key,
|
|
64
|
+
measures: [...new Set(parsed.measures)],
|
|
65
|
+
// Order matters: the first grouped dimension leads the rows.
|
|
66
|
+
groupBy: [...new Set(parsed.groupBy ?? [])],
|
|
67
|
+
filters,
|
|
68
|
+
period: parsed.period ?? DEFAULT_PERIOD,
|
|
69
|
+
time: parsed.time ?? dataset.defaultTime,
|
|
70
|
+
compareToPrevious: parsed.compareToPrevious ?? false,
|
|
71
|
+
sort: parsed.sort
|
|
72
|
+
? { by: parsed.sort.by, direction: parsed.sort.direction ?? 'desc' }
|
|
73
|
+
: null,
|
|
74
|
+
limit: parsed.limit ?? DEFAULT_LIMIT
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What the planner needs besides the query: values from the hooks and resolved labels. */
|
|
79
|
+
export type PlanContext = {
|
|
80
|
+
tenant: string | number
|
|
81
|
+
timezone: string
|
|
82
|
+
period: ResolvedPeriod
|
|
83
|
+
/** The moment the query runs. */
|
|
84
|
+
now: Date
|
|
85
|
+
/** Keys for filters on open dimensions, after label resolution, by dimension. */
|
|
86
|
+
resolvedValues: ReadonlyMap<string, readonly (string | number)[]>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const param = (value: ParamValue): ExprNode => ({ kind: 'param', value })
|
|
90
|
+
|
|
91
|
+
const and = (items: ExprNode[]): ExprNode =>
|
|
92
|
+
items.length === 1 && items[0]
|
|
93
|
+
? items[0]
|
|
94
|
+
: { kind: 'logical', op: 'and', items }
|
|
95
|
+
|
|
96
|
+
function measureOf(dataset: AnyDataset, key: string): AnyMeasure {
|
|
97
|
+
const entry: AnyMeasure | undefined = dataset.measures[key]
|
|
98
|
+
if (!entry) throw new Error(`Dataset ${dataset.key} has no measure ${key}`)
|
|
99
|
+
|
|
100
|
+
return entry
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function dimensionOf(dataset: AnyDataset, key: string): AnyDimension {
|
|
104
|
+
const entry: AnyDimension | undefined = dataset.dimensions[key]
|
|
105
|
+
if (!entry) throw new Error(`Dataset ${dataset.key} has no dimension ${key}`)
|
|
106
|
+
|
|
107
|
+
return entry
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A measure as one aggregate expression; shares and ratios divide after aggregation. */
|
|
111
|
+
function measureNode(dataset: AnyDataset, key: string): ExprNode {
|
|
112
|
+
const entry = measureOf(dataset, key)
|
|
113
|
+
|
|
114
|
+
switch (entry.kind) {
|
|
115
|
+
case 'aggregate':
|
|
116
|
+
return entry.node
|
|
117
|
+
case 'share': {
|
|
118
|
+
const numeratorWhere = entry.denominator
|
|
119
|
+
? and([entry.numerator, entry.denominator])
|
|
120
|
+
: entry.numerator
|
|
121
|
+
return {
|
|
122
|
+
kind: 'arithmetic',
|
|
123
|
+
op: '/',
|
|
124
|
+
left: {
|
|
125
|
+
kind: 'aggregate',
|
|
126
|
+
fn: 'count',
|
|
127
|
+
arg: null,
|
|
128
|
+
where: numeratorWhere
|
|
129
|
+
},
|
|
130
|
+
right: {
|
|
131
|
+
kind: 'aggregate',
|
|
132
|
+
fn: 'count',
|
|
133
|
+
arg: null,
|
|
134
|
+
where: entry.denominator
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
case 'ratio':
|
|
139
|
+
return {
|
|
140
|
+
kind: 'arithmetic',
|
|
141
|
+
op: '/',
|
|
142
|
+
left: measureNode(dataset, entry.numerator),
|
|
143
|
+
right: measureNode(dataset, entry.denominator)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
type DimensionNodes = {
|
|
149
|
+
key: ExprNode
|
|
150
|
+
label: ExprNode | null
|
|
151
|
+
virtualJoin: JoinClause | null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function dimensionNodes(
|
|
155
|
+
dataset: AnyDataset,
|
|
156
|
+
key: string,
|
|
157
|
+
timezone: string
|
|
158
|
+
): DimensionNodes {
|
|
159
|
+
const entry = dimensionOf(dataset, key)
|
|
160
|
+
|
|
161
|
+
switch (entry.kind) {
|
|
162
|
+
case 'enum':
|
|
163
|
+
case 'computed':
|
|
164
|
+
case 'number':
|
|
165
|
+
return { key: entry.expr, label: null, virtualJoin: null }
|
|
166
|
+
case 'relation':
|
|
167
|
+
return { key: entry.key, label: entry.name, virtualJoin: null }
|
|
168
|
+
case 'manyToMany':
|
|
169
|
+
return {
|
|
170
|
+
key: entry.key,
|
|
171
|
+
label: entry.name,
|
|
172
|
+
virtualJoin: { table: entry.table, type: 'left', on: entry.on }
|
|
173
|
+
}
|
|
174
|
+
case 'timeBucket':
|
|
175
|
+
case 'timePart': {
|
|
176
|
+
const field = dataset.time[entry.time]
|
|
177
|
+
if (!field)
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Dataset ${dataset.key} has no time field ${entry.time}`
|
|
180
|
+
)
|
|
181
|
+
return {
|
|
182
|
+
key:
|
|
183
|
+
entry.kind === 'timeBucket'
|
|
184
|
+
? {
|
|
185
|
+
kind: 'timeBucket',
|
|
186
|
+
unit: entry.unit,
|
|
187
|
+
item: field.column,
|
|
188
|
+
timezone
|
|
189
|
+
}
|
|
190
|
+
: {
|
|
191
|
+
kind: 'timePart',
|
|
192
|
+
part: entry.part,
|
|
193
|
+
item: field.column,
|
|
194
|
+
timezone
|
|
195
|
+
},
|
|
196
|
+
label: null,
|
|
197
|
+
virtualJoin: null
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Tenant, scope and period: the conditions every statement of a query carries. */
|
|
204
|
+
function baseWhere(
|
|
205
|
+
dataset: AnyDataset,
|
|
206
|
+
tenant: string | number,
|
|
207
|
+
period: { time: string; start: Date; end: Date } | null
|
|
208
|
+
): ExprNode[] {
|
|
209
|
+
const where: ExprNode[] = [
|
|
210
|
+
{
|
|
211
|
+
kind: 'compare',
|
|
212
|
+
op: '=',
|
|
213
|
+
left: dataset.tenantColumn,
|
|
214
|
+
right: param(tenant)
|
|
215
|
+
}
|
|
216
|
+
]
|
|
217
|
+
if (dataset.scope) where.push(dataset.scope)
|
|
218
|
+
|
|
219
|
+
if (period) {
|
|
220
|
+
const field = dataset.time[period.time]
|
|
221
|
+
if (!field)
|
|
222
|
+
throw new Error(`Dataset ${dataset.key} has no time field ${period.time}`)
|
|
223
|
+
where.push(
|
|
224
|
+
{
|
|
225
|
+
kind: 'compare',
|
|
226
|
+
op: '>=',
|
|
227
|
+
left: field.column,
|
|
228
|
+
right: param(period.start)
|
|
229
|
+
},
|
|
230
|
+
{ kind: 'compare', op: '<', left: field.column, right: param(period.end) }
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return where
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function filterNode(
|
|
238
|
+
dataset: AnyDataset,
|
|
239
|
+
filter: NormalizedFilter,
|
|
240
|
+
context: PlanContext
|
|
241
|
+
): ExprNode {
|
|
242
|
+
const entry = dimensionOf(dataset, filter.dimension)
|
|
243
|
+
const { key } = dimensionNodes(dataset, filter.dimension, context.timezone)
|
|
244
|
+
|
|
245
|
+
if (entry.kind === 'manyToMany') {
|
|
246
|
+
const linked = (
|
|
247
|
+
condition: ExprNode | null,
|
|
248
|
+
negated: boolean
|
|
249
|
+
): ExprNode => ({
|
|
250
|
+
kind: 'exists',
|
|
251
|
+
table: entry.table,
|
|
252
|
+
where: condition ? and([entry.on, condition]) : entry.on,
|
|
253
|
+
negated
|
|
254
|
+
})
|
|
255
|
+
switch (filter.op) {
|
|
256
|
+
case 'isEmpty':
|
|
257
|
+
case 'isNotEmpty':
|
|
258
|
+
return linked(null, filter.op === 'isEmpty')
|
|
259
|
+
case 'between':
|
|
260
|
+
throw new Error(`${filter.dimension} does not support between`)
|
|
261
|
+
case 'in':
|
|
262
|
+
case 'notIn': {
|
|
263
|
+
const values =
|
|
264
|
+
context.resolvedValues.get(filter.dimension) ?? filter.values
|
|
265
|
+
const matches: ExprNode = {
|
|
266
|
+
kind: 'in',
|
|
267
|
+
item: key,
|
|
268
|
+
values: values.map(param),
|
|
269
|
+
negated: false
|
|
270
|
+
}
|
|
271
|
+
return linked(matches, filter.op === 'notIn')
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
switch (filter.op) {
|
|
277
|
+
case 'isEmpty':
|
|
278
|
+
return { kind: 'isNull', item: key, negated: false }
|
|
279
|
+
case 'isNotEmpty':
|
|
280
|
+
return { kind: 'isNull', item: key, negated: true }
|
|
281
|
+
case 'between':
|
|
282
|
+
return and([
|
|
283
|
+
{ kind: 'compare', op: '>=', left: key, right: param(filter.from) },
|
|
284
|
+
{ kind: 'compare', op: '<=', left: key, right: param(filter.to) }
|
|
285
|
+
])
|
|
286
|
+
case 'in':
|
|
287
|
+
case 'notIn': {
|
|
288
|
+
const values =
|
|
289
|
+
context.resolvedValues.get(filter.dimension) ?? filter.values
|
|
290
|
+
const list: ExprNode = {
|
|
291
|
+
kind: 'in',
|
|
292
|
+
item: key,
|
|
293
|
+
values: values.map(param),
|
|
294
|
+
negated: filter.op === 'notIn'
|
|
295
|
+
}
|
|
296
|
+
if (filter.op === 'in') return list
|
|
297
|
+
// "Not one of these" keeps rows without a value, unlike SQL's NOT IN.
|
|
298
|
+
return {
|
|
299
|
+
kind: 'logical',
|
|
300
|
+
op: 'or',
|
|
301
|
+
items: [list, { kind: 'isNull', item: key, negated: false }]
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Output column aliases. Keys come from definitions, never from input. */
|
|
308
|
+
export const columnAlias = {
|
|
309
|
+
measure: (key: string) => `m_${key}`,
|
|
310
|
+
groupKey: (key: string) => `g_${key}`,
|
|
311
|
+
groupLabel: (key: string) => `g_${key}__label`
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The statement for a query's rows, or for its totals when `grouped` is false. Totals never
|
|
316
|
+
* group, so a many-to-many grouping cannot inflate them.
|
|
317
|
+
*/
|
|
318
|
+
export function planQuery(
|
|
319
|
+
dataset: AnyDataset,
|
|
320
|
+
query: NormalizedQuery,
|
|
321
|
+
context: PlanContext,
|
|
322
|
+
grouped: boolean
|
|
323
|
+
): SelectStatement {
|
|
324
|
+
const select: SelectItem[] = []
|
|
325
|
+
const groupBy: number[] = []
|
|
326
|
+
const virtualJoins: JoinClause[] = []
|
|
327
|
+
const groupKeys = grouped ? query.groupBy : []
|
|
328
|
+
|
|
329
|
+
for (const key of groupKeys) {
|
|
330
|
+
const nodes = dimensionNodes(dataset, key, context.timezone)
|
|
331
|
+
if (nodes.virtualJoin) virtualJoins.push(nodes.virtualJoin)
|
|
332
|
+
|
|
333
|
+
groupBy.push(select.length)
|
|
334
|
+
select.push({ alias: columnAlias.groupKey(key), node: nodes.key })
|
|
335
|
+
if (nodes.label) {
|
|
336
|
+
groupBy.push(select.length)
|
|
337
|
+
select.push({ alias: columnAlias.groupLabel(key), node: nodes.label })
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
for (const key of query.measures) {
|
|
341
|
+
select.push({
|
|
342
|
+
alias: columnAlias.measure(key),
|
|
343
|
+
node: measureNode(dataset, key)
|
|
344
|
+
})
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const where = [
|
|
348
|
+
...baseWhere(dataset, context.tenant, {
|
|
349
|
+
time: query.time,
|
|
350
|
+
start: context.period.start,
|
|
351
|
+
end: context.period.end
|
|
352
|
+
}),
|
|
353
|
+
...query.filters.map((filter) => filterNode(dataset, filter, context))
|
|
354
|
+
]
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
from: dataset.from,
|
|
358
|
+
virtualJoins,
|
|
359
|
+
where,
|
|
360
|
+
select,
|
|
361
|
+
groupBy,
|
|
362
|
+
orderBy: groupKeys.length > 0 ? orderBy(dataset, query, select) : [],
|
|
363
|
+
limit: grouped && groupKeys.length > 0 ? query.limit + 1 : null,
|
|
364
|
+
now: context.now
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function orderBy(
|
|
369
|
+
dataset: AnyDataset,
|
|
370
|
+
query: NormalizedQuery,
|
|
371
|
+
select: readonly SelectItem[]
|
|
372
|
+
): SelectStatement['orderBy'] {
|
|
373
|
+
const indexOf = (alias: string) =>
|
|
374
|
+
select.findIndex((item) => item.alias === alias)
|
|
375
|
+
const order: { index: number; direction: 'asc' | 'desc' }[] = []
|
|
376
|
+
|
|
377
|
+
const timeKey = query.groupBy.find((key) => {
|
|
378
|
+
const kind = dimensionOf(dataset, key).kind
|
|
379
|
+
return kind === 'timeBucket' || kind === 'timePart'
|
|
380
|
+
})
|
|
381
|
+
const sort =
|
|
382
|
+
query.sort ??
|
|
383
|
+
(timeKey
|
|
384
|
+
? { by: timeKey, direction: 'asc' as const }
|
|
385
|
+
: { by: query.measures[0] ?? '', direction: 'desc' as const })
|
|
386
|
+
|
|
387
|
+
const sortIndex =
|
|
388
|
+
sort.by in dataset.measures
|
|
389
|
+
? indexOf(columnAlias.measure(sort.by))
|
|
390
|
+
: indexOf(columnAlias.groupKey(sort.by))
|
|
391
|
+
if (sortIndex >= 0)
|
|
392
|
+
order.push({ index: sortIndex, direction: sort.direction })
|
|
393
|
+
|
|
394
|
+
// Ties break on the group keys, so the same query always returns rows in the same order.
|
|
395
|
+
for (const key of query.groupBy) {
|
|
396
|
+
const index = indexOf(columnAlias.groupKey(key))
|
|
397
|
+
if (index >= 0 && index !== sortIndex)
|
|
398
|
+
order.push({ index, direction: 'asc' })
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return order
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Looks up an open dimension's keys and names. With `values`, only entries whose key or name
|
|
406
|
+
* (case-insensitive) matches; without, the first entries by name, to list valid values.
|
|
407
|
+
*/
|
|
408
|
+
export function planLookup(
|
|
409
|
+
dataset: AnyDataset,
|
|
410
|
+
dimensionKey: string,
|
|
411
|
+
tenant: string | number,
|
|
412
|
+
values: readonly string[] | null,
|
|
413
|
+
now: Date
|
|
414
|
+
): SelectStatement {
|
|
415
|
+
const nodes = dimensionNodes(dataset, dimensionKey, 'UTC')
|
|
416
|
+
const label = nodes.label ?? nodes.key
|
|
417
|
+
const where = baseWhere(dataset, tenant, null)
|
|
418
|
+
|
|
419
|
+
where.push({ kind: 'isNull', item: nodes.key, negated: true })
|
|
420
|
+
if (values) {
|
|
421
|
+
where.push({
|
|
422
|
+
kind: 'logical',
|
|
423
|
+
op: 'or',
|
|
424
|
+
items: [
|
|
425
|
+
{
|
|
426
|
+
kind: 'in',
|
|
427
|
+
item: { kind: 'toText', item: nodes.key },
|
|
428
|
+
values: values.map(param),
|
|
429
|
+
negated: false
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
kind: 'in',
|
|
433
|
+
item: { kind: 'lower', item: label },
|
|
434
|
+
values: values.map((value) => param(value.toLowerCase())),
|
|
435
|
+
negated: false
|
|
436
|
+
}
|
|
437
|
+
]
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
from: dataset.from,
|
|
443
|
+
virtualJoins: nodes.virtualJoin ? [nodes.virtualJoin] : [],
|
|
444
|
+
where,
|
|
445
|
+
select: [
|
|
446
|
+
{ alias: 'key', node: nodes.key },
|
|
447
|
+
{ alias: 'label', node: label }
|
|
448
|
+
],
|
|
449
|
+
groupBy: [0, 1],
|
|
450
|
+
orderBy: [{ index: 1, direction: 'asc' }],
|
|
451
|
+
limit: values ? 500 : 50,
|
|
452
|
+
now
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ---------------------------------------------------------------------------------------------
|
|
457
|
+
// Shaping
|
|
458
|
+
|
|
459
|
+
const WEEKDAYS = [
|
|
460
|
+
'Monday',
|
|
461
|
+
'Tuesday',
|
|
462
|
+
'Wednesday',
|
|
463
|
+
'Thursday',
|
|
464
|
+
'Friday',
|
|
465
|
+
'Saturday',
|
|
466
|
+
'Sunday'
|
|
467
|
+
]
|
|
468
|
+
|
|
469
|
+
export type ShapedValue = { key: string | number | null; label: string }
|
|
470
|
+
export type ShapedRow = Record<string, ShapedValue | number | null>
|
|
471
|
+
|
|
472
|
+
function decodeKey(
|
|
473
|
+
dimension: AnyDimension,
|
|
474
|
+
raw: unknown,
|
|
475
|
+
dialect: Dialect
|
|
476
|
+
): string | number | null {
|
|
477
|
+
if (raw === null || raw === undefined) return null
|
|
478
|
+
|
|
479
|
+
switch (dimension.kind) {
|
|
480
|
+
case 'number':
|
|
481
|
+
case 'timePart':
|
|
482
|
+
return dialect.decodeNumber(raw)
|
|
483
|
+
case 'relation':
|
|
484
|
+
case 'manyToMany':
|
|
485
|
+
if (typeof raw === 'string') return raw
|
|
486
|
+
return dialect.decodeNumber(raw) ?? String(raw)
|
|
487
|
+
default:
|
|
488
|
+
return String(raw)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function labelOf(
|
|
493
|
+
dimension: AnyDimension,
|
|
494
|
+
key: string | number | null,
|
|
495
|
+
rawLabel: unknown
|
|
496
|
+
): string {
|
|
497
|
+
switch (dimension.kind) {
|
|
498
|
+
case 'enum':
|
|
499
|
+
case 'computed':
|
|
500
|
+
return key === null
|
|
501
|
+
? (dimension.empty ?? 'None')
|
|
502
|
+
: (dimension.values[String(key)] ?? String(key))
|
|
503
|
+
case 'relation':
|
|
504
|
+
case 'manyToMany':
|
|
505
|
+
if (key === null) return dimension.empty ?? 'None'
|
|
506
|
+
return rawLabel === null || rawLabel === undefined
|
|
507
|
+
? String(key)
|
|
508
|
+
: String(rawLabel)
|
|
509
|
+
case 'timeBucket':
|
|
510
|
+
if (key === null) return 'None'
|
|
511
|
+
return dimension.labelFor?.(String(key)) ?? String(key)
|
|
512
|
+
case 'timePart':
|
|
513
|
+
if (typeof key !== 'number') return 'None'
|
|
514
|
+
if (dimension.labelFor) return dimension.labelFor(key)
|
|
515
|
+
return dimension.part === 'isodow'
|
|
516
|
+
? (WEEKDAYS[key - 1] ?? String(key))
|
|
517
|
+
: `${String(key).padStart(2, '0')}:00`
|
|
518
|
+
case 'number':
|
|
519
|
+
if (typeof key !== 'number') return 'None'
|
|
520
|
+
return dimension.labelFor?.(key) ?? String(key)
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Decodes measures in one raw row; counts are never null. */
|
|
525
|
+
export function shapeMeasures(
|
|
526
|
+
dataset: AnyDataset,
|
|
527
|
+
measures: readonly string[],
|
|
528
|
+
raw: Record<string, unknown>,
|
|
529
|
+
dialect: Dialect
|
|
530
|
+
): Record<string, number | null> {
|
|
531
|
+
const values: Record<string, number | null> = {}
|
|
532
|
+
for (const key of measures) {
|
|
533
|
+
const value = dialect.decodeNumber(raw[columnAlias.measure(key)])
|
|
534
|
+
values[key] =
|
|
535
|
+
measureOf(dataset, key).result === 'count' ? (value ?? 0) : value
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return values
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export function shapeRows(
|
|
542
|
+
dataset: AnyDataset,
|
|
543
|
+
query: NormalizedQuery,
|
|
544
|
+
rawRows: readonly Record<string, unknown>[],
|
|
545
|
+
dialect: Dialect
|
|
546
|
+
): { rows: ShapedRow[]; truncated: boolean } {
|
|
547
|
+
const truncated = query.groupBy.length > 0 && rawRows.length > query.limit
|
|
548
|
+
const kept = truncated ? rawRows.slice(0, query.limit) : rawRows
|
|
549
|
+
|
|
550
|
+
const rows = kept.map((raw) => {
|
|
551
|
+
const row: ShapedRow = {}
|
|
552
|
+
for (const key of query.groupBy) {
|
|
553
|
+
const dimension = dimensionOf(dataset, key)
|
|
554
|
+
const value = decodeKey(
|
|
555
|
+
dimension,
|
|
556
|
+
raw[columnAlias.groupKey(key)],
|
|
557
|
+
dialect
|
|
558
|
+
)
|
|
559
|
+
row[key] = {
|
|
560
|
+
key: value,
|
|
561
|
+
label: labelOf(dimension, value, raw[columnAlias.groupLabel(key)])
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return { ...row, ...shapeMeasures(dataset, query.measures, raw, dialect) }
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
return { rows, truncated }
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Notes a reader needs to read the numbers right. */
|
|
572
|
+
export function resultNotes(
|
|
573
|
+
dataset: AnyDataset,
|
|
574
|
+
query: NormalizedQuery
|
|
575
|
+
): string[] {
|
|
576
|
+
const notes: string[] = []
|
|
577
|
+
|
|
578
|
+
for (const key of query.groupBy) {
|
|
579
|
+
const dimension = dimensionOf(dataset, key)
|
|
580
|
+
if (dimension.kind !== 'manyToMany') continue
|
|
581
|
+
notes.push(
|
|
582
|
+
dimension.note ??
|
|
583
|
+
`A row with several ${dimension.label.toLowerCase()} values counts once per value, so ${dimension.label.toLowerCase()} rows can add up to more than the total.`
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return notes
|
|
588
|
+
}
|