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