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/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
+ }