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/dataset.ts ADDED
@@ -0,0 +1,783 @@
1
+ import {
2
+ type Expr,
3
+ type ExprNode,
4
+ type SqlType,
5
+ type Table,
6
+ type TableRef,
7
+ type TimeBucketUnit,
8
+ type TimePartName,
9
+ directChildren,
10
+ rootOf
11
+ } from './expr.js'
12
+
13
+ declare const ratioBrand: unique symbol
14
+
15
+ /** A share or ratio between 0 and 1. Branded so nobody renders 0.42 as "0.42%". */
16
+ export type Ratio = number & { readonly [ratioBrand]: 'zeroToOne' }
17
+
18
+ type Described = {
19
+ label: string
20
+ description?: string | undefined
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------------------------
24
+ // Measures
25
+
26
+ /**
27
+ * `count` results are never null. `number` results (sum, avg, median) are null for an empty
28
+ * group. `ratio` results (share, ratio) are a 0-1 `Ratio`, null when the base is zero.
29
+ */
30
+ export type MeasureResult = 'count' | 'number' | 'ratio'
31
+
32
+ export type AggregateMeasure<
33
+ R extends 'count' | 'number' = 'count' | 'number'
34
+ > = {
35
+ readonly kind: 'aggregate'
36
+ readonly result: R
37
+ readonly label: string
38
+ readonly description: string | undefined
39
+ readonly node: Extract<ExprNode, { kind: 'aggregate' }>
40
+ }
41
+
42
+ export type ShareMeasure = {
43
+ readonly kind: 'share'
44
+ readonly result: 'ratio'
45
+ readonly label: string
46
+ readonly description: string | undefined
47
+ readonly numerator: ExprNode
48
+ readonly denominator: ExprNode | null
49
+ }
50
+
51
+ export type RatioMeasure<
52
+ A extends string = string,
53
+ B extends string = string
54
+ > = {
55
+ readonly kind: 'ratio'
56
+ readonly result: 'ratio'
57
+ readonly label: string
58
+ readonly description: string | undefined
59
+ readonly numerator: A
60
+ readonly denominator: B
61
+ }
62
+
63
+ export type AnyMeasure = AggregateMeasure | ShareMeasure | RatioMeasure
64
+
65
+ /** The TypeScript type of a measure's value in a result row. */
66
+ export type MeasureValue<M> = M extends { result: 'count' }
67
+ ? number
68
+ : M extends { result: 'number' }
69
+ ? number | null
70
+ : Ratio | null
71
+
72
+ type Filtered = Described & { where?: Expr<'boolean'> | undefined }
73
+
74
+ function aggregate<R extends 'count' | 'number'>(
75
+ result: R,
76
+ fn: Extract<ExprNode, { kind: 'aggregate' }>['fn'],
77
+ arg: Expr<SqlType> | null,
78
+ options: Filtered
79
+ ): AggregateMeasure<R> {
80
+ return {
81
+ kind: 'aggregate',
82
+ result,
83
+ label: options.label,
84
+ description: options.description,
85
+ node: {
86
+ kind: 'aggregate',
87
+ fn,
88
+ arg: arg?.node ?? null,
89
+ where: options.where?.node ?? null
90
+ }
91
+ }
92
+ }
93
+
94
+ export const measure = {
95
+ /** Number of rows, optionally only those matching `where`. */
96
+ count(options: Filtered): AggregateMeasure<'count'> {
97
+ return aggregate('count', 'count', null, options)
98
+ },
99
+
100
+ /** Number of rows matching `condition`. */
101
+ countWhere(
102
+ condition: Expr<'boolean'>,
103
+ options: Described
104
+ ): AggregateMeasure<'count'> {
105
+ return aggregate('count', 'count', null, { ...options, where: condition })
106
+ },
107
+
108
+ /** Number of distinct non-null values of `expr`. */
109
+ countDistinct(
110
+ expr: Expr<SqlType>,
111
+ options: Filtered
112
+ ): AggregateMeasure<'count'> {
113
+ return aggregate('count', 'countDistinct', expr, options)
114
+ },
115
+
116
+ sum(expr: Expr<'number'>, options: Filtered): AggregateMeasure<'number'> {
117
+ return aggregate('number', 'sum', expr, options)
118
+ },
119
+
120
+ avg(expr: Expr<'number'>, options: Filtered): AggregateMeasure<'number'> {
121
+ return aggregate('number', 'avg', expr, options)
122
+ },
123
+
124
+ median(expr: Expr<'number'>, options: Filtered): AggregateMeasure<'number'> {
125
+ return aggregate('number', 'median', expr, options)
126
+ },
127
+
128
+ /**
129
+ * The share of rows matching `numerator` among rows matching `denominator` (all rows when
130
+ * omitted). A `Ratio` from 0 to 1.
131
+ */
132
+ share(
133
+ options: Described & {
134
+ numerator: Expr<'boolean'>
135
+ denominator?: Expr<'boolean'> | undefined
136
+ }
137
+ ): ShareMeasure {
138
+ return {
139
+ kind: 'share',
140
+ result: 'ratio',
141
+ label: options.label,
142
+ description: options.description,
143
+ numerator: options.numerator.node,
144
+ denominator: options.denominator?.node ?? null
145
+ }
146
+ },
147
+
148
+ /**
149
+ * One measure of this dataset over another, computed after aggregation. Both keys must be
150
+ * measures of the same dataset; `defineDataset` checks them.
151
+ */
152
+ ratio<const A extends string, const B extends string>(
153
+ numerator: A,
154
+ denominator: B,
155
+ options: Described
156
+ ): RatioMeasure<A, B> {
157
+ return {
158
+ kind: 'ratio',
159
+ result: 'ratio',
160
+ label: options.label,
161
+ description: options.description,
162
+ numerator,
163
+ denominator
164
+ }
165
+ }
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------------------------
169
+ // Dimensions
170
+
171
+ /**
172
+ * How a dimension filters:
173
+ * - `closed`: a fixed set of keys (enum, computed) - `in`, `notIn`, `isEmpty`, `isNotEmpty`;
174
+ * - `open`: keys from the data (relation, many-to-many) - the same operators, with values given
175
+ * as keys or labels and resolved at query time;
176
+ * - `numeric`: numbers (numeric columns, weekday, hour) - `between`, `isEmpty`, `isNotEmpty`;
177
+ * - `time`: time buckets keyed by ISO date - `between`, `isEmpty`, `isNotEmpty`.
178
+ */
179
+ export type DimensionCategory = 'closed' | 'open' | 'numeric' | 'time'
180
+
181
+ type DimensionOptions<
182
+ G extends boolean = boolean,
183
+ F extends boolean = boolean
184
+ > = Described & {
185
+ /** Only offered for grouping. */
186
+ groupOnly?: G | undefined
187
+ /** Only offered for filtering. */
188
+ filterOnly?: F | undefined
189
+ }
190
+
191
+ /** Time dimensions name themselves after their unit unless given a label. */
192
+ type TimeDimensionOptions<G extends boolean, F extends boolean, Key> = Omit<
193
+ DimensionOptions<G, F>,
194
+ 'label'
195
+ > & {
196
+ label?: string | undefined
197
+ labelFor?: ((key: Key) => string) | undefined
198
+ }
199
+
200
+ /**
201
+ * `groupOnly: true` makes `filterable` the literal `false` (and `filterOnly`, `groupable`), so
202
+ * queries cannot filter or group by what the dimension does not offer.
203
+ */
204
+ export type DimensionAccess<G extends boolean, F extends boolean> = {
205
+ readonly groupable: [F] extends [true] ? false : boolean
206
+ readonly filterable: [G] extends [true] ? false : boolean
207
+ }
208
+
209
+ type DimensionBase<C extends DimensionCategory, Key> = {
210
+ readonly category: C
211
+ readonly label: string
212
+ readonly description: string | undefined
213
+ readonly groupable: boolean
214
+ readonly filterable: boolean
215
+ /** Phantom: the key type in result rows. */
216
+ readonly keyType?: Key
217
+ }
218
+
219
+ /** `Empty` is `null` when the expression can be null, `never` when it cannot. */
220
+ export type EnumDimension<
221
+ K extends string = string,
222
+ Empty extends null = null
223
+ > = DimensionBase<'closed', K | Empty> & {
224
+ readonly kind: 'enum' | 'computed'
225
+ readonly expr: ExprNode
226
+ readonly values: Readonly<Record<K, string>>
227
+ readonly empty: string | undefined
228
+ }
229
+
230
+ export type RelationDimension<K = unknown> = DimensionBase<'open', K> & {
231
+ readonly kind: 'relation'
232
+ readonly key: ExprNode
233
+ readonly name: ExprNode
234
+ readonly empty: string | undefined
235
+ }
236
+
237
+ export type ManyToManyDimension = DimensionBase<'open', string> & {
238
+ readonly kind: 'manyToMany'
239
+ readonly table: TableRef
240
+ readonly on: ExprNode
241
+ readonly key: ExprNode
242
+ readonly name: ExprNode
243
+ readonly empty: string | undefined
244
+ readonly note: string | undefined
245
+ }
246
+
247
+ export type NumberDimension = DimensionBase<'numeric', number> & {
248
+ readonly kind: 'number'
249
+ readonly expr: ExprNode
250
+ readonly labelFor: ((key: number) => string) | undefined
251
+ }
252
+
253
+ export type TimePartDimension<T extends string = string> = DimensionBase<
254
+ 'numeric',
255
+ number
256
+ > & {
257
+ readonly kind: 'timePart'
258
+ readonly time: T
259
+ readonly part: TimePartName
260
+ readonly labelFor: ((key: number) => string) | undefined
261
+ }
262
+
263
+ export type TimeBucketDimension<T extends string = string> = DimensionBase<
264
+ 'time',
265
+ string
266
+ > & {
267
+ readonly kind: 'timeBucket'
268
+ readonly time: T
269
+ readonly unit: TimeBucketUnit
270
+ readonly labelFor: ((key: string) => string) | undefined
271
+ }
272
+
273
+ export type AnyDimension =
274
+ | EnumDimension
275
+ | RelationDimension
276
+ | ManyToManyDimension
277
+ | NumberDimension
278
+ | TimePartDimension
279
+ | TimeBucketDimension
280
+
281
+ /**
282
+ * `{ key, label }` as it appears in a result row for this dimension. Enum and computed keys
283
+ * are null only when their expression can be null; any other key is null when the row has no
284
+ * value (no related row, a null column, a time field the period does not apply to).
285
+ */
286
+ export type DimensionValue<Dim> = Dim extends {
287
+ kind: 'enum' | 'computed'
288
+ keyType?: infer K
289
+ }
290
+ ? { key: K; label: string }
291
+ : Dim extends { keyType?: infer K }
292
+ ? { key: K | null; label: string }
293
+ : never
294
+
295
+ /** `null` when an expression's values `V` include null, otherwise `never`. */
296
+ type EmptyOf<V> = null extends V ? null : never
297
+
298
+ /**
299
+ * Makes `expr` fail typecheck when it can produce a value `Allowed` does not list, naming the
300
+ * missing values. Expressions typed only as `string` pass; `defineDataset` checks those.
301
+ */
302
+ type CoversValues<V, Allowed> =
303
+ string extends NonNullable<V>
304
+ ? unknown
305
+ : [Exclude<NonNullable<V>, Allowed>] extends [never]
306
+ ? unknown
307
+ : { readonly missingFromValues: Exclude<NonNullable<V>, Allowed> }
308
+
309
+ /** What a closed dimension's null key is called. */
310
+ type EmptyOption = {
311
+ /** The label of rows whose value is null. "None" when omitted. */
312
+ empty?: string | undefined
313
+ }
314
+
315
+ function base(options: DimensionOptions) {
316
+ return {
317
+ label: options.label,
318
+ description: options.description,
319
+ groupable: options.filterOnly !== true,
320
+ filterable: options.groupOnly !== true
321
+ }
322
+ }
323
+
324
+ /** Narrows `groupable` and `filterable` (which `base` computed) to what the options say. */
325
+ function withAccess<
326
+ D extends AnyDimension,
327
+ G extends boolean,
328
+ F extends boolean
329
+ >(
330
+ _options:
331
+ | { groupOnly?: G | undefined; filterOnly?: F | undefined }
332
+ | undefined,
333
+ dimension: D
334
+ ): D & DimensionAccess<G, F> {
335
+ return dimension as D & DimensionAccess<G, F>
336
+ }
337
+
338
+ export const dimension = {
339
+ /**
340
+ * A column holding one value of a closed set, such as a Prisma enum. `labels` must name
341
+ * every value, so adding an enum value fails typecheck until it has a label, and a column
342
+ * typed with values outside `values` fails too. A nullable column gives null keys.
343
+ */
344
+ enum<
345
+ const E extends Record<string, string>,
346
+ V extends string | null,
347
+ G extends boolean = boolean,
348
+ F extends boolean = boolean
349
+ >(
350
+ expr: Expr<'string', V> & CoversValues<V, E[keyof E]>,
351
+ values: E,
352
+ options: DimensionOptions<G, F> &
353
+ EmptyOption & { labels: Record<E[keyof E], string> }
354
+ ): EnumDimension<E[keyof E], EmptyOf<V>> & DimensionAccess<G, F> {
355
+ for (const value of Object.values(values)) {
356
+ if (!(value in options.labels)) {
357
+ throw new Error(`${options.label}: the value ${value} has no label`)
358
+ }
359
+ }
360
+
361
+ return withAccess(options, {
362
+ ...base(options),
363
+ category: 'closed',
364
+ kind: 'enum',
365
+ expr: expr.node,
366
+ values: options.labels,
367
+ empty: options.empty
368
+ })
369
+ },
370
+
371
+ /**
372
+ * A value computed from other columns with the expression builders, usually `caseWhen`.
373
+ * `values` lists every value the expression can produce, with its label; a plain value of
374
+ * the expression missing from `values` fails typecheck. A `caseWhen` without `otherwise`
375
+ * gives null keys.
376
+ */
377
+ computed<
378
+ V extends string | null,
379
+ const K extends string,
380
+ G extends boolean = boolean,
381
+ F extends boolean = boolean
382
+ >(
383
+ options: DimensionOptions<G, F> &
384
+ EmptyOption & {
385
+ expr: Expr<'string', V> & CoversValues<V, NoInfer<K>>
386
+ values: Record<K, string>
387
+ }
388
+ ): EnumDimension<K, EmptyOf<V>> & DimensionAccess<G, F> {
389
+ return withAccess(options, {
390
+ ...base(options),
391
+ category: 'closed',
392
+ kind: 'computed',
393
+ expr: options.expr.node,
394
+ values: options.values,
395
+ empty: options.empty
396
+ })
397
+ },
398
+
399
+ /**
400
+ * A to-one relation such as a team. The key type comes from the `key` column, so a branded
401
+ * id column types the key in filters and rows. Rows without a related record get the
402
+ * `empty` label and a null key.
403
+ */
404
+ relation<
405
+ Key extends string | number,
406
+ G extends boolean = boolean,
407
+ F extends boolean = boolean
408
+ >(
409
+ options: DimensionOptions<G, F> & {
410
+ key: Expr<Key extends number ? 'number' : 'string', Key | null>
411
+ name: Expr<'string'>
412
+ empty?: string | undefined
413
+ }
414
+ ): RelationDimension<Key> & DimensionAccess<G, F> {
415
+ return withAccess(options, {
416
+ ...base(options),
417
+ category: 'open',
418
+ kind: 'relation',
419
+ key: options.key.node,
420
+ name: options.name.node,
421
+ empty: options.empty
422
+ })
423
+ },
424
+
425
+ /**
426
+ * A to-many relation through a link table, such as tags. Grouping counts a row once per
427
+ * related value, so totals for such a grouping come from a separate query. Filtering
428
+ * compiles to `EXISTS`, so it never multiplies rows.
429
+ *
430
+ * `through.table` is the link table as a standalone `table()` (further joins hang off it),
431
+ * and `through.on` links it to the dataset's table.
432
+ */
433
+ manyToMany<
434
+ Row = unknown,
435
+ G extends boolean = boolean,
436
+ F extends boolean = boolean
437
+ >(
438
+ options: DimensionOptions<G, F> & {
439
+ through: {
440
+ table: Table<Row>
441
+ on: Expr<'boolean'>
442
+ key: Expr<'string' | 'number'>
443
+ name: Expr<'string'>
444
+ }
445
+ /** Explains the overlap to the reader of a grouped result. */
446
+ note?: string | undefined
447
+ empty?: string | undefined
448
+ }
449
+ ): ManyToManyDimension & DimensionAccess<G, F> {
450
+ return withAccess(options, {
451
+ ...base(options),
452
+ category: 'open',
453
+ kind: 'manyToMany',
454
+ table: options.through.table.ref,
455
+ on: options.through.on.node,
456
+ key: options.through.key.node,
457
+ name: options.through.name.node,
458
+ empty: options.empty,
459
+ note: options.note
460
+ })
461
+ },
462
+
463
+ /** A numeric column, grouped by its exact values and filtered with `between`. */
464
+ number<G extends boolean = boolean, F extends boolean = boolean>(
465
+ expr: Expr<'number'>,
466
+ options: DimensionOptions<G, F> & {
467
+ labelFor?: ((key: number) => string) | undefined
468
+ }
469
+ ): NumberDimension & DimensionAccess<G, F> {
470
+ return withAccess(options, {
471
+ ...base(options),
472
+ category: 'numeric',
473
+ kind: 'number',
474
+ expr: expr.node,
475
+ labelFor: options.labelFor
476
+ })
477
+ },
478
+
479
+ /** Day, ISO week (starting Monday) or month of a time field, in the tenant's timezone. */
480
+ timeBucket<
481
+ const T extends string,
482
+ G extends boolean = boolean,
483
+ F extends boolean = boolean
484
+ >(
485
+ time: T,
486
+ unit: TimeBucketUnit,
487
+ options?: TimeDimensionOptions<G, F, string>
488
+ ): TimeBucketDimension<T> & DimensionAccess<G, F> {
489
+ return withAccess(options, {
490
+ ...base({
491
+ ...options,
492
+ label: options?.label ?? unit[0]?.toUpperCase() + unit.slice(1)
493
+ }),
494
+ category: 'time',
495
+ kind: 'timeBucket',
496
+ time,
497
+ unit,
498
+ labelFor: options?.labelFor
499
+ })
500
+ },
501
+
502
+ /** ISO weekday (1 = Monday) or hour (0-23) of a time field, in the tenant's timezone. */
503
+ timePart<
504
+ const T extends string,
505
+ G extends boolean = boolean,
506
+ F extends boolean = boolean
507
+ >(
508
+ time: T,
509
+ part: TimePartName,
510
+ options?: TimeDimensionOptions<G, F, number>
511
+ ): TimePartDimension<T> & DimensionAccess<G, F> {
512
+ return withAccess(options, {
513
+ ...base({
514
+ ...options,
515
+ label: options?.label ?? (part === 'isodow' ? 'Weekday' : 'Hour')
516
+ }),
517
+ category: 'numeric',
518
+ kind: 'timePart',
519
+ time,
520
+ part,
521
+ labelFor: options?.labelFor
522
+ })
523
+ }
524
+ }
525
+
526
+ // ---------------------------------------------------------------------------------------------
527
+ // Datasets
528
+
529
+ export type TimeField = { readonly column: ExprNode; readonly label: string }
530
+
531
+ export type Dataset<
532
+ K extends string = string,
533
+ M extends Record<string, AnyMeasure> = Record<string, AnyMeasure>,
534
+ Dm extends Record<string, AnyDimension> = Record<string, AnyDimension>,
535
+ T extends string = string
536
+ > = {
537
+ readonly key: K
538
+ readonly label: string
539
+ readonly description: string
540
+ readonly from: TableRef
541
+ readonly source: string | undefined
542
+ readonly tenantColumn: ExprNode
543
+ readonly scope: ExprNode | null
544
+ readonly time: Readonly<Record<T, TimeField>>
545
+ readonly defaultTime: T
546
+ readonly measures: M
547
+ readonly dimensions: Dm
548
+ }
549
+
550
+ // biome-ignore lint/suspicious/noExplicitAny: the widest dataset, used only as a constraint.
551
+ export type AnyDataset = Dataset<string, any, any, string>
552
+
553
+ /** Ratio measures may only name other measures of the same dataset. */
554
+ type CheckMeasures<M> = {
555
+ [Key in keyof M]: M[Key] extends { kind: 'ratio' }
556
+ ? {
557
+ numerator: Exclude<keyof M & string, Key>
558
+ denominator: Exclude<keyof M & string, Key>
559
+ }
560
+ : unknown
561
+ }
562
+
563
+ /** Time dimensions may only name declared time fields. */
564
+ type CheckDimensions<Dm, T extends string> = {
565
+ [Key in keyof Dm]: Dm[Key] extends { kind: 'timeBucket' | 'timePart' }
566
+ ? { time: T }
567
+ : unknown
568
+ }
569
+
570
+ export type DatasetDefinition<
571
+ K extends string,
572
+ M extends Record<string, AnyMeasure>,
573
+ Dm extends Record<string, AnyDimension>,
574
+ T extends string
575
+ > = {
576
+ key: K
577
+ label: string
578
+ /** Tells a reader (and a model) what one row of the dataset is. */
579
+ description: string
580
+ from: { readonly ref: TableRef }
581
+ /** Which source (database) the dataset runs on; the default when there is one source. */
582
+ source?: string | undefined
583
+ /** The column every query is filtered by; the value comes from `tenant(ctx)`. */
584
+ tenantColumn: Expr<SqlType>
585
+ /** Always applied, for example excluding test rows. */
586
+ scope?: Expr<'boolean'> | undefined
587
+ time: Record<T, { column: Expr<'timestamp'>; label: string }>
588
+ /** The time field periods apply to when a query names none; the first one by default. */
589
+ defaultTime?: NoInfer<T> | undefined
590
+ measures: M & CheckMeasures<M>
591
+ dimensions: Dm & CheckDimensions<Dm, NoInfer<T>>
592
+ }
593
+
594
+ /**
595
+ * Declares a dataset: one fact table with its tenant column, time fields, measures and
596
+ * dimensions. Keys stay literal types, so queries and result rows are typed from it.
597
+ */
598
+ export function defineDataset<
599
+ const K extends string,
600
+ const M extends Record<string, AnyMeasure>,
601
+ const Dm extends Record<string, AnyDimension>,
602
+ const T extends string
603
+ >(definition: DatasetDefinition<K, M, Dm, T>): Dataset<K, M, Dm, T> {
604
+ const timeEntries = Object.entries<{
605
+ column: Expr<'timestamp'>
606
+ label: string
607
+ }>(definition.time)
608
+ const firstTime = timeEntries[0]
609
+ if (!firstTime) {
610
+ throw new Error(
611
+ `Dataset ${definition.key}: declare at least one time field`
612
+ )
613
+ }
614
+
615
+ const time = Object.fromEntries(
616
+ timeEntries.map(([key, field]) => [
617
+ key,
618
+ { column: field.column.node, label: field.label }
619
+ ])
620
+ ) as Record<T, TimeField>
621
+
622
+ const dataset: Dataset<K, M, Dm, T> = {
623
+ key: definition.key,
624
+ label: definition.label,
625
+ description: definition.description,
626
+ from: definition.from.ref,
627
+ source: definition.source,
628
+ tenantColumn: definition.tenantColumn.node,
629
+ scope: definition.scope?.node ?? null,
630
+ time,
631
+ defaultTime: definition.defaultTime ?? (firstTime[0] as T),
632
+ measures: definition.measures,
633
+ dimensions: definition.dimensions
634
+ }
635
+
636
+ return validateDataset(dataset)
637
+ }
638
+
639
+ /**
640
+ * Checks what the types cannot: every column is reachable from the dataset's table, ratio
641
+ * bases exist without cycles, and computed values cover the expression's plain values.
642
+ */
643
+ function validateDataset<DS extends AnyDataset>(dataset: DS): DS {
644
+ const fail = (message: string): never => {
645
+ throw new Error(`Dataset ${dataset.key}: ${message}`)
646
+ }
647
+
648
+ const check = (
649
+ node: ExprNode,
650
+ where: string,
651
+ extraRoots: readonly TableRef[] = []
652
+ ) => {
653
+ visitScoped(node, [dataset.from, ...extraRoots], (inner, roots) => {
654
+ if (inner.kind === 'column') {
655
+ const root = rootOf(inner.table)
656
+ if (!roots.includes(root)) {
657
+ fail(
658
+ `${where} reads ${inner.table.name}.${inner.column}, which is not joined to ${dataset.from.name}`
659
+ )
660
+ }
661
+ }
662
+ })
663
+ }
664
+
665
+ check(dataset.tenantColumn, 'tenantColumn')
666
+ if (dataset.scope) check(dataset.scope, 'scope')
667
+ for (const [key, field] of Object.entries<TimeField>(dataset.time)) {
668
+ check(field.column, `time field ${key}`)
669
+ }
670
+
671
+ const measures: Record<string, AnyMeasure> = dataset.measures
672
+ for (const [key, entry] of Object.entries(measures)) {
673
+ if (entry.kind === 'aggregate') check(entry.node, `measure ${key}`)
674
+ if (entry.kind === 'share') {
675
+ check(entry.numerator, `measure ${key}`)
676
+ if (entry.denominator) check(entry.denominator, `measure ${key}`)
677
+ }
678
+ if (entry.kind === 'ratio') checkRatio(measures, key, [], fail)
679
+ }
680
+
681
+ const dimensions: Record<string, AnyDimension> = dataset.dimensions
682
+ for (const [key, entry] of Object.entries(dimensions)) {
683
+ const where = `dimension ${key}`
684
+ switch (entry.kind) {
685
+ case 'enum':
686
+ case 'number':
687
+ check(entry.expr, where)
688
+ break
689
+ case 'computed':
690
+ check(entry.expr, where)
691
+ for (const value of plainCaseValues(entry.expr)) {
692
+ if (!(value in entry.values)) {
693
+ fail(
694
+ `${where} can produce "${value}", which is missing from its values`
695
+ )
696
+ }
697
+ }
698
+ break
699
+ case 'relation':
700
+ check(entry.key, where)
701
+ check(entry.name, where)
702
+ break
703
+ case 'manyToMany':
704
+ if (entry.table.join) {
705
+ fail(
706
+ `${where}: through.table must be a standalone table(), not a join`
707
+ )
708
+ }
709
+ check(entry.on, where, [entry.table])
710
+ check(entry.key, where, [entry.table])
711
+ check(entry.name, where, [entry.table])
712
+ break
713
+ case 'timeBucket':
714
+ case 'timePart':
715
+ if (!(entry.time in dataset.time))
716
+ fail(`${where} names unknown time field ${entry.time}`)
717
+ break
718
+ }
719
+ }
720
+
721
+ return dataset
722
+ }
723
+
724
+ function checkRatio(
725
+ measures: Record<string, AnyMeasure>,
726
+ key: string,
727
+ path: string[],
728
+ fail: (message: string) => never
729
+ ): void {
730
+ if (path.includes(key))
731
+ fail(`ratio measures form a cycle: ${[...path, key].join(' -> ')}`)
732
+
733
+ const entry = measures[key]
734
+ if (!entry) fail(`ratio measure ${path.at(-1)} names unknown measure ${key}`)
735
+ if (entry?.kind !== 'ratio') return
736
+
737
+ checkRatio(measures, entry.numerator, [...path, key], fail)
738
+ checkRatio(measures, entry.denominator, [...path, key], fail)
739
+ }
740
+
741
+ /**
742
+ * Walks an expression while tracking which table roots are in scope: an `exists` subquery
743
+ * brings its own table into scope for its condition, and a joined column's `ON` clause is
744
+ * checked too.
745
+ */
746
+ function visitScoped(
747
+ node: ExprNode,
748
+ roots: readonly TableRef[],
749
+ visit: (node: ExprNode, roots: readonly TableRef[]) => void,
750
+ seenJoins = new Set<TableRef>()
751
+ ): void {
752
+ visit(node, roots)
753
+
754
+ if (node.kind === 'exists') {
755
+ visitScoped(node.where, [...roots, node.table], visit, seenJoins)
756
+ return
757
+ }
758
+ if (node.kind === 'column' && node.table.join && !seenJoins.has(node.table)) {
759
+ // An ON clause reads its own table, so visit each one once.
760
+ seenJoins.add(node.table)
761
+ visitScoped(node.table.join.on, roots, visit, seenJoins)
762
+ return
763
+ }
764
+
765
+ for (const child of directChildren(node)) {
766
+ visitScoped(child, roots, visit, seenJoins)
767
+ }
768
+ }
769
+
770
+ /** The plain string values a computed expression can produce. */
771
+ function plainCaseValues(node: ExprNode): string[] {
772
+ if (node.kind === 'param' && typeof node.value === 'string')
773
+ return [node.value]
774
+ if (node.kind === 'case') {
775
+ return [
776
+ ...node.branches.flatMap((branch) => plainCaseValues(branch.result)),
777
+ ...(node.otherwise ? plainCaseValues(node.otherwise) : [])
778
+ ]
779
+ }
780
+ if (node.kind === 'coalesce') return node.items.flatMap(plainCaseValues)
781
+
782
+ return []
783
+ }