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/analytics.ts
ADDED
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { CompiledStatement, Dialect, SelectStatement } from './compile.js'
|
|
3
|
+
import type { AnyDataset, AnyDimension } from './dataset.js'
|
|
4
|
+
import {
|
|
5
|
+
type NormalizedQuery,
|
|
6
|
+
type ParsedQuery,
|
|
7
|
+
type PlanContext,
|
|
8
|
+
normalizeQuery,
|
|
9
|
+
planLookup,
|
|
10
|
+
planQuery,
|
|
11
|
+
resultNotes,
|
|
12
|
+
shapeMeasures,
|
|
13
|
+
shapeRows
|
|
14
|
+
} from './plan.js'
|
|
15
|
+
import {
|
|
16
|
+
PeriodError,
|
|
17
|
+
type ResolvedPeriod,
|
|
18
|
+
isValidTimezone,
|
|
19
|
+
previousPeriod,
|
|
20
|
+
resolvePeriod
|
|
21
|
+
} from './period.js'
|
|
22
|
+
import {
|
|
23
|
+
type DatasetQuery,
|
|
24
|
+
type QueryResult,
|
|
25
|
+
type ResultOf,
|
|
26
|
+
buildQuerySchema,
|
|
27
|
+
datasetResultSchema
|
|
28
|
+
} from './query.js'
|
|
29
|
+
|
|
30
|
+
export type Row = Record<string, unknown>
|
|
31
|
+
|
|
32
|
+
/** A database: a dialect to compile with and a function that runs the compiled SQL. */
|
|
33
|
+
export type AnalyticsSource = {
|
|
34
|
+
readonly dialect: Dialect
|
|
35
|
+
/** Runs one read-only statement and returns its rows as plain objects. */
|
|
36
|
+
execute(statement: CompiledStatement): Promise<readonly Row[]>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type AnalyticsErrorCode =
|
|
40
|
+
| 'invalid_query'
|
|
41
|
+
| 'forbidden'
|
|
42
|
+
| 'missing_tenant'
|
|
43
|
+
| 'invalid_timezone'
|
|
44
|
+
| 'invalid_period'
|
|
45
|
+
| 'unknown_value'
|
|
46
|
+
|
|
47
|
+
/** What `details` holds for each error code. */
|
|
48
|
+
export type AnalyticsErrorDetails = {
|
|
49
|
+
/** The schema's issues, for a model to fix its query. */
|
|
50
|
+
invalid_query: readonly z.core.$ZodIssue[]
|
|
51
|
+
forbidden: undefined
|
|
52
|
+
missing_tenant: undefined
|
|
53
|
+
invalid_timezone: undefined
|
|
54
|
+
invalid_period: undefined
|
|
55
|
+
/** The values that matched nothing, and the values that exist. */
|
|
56
|
+
unknown_value: {
|
|
57
|
+
dimension: string
|
|
58
|
+
unknown: string[]
|
|
59
|
+
valid: { key: string; label: string }[]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class AnalyticsError<
|
|
64
|
+
C extends AnalyticsErrorCode = AnalyticsErrorCode
|
|
65
|
+
> extends Error {
|
|
66
|
+
override readonly name = 'AnalyticsError'
|
|
67
|
+
readonly details: AnalyticsErrorDetails[C]
|
|
68
|
+
|
|
69
|
+
constructor(
|
|
70
|
+
readonly code: C,
|
|
71
|
+
message: string,
|
|
72
|
+
...[details]: AnalyticsErrorDetails[C] extends undefined
|
|
73
|
+
? []
|
|
74
|
+
: [details: AnalyticsErrorDetails[C]]
|
|
75
|
+
) {
|
|
76
|
+
super(message)
|
|
77
|
+
this.details = details as AnalyticsErrorDetails[C]
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One `AnalyticsError` type per code, so checking `code` narrows `details`. */
|
|
82
|
+
export type AnalyticsErrorOf<
|
|
83
|
+
C extends AnalyticsErrorCode = AnalyticsErrorCode
|
|
84
|
+
> = C extends AnalyticsErrorCode ? AnalyticsError<C> : never
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether `error` is an `AnalyticsError` (with `code`, when given). The result narrows by
|
|
88
|
+
* code: `if (isAnalyticsError(error, 'unknown_value')) error.details.valid`.
|
|
89
|
+
*/
|
|
90
|
+
export function isAnalyticsError<
|
|
91
|
+
C extends AnalyticsErrorCode = AnalyticsErrorCode
|
|
92
|
+
>(error: unknown, code?: C): error is AnalyticsErrorOf<C> {
|
|
93
|
+
return (
|
|
94
|
+
error instanceof AnalyticsError &&
|
|
95
|
+
(code === undefined || error.code === code)
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** What `onQuery` hears about each `analytics.query` call. */
|
|
100
|
+
export type AnalyticsQueryEvent = {
|
|
101
|
+
/** The dataset asked for; null when the input named no known dataset. */
|
|
102
|
+
dataset: string | null
|
|
103
|
+
durationMs: number
|
|
104
|
+
/** Rows returned; 0 on error. */
|
|
105
|
+
rows: number
|
|
106
|
+
outcome: 'success' | 'error'
|
|
107
|
+
/** What the query threw, on error. */
|
|
108
|
+
error?: unknown
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
|
|
112
|
+
datasets: DS
|
|
113
|
+
/** Databases by name; a dataset's `source` picks one, or the only one is used. */
|
|
114
|
+
sources: Record<string, AnalyticsSource>
|
|
115
|
+
/** The tenant every query is scoped to. Queries without one are refused. */
|
|
116
|
+
tenant: (ctx: Ctx) => string | number | null | undefined
|
|
117
|
+
/**
|
|
118
|
+
* Whether `ctx` may query `dataset`. Everything is allowed when omitted. It decides what
|
|
119
|
+
* `listDatasets(ctx)` and `querySchema({ ctx })` offer, and is checked again on every query.
|
|
120
|
+
*/
|
|
121
|
+
authorize?:
|
|
122
|
+
| ((dataset: DS[number], ctx: Ctx) => boolean | Promise<boolean>)
|
|
123
|
+
| undefined
|
|
124
|
+
/** IANA timezone periods and time buckets resolve in. UTC when omitted. */
|
|
125
|
+
timezone?:
|
|
126
|
+
| ((
|
|
127
|
+
ctx: Ctx
|
|
128
|
+
) => string | null | undefined | Promise<string | null | undefined>)
|
|
129
|
+
| undefined
|
|
130
|
+
/** The clock periods and `minutesAgo` resolve against. */
|
|
131
|
+
now?: (() => Date) | undefined
|
|
132
|
+
/**
|
|
133
|
+
* Called once per `query`, after it succeeds or fails: for metrics and logs. What it
|
|
134
|
+
* throws or rejects with is swallowed, so it can never fail a query.
|
|
135
|
+
*/
|
|
136
|
+
onQuery?: ((event: AnalyticsQueryEvent, ctx: Ctx) => unknown) | undefined
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type DatasetCatalogEntry<Key extends string = string> = {
|
|
140
|
+
key: Key
|
|
141
|
+
label: string
|
|
142
|
+
description: string
|
|
143
|
+
time: { key: string; label: string }[]
|
|
144
|
+
measures: { key: string; label: string; description: string | undefined }[]
|
|
145
|
+
dimensions: {
|
|
146
|
+
key: string
|
|
147
|
+
label: string
|
|
148
|
+
description: string | undefined
|
|
149
|
+
kind: AnyDimension['kind']
|
|
150
|
+
category: AnyDimension['category']
|
|
151
|
+
groupable: boolean
|
|
152
|
+
filterable: boolean
|
|
153
|
+
values: { key: string; label: string }[] | undefined
|
|
154
|
+
}[]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export type Analytics<DS extends readonly AnyDataset[], Ctx> = {
|
|
158
|
+
readonly datasets: DS
|
|
159
|
+
/** Parses, authorizes, plans, compiles, runs and shapes one query. */
|
|
160
|
+
query<const Q extends DatasetQuery<DS[number]>>(
|
|
161
|
+
input: Q,
|
|
162
|
+
ctx: Ctx
|
|
163
|
+
): Promise<ResultOf<DS[number], Q>>
|
|
164
|
+
/** The compiled statements a query would run, without running them or resolving labels. */
|
|
165
|
+
explain(
|
|
166
|
+
input: DatasetQuery<DS[number]>,
|
|
167
|
+
ctx: Ctx
|
|
168
|
+
): Promise<CompiledStatement[]>
|
|
169
|
+
/**
|
|
170
|
+
* The input schema: a discriminated union on `dataset`, for the datasets listed (all when
|
|
171
|
+
* omitted), typed for those datasets only. One schema is built per set of datasets and
|
|
172
|
+
* reused.
|
|
173
|
+
*/
|
|
174
|
+
querySchema<const K extends DS[number]['key'] = DS[number]['key']>(options?: {
|
|
175
|
+
datasets?: readonly K[] | undefined
|
|
176
|
+
}): z.ZodType<DatasetQuery<Extract<DS[number], { key: K }>>>
|
|
177
|
+
/**
|
|
178
|
+
* The input schema for the datasets `authorize` allows `ctx`, for a tool built per caller.
|
|
179
|
+
* Rejects with `forbidden` when none are allowed.
|
|
180
|
+
*/
|
|
181
|
+
querySchema(options: {
|
|
182
|
+
ctx: Ctx
|
|
183
|
+
}): Promise<z.ZodType<DatasetQuery<DS[number]>>>
|
|
184
|
+
/** The result schema for one dataset. */
|
|
185
|
+
resultSchema(dataset: DS[number]['key']): z.ZodType<QueryResult<Row, Row>>
|
|
186
|
+
/** The datasets `authorize` allows `ctx`, with their measures and dimensions. */
|
|
187
|
+
listDatasets(ctx: Ctx): Promise<DatasetCatalogEntry<DS[number]['key']>[]>
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export type AnalyticsQuery<A> =
|
|
191
|
+
A extends Analytics<infer DS, never> ? DatasetQuery<DS[number]> : never
|
|
192
|
+
|
|
193
|
+
export type AnalyticsResult<A, Q> =
|
|
194
|
+
A extends Analytics<infer DS, never> ? ResultOf<DS[number], Q> : never
|
|
195
|
+
|
|
196
|
+
type ShapedResult = QueryResult<Row, Row>
|
|
197
|
+
|
|
198
|
+
export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
199
|
+
options: AnalyticsOptions<DS, Ctx>
|
|
200
|
+
): Analytics<DS, Ctx> {
|
|
201
|
+
const datasets: readonly AnyDataset[] = options.datasets
|
|
202
|
+
const sourceNames = Object.keys(options.sources)
|
|
203
|
+
const byKey = new Map<string, AnyDataset>()
|
|
204
|
+
const sourceOf = new Map<string, AnalyticsSource>()
|
|
205
|
+
|
|
206
|
+
for (const dataset of datasets) {
|
|
207
|
+
if (byKey.has(dataset.key))
|
|
208
|
+
throw new Error(`Two datasets use the key ${dataset.key}`)
|
|
209
|
+
byKey.set(dataset.key, dataset)
|
|
210
|
+
|
|
211
|
+
const source = resolveSource(dataset, options.sources, sourceNames)
|
|
212
|
+
sourceOf.set(dataset.key, source)
|
|
213
|
+
compileEverything(dataset, source.dialect)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const now = options.now ?? (() => new Date())
|
|
217
|
+
const querySchemas = new Map<string, z.ZodType>()
|
|
218
|
+
const resultSchemas = new Map<string, z.ZodType>()
|
|
219
|
+
|
|
220
|
+
const datasetOf = (key: string): AnyDataset => {
|
|
221
|
+
const dataset = byKey.get(key)
|
|
222
|
+
if (!dataset)
|
|
223
|
+
throw new AnalyticsError('invalid_query', `Unknown dataset ${key}`, [])
|
|
224
|
+
return dataset
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const sourceFor = (dataset: AnyDataset) => {
|
|
228
|
+
const source = sourceOf.get(dataset.key)
|
|
229
|
+
if (!source) throw new Error(`No source for ${dataset.key}`)
|
|
230
|
+
return source
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const querySchemaFor = (keys: readonly string[] | undefined) => {
|
|
234
|
+
const selected = keys ? keys.map(datasetOf) : datasets
|
|
235
|
+
const selectionKey = selected
|
|
236
|
+
.map((dataset) => dataset.key)
|
|
237
|
+
.sort()
|
|
238
|
+
.join(',')
|
|
239
|
+
|
|
240
|
+
let schema = querySchemas.get(selectionKey)
|
|
241
|
+
if (!schema) {
|
|
242
|
+
schema = buildQuerySchema(selected)
|
|
243
|
+
querySchemas.set(selectionKey, schema)
|
|
244
|
+
}
|
|
245
|
+
return schema
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const resultSchemaFor = (key: string) => {
|
|
249
|
+
let schema = resultSchemas.get(key)
|
|
250
|
+
if (!schema) {
|
|
251
|
+
schema = datasetResultSchema(datasetOf(key))
|
|
252
|
+
resultSchemas.set(key, schema)
|
|
253
|
+
}
|
|
254
|
+
return schema
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const allowedDatasets = async (ctx: Ctx) => {
|
|
258
|
+
const { authorize } = options
|
|
259
|
+
if (!authorize) return datasets
|
|
260
|
+
|
|
261
|
+
const allowed = await Promise.all(
|
|
262
|
+
datasets.map((dataset) => authorize(dataset, ctx))
|
|
263
|
+
)
|
|
264
|
+
return datasets.filter((_, index) => allowed[index])
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const querySchemaForCtx = async (ctx: Ctx) => {
|
|
268
|
+
const allowed = await allowedDatasets(ctx)
|
|
269
|
+
if (allowed.length === 0) {
|
|
270
|
+
throw new AnalyticsError('forbidden', 'No dataset is allowed')
|
|
271
|
+
}
|
|
272
|
+
return querySchemaFor(allowed.map((dataset) => dataset.key))
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Parse, authorize, and resolve everything that comes from hooks. */
|
|
276
|
+
const prepare = async (input: unknown, ctx: Ctx) => {
|
|
277
|
+
const parsed = querySchemaFor(undefined).safeParse(input)
|
|
278
|
+
if (!parsed.success) {
|
|
279
|
+
throw new AnalyticsError(
|
|
280
|
+
'invalid_query',
|
|
281
|
+
`Invalid analytics query: ${z.prettifyError(parsed.error)}`,
|
|
282
|
+
parsed.error.issues
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const dataset = datasetOf((parsed.data as ParsedQuery).dataset)
|
|
287
|
+
if (options.authorize && !(await options.authorize(dataset, ctx))) {
|
|
288
|
+
throw new AnalyticsError(
|
|
289
|
+
'forbidden',
|
|
290
|
+
`Not allowed to query ${dataset.key}`
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const tenant = options.tenant(ctx)
|
|
295
|
+
if (tenant === null || tenant === undefined || tenant === '') {
|
|
296
|
+
throw new AnalyticsError(
|
|
297
|
+
'missing_tenant',
|
|
298
|
+
'Refusing to query without a tenant'
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const timezone = (await options.timezone?.(ctx)) || 'UTC'
|
|
303
|
+
if (!isValidTimezone(timezone)) {
|
|
304
|
+
throw new AnalyticsError(
|
|
305
|
+
'invalid_timezone',
|
|
306
|
+
`Unknown timezone ${timezone}`
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const query = normalizeQuery(dataset, parsed.data as ParsedQuery)
|
|
311
|
+
const at = now()
|
|
312
|
+
let period: ResolvedPeriod
|
|
313
|
+
try {
|
|
314
|
+
period = resolvePeriod(query.period, timezone, at)
|
|
315
|
+
} catch (error) {
|
|
316
|
+
if (error instanceof PeriodError) {
|
|
317
|
+
throw new AnalyticsError('invalid_period', error.message)
|
|
318
|
+
}
|
|
319
|
+
throw error
|
|
320
|
+
}
|
|
321
|
+
const previous = query.compareToPrevious ? previousPeriod(period) : null
|
|
322
|
+
|
|
323
|
+
return { dataset, query, tenant, timezone, period, previous, now: at }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const runQuery = async (input: unknown, ctx: Ctx): Promise<ShapedResult> => {
|
|
327
|
+
const { dataset, query, tenant, timezone, period, previous, now } =
|
|
328
|
+
await prepare(input, ctx)
|
|
329
|
+
const source = sourceFor(dataset)
|
|
330
|
+
const { dialect } = source
|
|
331
|
+
const run = (statement: SelectStatement) =>
|
|
332
|
+
source.execute(dialect.compile(statement))
|
|
333
|
+
|
|
334
|
+
const resolvedValues = await resolveOpenValues(
|
|
335
|
+
dataset,
|
|
336
|
+
query,
|
|
337
|
+
tenant,
|
|
338
|
+
now,
|
|
339
|
+
run,
|
|
340
|
+
dialect
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
const runPeriod = async (target: ResolvedPeriod) => {
|
|
344
|
+
const statements = periodStatements(dataset, query, {
|
|
345
|
+
tenant,
|
|
346
|
+
timezone,
|
|
347
|
+
period: target,
|
|
348
|
+
now,
|
|
349
|
+
resolvedValues
|
|
350
|
+
})
|
|
351
|
+
const [rawRows, rawTotals] = await Promise.all([
|
|
352
|
+
run(statements.rows),
|
|
353
|
+
statements.totals ? run(statements.totals) : null
|
|
354
|
+
])
|
|
355
|
+
|
|
356
|
+
const { rows, truncated } = shapeRows(dataset, query, rawRows, dialect)
|
|
357
|
+
const totalsRow = (rawTotals ?? rawRows)[0] ?? {}
|
|
358
|
+
const totals = shapeMeasures(dataset, query.measures, totalsRow, dialect)
|
|
359
|
+
const periodOut = { from: target.from, to: target.to, timezone }
|
|
360
|
+
|
|
361
|
+
return { period: periodOut, rows, totals, truncated }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const [current, before] = await Promise.all([
|
|
365
|
+
runPeriod(period),
|
|
366
|
+
previous ? runPeriod(previous) : null
|
|
367
|
+
])
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
period: current.period,
|
|
371
|
+
rows: current.rows,
|
|
372
|
+
totals: current.totals,
|
|
373
|
+
...(before && {
|
|
374
|
+
previous: {
|
|
375
|
+
period: before.period,
|
|
376
|
+
rows: before.rows,
|
|
377
|
+
totals: before.totals
|
|
378
|
+
}
|
|
379
|
+
}),
|
|
380
|
+
notes: resultNotes(dataset, query),
|
|
381
|
+
truncated: current.truncated
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const datasetKeyOf = (input: unknown) => {
|
|
386
|
+
if (typeof input !== 'object' || input === null) return null
|
|
387
|
+
const key = 'dataset' in input ? input.dataset : undefined
|
|
388
|
+
return typeof key === 'string' && byKey.has(key) ? key : null
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const report = (event: AnalyticsQueryEvent, ctx: Ctx) => {
|
|
392
|
+
try {
|
|
393
|
+
// Promise.resolve covers async hooks, which reject instead of throwing.
|
|
394
|
+
Promise.resolve(options.onQuery?.(event, ctx)).catch(() => {})
|
|
395
|
+
} catch {
|
|
396
|
+
// Metrics and logs must never fail the query they describe.
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const query = async (input: unknown, ctx: Ctx): Promise<ShapedResult> => {
|
|
401
|
+
const startedAt = performance.now()
|
|
402
|
+
const dataset = datasetKeyOf(input)
|
|
403
|
+
const durationMs = () => Math.round(performance.now() - startedAt)
|
|
404
|
+
|
|
405
|
+
try {
|
|
406
|
+
const result = await runQuery(input, ctx)
|
|
407
|
+
report(
|
|
408
|
+
{
|
|
409
|
+
dataset,
|
|
410
|
+
durationMs: durationMs(),
|
|
411
|
+
rows: result.rows.length,
|
|
412
|
+
outcome: 'success'
|
|
413
|
+
},
|
|
414
|
+
ctx
|
|
415
|
+
)
|
|
416
|
+
return result
|
|
417
|
+
} catch (error) {
|
|
418
|
+
report(
|
|
419
|
+
{ dataset, durationMs: durationMs(), rows: 0, outcome: 'error', error },
|
|
420
|
+
ctx
|
|
421
|
+
)
|
|
422
|
+
throw error
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const explain = async (
|
|
427
|
+
input: unknown,
|
|
428
|
+
ctx: Ctx
|
|
429
|
+
): Promise<CompiledStatement[]> => {
|
|
430
|
+
const { dataset, query, tenant, timezone, period, previous, now } =
|
|
431
|
+
await prepare(input, ctx)
|
|
432
|
+
const { dialect } = sourceFor(dataset)
|
|
433
|
+
const compiled: CompiledStatement[] = []
|
|
434
|
+
|
|
435
|
+
for (const target of previous ? [period, previous] : [period]) {
|
|
436
|
+
const statements = periodStatements(dataset, query, {
|
|
437
|
+
tenant,
|
|
438
|
+
timezone,
|
|
439
|
+
period: target,
|
|
440
|
+
now,
|
|
441
|
+
resolvedValues: new Map()
|
|
442
|
+
})
|
|
443
|
+
compiled.push(dialect.compile(statements.rows))
|
|
444
|
+
if (statements.totals) compiled.push(dialect.compile(statements.totals))
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return compiled
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
datasets: options.datasets,
|
|
452
|
+
query: query as Analytics<DS, Ctx>['query'],
|
|
453
|
+
explain,
|
|
454
|
+
querySchema: ((
|
|
455
|
+
schemaOptions?: { datasets?: readonly string[] } | { ctx: Ctx }
|
|
456
|
+
) =>
|
|
457
|
+
schemaOptions && 'ctx' in schemaOptions
|
|
458
|
+
? querySchemaForCtx(schemaOptions.ctx)
|
|
459
|
+
: querySchemaFor(schemaOptions?.datasets)) as Analytics<
|
|
460
|
+
DS,
|
|
461
|
+
Ctx
|
|
462
|
+
>['querySchema'],
|
|
463
|
+
resultSchema: (key) => resultSchemaFor(key) as z.ZodType<ShapedResult>,
|
|
464
|
+
listDatasets: async (ctx) =>
|
|
465
|
+
(await allowedDatasets(ctx)).map(
|
|
466
|
+
(dataset) =>
|
|
467
|
+
catalogEntry(dataset) as DatasetCatalogEntry<DS[number]['key']>
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** The statement for a period's rows, and for its totals when the query groups. */
|
|
473
|
+
function periodStatements(
|
|
474
|
+
dataset: AnyDataset,
|
|
475
|
+
query: NormalizedQuery,
|
|
476
|
+
context: PlanContext
|
|
477
|
+
) {
|
|
478
|
+
const grouped = query.groupBy.length > 0
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
rows: planQuery(dataset, query, context, true),
|
|
482
|
+
totals: grouped ? planQuery(dataset, query, context, false) : null
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function resolveSource(
|
|
487
|
+
dataset: AnyDataset,
|
|
488
|
+
sources: Record<string, AnalyticsSource>,
|
|
489
|
+
sourceNames: string[]
|
|
490
|
+
): AnalyticsSource {
|
|
491
|
+
const name =
|
|
492
|
+
dataset.source ?? (sourceNames.length === 1 ? sourceNames[0] : undefined)
|
|
493
|
+
if (!name) {
|
|
494
|
+
throw new Error(
|
|
495
|
+
`Dataset ${dataset.key} names no source, and there are several: ${sourceNames.join(', ')}`
|
|
496
|
+
)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const source = sources[name]
|
|
500
|
+
if (!source)
|
|
501
|
+
throw new Error(`Dataset ${dataset.key} uses unknown source ${name}`)
|
|
502
|
+
|
|
503
|
+
return source
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Compiles every measure, grouping and filter once, so a definition the dialect cannot render
|
|
508
|
+
* fails when the app starts instead of on the first query.
|
|
509
|
+
*/
|
|
510
|
+
function compileEverything(dataset: AnyDataset, dialect: Dialect): void {
|
|
511
|
+
const now = new Date(0)
|
|
512
|
+
const context: PlanContext = {
|
|
513
|
+
tenant: 'tenant',
|
|
514
|
+
timezone: 'UTC',
|
|
515
|
+
period: resolvePeriod({ last: { days: 1 } }, 'UTC', now),
|
|
516
|
+
now,
|
|
517
|
+
resolvedValues: new Map()
|
|
518
|
+
}
|
|
519
|
+
const measures = Object.keys(dataset.measures)
|
|
520
|
+
const base = normalizeQuery(dataset, { dataset: dataset.key, measures })
|
|
521
|
+
|
|
522
|
+
try {
|
|
523
|
+
dialect.compile(planQuery(dataset, base, context, true))
|
|
524
|
+
|
|
525
|
+
for (const [key, dimension] of Object.entries<AnyDimension>(
|
|
526
|
+
dataset.dimensions
|
|
527
|
+
)) {
|
|
528
|
+
if (dimension.groupable) {
|
|
529
|
+
dialect.compile(
|
|
530
|
+
planQuery(dataset, { ...base, groupBy: [key] }, context, true)
|
|
531
|
+
)
|
|
532
|
+
}
|
|
533
|
+
if (dimension.filterable) {
|
|
534
|
+
const filter =
|
|
535
|
+
dimension.category === 'numeric'
|
|
536
|
+
? { dimension: key, op: 'between' as const, from: 0, to: 1 }
|
|
537
|
+
: dimension.category === 'time'
|
|
538
|
+
? {
|
|
539
|
+
dimension: key,
|
|
540
|
+
op: 'between' as const,
|
|
541
|
+
from: '2000-01-01',
|
|
542
|
+
to: '2000-01-02'
|
|
543
|
+
}
|
|
544
|
+
: { dimension: key, op: 'in' as const, values: ['value'] }
|
|
545
|
+
dialect.compile(
|
|
546
|
+
planQuery(dataset, { ...base, filters: [filter] }, context, true)
|
|
547
|
+
)
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
} catch (error) {
|
|
551
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
552
|
+
throw new Error(
|
|
553
|
+
`Dataset ${dataset.key} does not compile for ${dialect.name}: ${message}`
|
|
554
|
+
)
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Turns the keys or labels given for open dimensions (tags, teams) into keys. An unknown value
|
|
560
|
+
* fails with the valid values listed, so a model can fix its query in one retry.
|
|
561
|
+
*/
|
|
562
|
+
async function resolveOpenValues(
|
|
563
|
+
dataset: AnyDataset,
|
|
564
|
+
query: NormalizedQuery,
|
|
565
|
+
tenant: string | number,
|
|
566
|
+
now: Date,
|
|
567
|
+
run: (statement: SelectStatement) => Promise<readonly Row[]>,
|
|
568
|
+
dialect: Dialect
|
|
569
|
+
): Promise<Map<string, (string | number)[]>> {
|
|
570
|
+
const resolved = new Map<string, (string | number)[]>()
|
|
571
|
+
|
|
572
|
+
for (const filter of query.filters) {
|
|
573
|
+
if (filter.op !== 'in' && filter.op !== 'notIn') continue
|
|
574
|
+
const dimension: AnyDimension | undefined =
|
|
575
|
+
dataset.dimensions[filter.dimension]
|
|
576
|
+
if (dimension?.category !== 'open') continue
|
|
577
|
+
|
|
578
|
+
const wanted = filter.values.map(String)
|
|
579
|
+
const found = await run(
|
|
580
|
+
planLookup(dataset, filter.dimension, tenant, wanted, now)
|
|
581
|
+
)
|
|
582
|
+
const keys = new Set<string | number>()
|
|
583
|
+
const unknown: string[] = []
|
|
584
|
+
|
|
585
|
+
for (const value of wanted) {
|
|
586
|
+
const matches = found.filter(
|
|
587
|
+
(row) =>
|
|
588
|
+
String(row.key) === value ||
|
|
589
|
+
String(row.label ?? '').toLowerCase() === value.toLowerCase()
|
|
590
|
+
)
|
|
591
|
+
if (matches.length === 0) unknown.push(value)
|
|
592
|
+
for (const match of matches) keys.add(decodeKey(match.key, dialect))
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (unknown.length > 0) {
|
|
596
|
+
const valid = (
|
|
597
|
+
await run(planLookup(dataset, filter.dimension, tenant, null, now))
|
|
598
|
+
).map((row) => ({
|
|
599
|
+
key: String(decodeKey(row.key, dialect)),
|
|
600
|
+
label: String(row.label ?? row.key)
|
|
601
|
+
}))
|
|
602
|
+
const list = valid
|
|
603
|
+
.map((entry) => `${entry.label} (${entry.key})`)
|
|
604
|
+
.join(', ')
|
|
605
|
+
const quoted = unknown.map((value) => `"${value}"`).join(', ')
|
|
606
|
+
throw new AnalyticsError(
|
|
607
|
+
'unknown_value',
|
|
608
|
+
`Unknown ${dimension.label.toLowerCase()}: ${quoted}. Valid values: ${list || 'none'}.`,
|
|
609
|
+
{ dimension: filter.dimension, unknown, valid }
|
|
610
|
+
)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
resolved.set(filter.dimension, [...keys])
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return resolved
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function decodeKey(value: unknown, dialect: Dialect): string | number {
|
|
620
|
+
if (typeof value === 'string') return value
|
|
621
|
+
|
|
622
|
+
return dialect.decodeNumber(value) ?? String(value)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function catalogEntry(dataset: AnyDataset): DatasetCatalogEntry {
|
|
626
|
+
return {
|
|
627
|
+
key: dataset.key,
|
|
628
|
+
label: dataset.label,
|
|
629
|
+
description: dataset.description,
|
|
630
|
+
time: Object.entries<{ label: string }>(dataset.time).map(
|
|
631
|
+
([key, field]) => ({
|
|
632
|
+
key,
|
|
633
|
+
label: field.label
|
|
634
|
+
})
|
|
635
|
+
),
|
|
636
|
+
measures: Object.entries<{
|
|
637
|
+
label: string
|
|
638
|
+
description: string | undefined
|
|
639
|
+
}>(dataset.measures).map(([key, entry]) => ({
|
|
640
|
+
key,
|
|
641
|
+
label: entry.label,
|
|
642
|
+
description: entry.description
|
|
643
|
+
})),
|
|
644
|
+
dimensions: Object.entries<AnyDimension>(dataset.dimensions).map(
|
|
645
|
+
([key, entry]) => ({
|
|
646
|
+
key,
|
|
647
|
+
label: entry.label,
|
|
648
|
+
description: entry.description,
|
|
649
|
+
kind: entry.kind,
|
|
650
|
+
category: entry.category,
|
|
651
|
+
groupable: entry.groupable,
|
|
652
|
+
filterable: entry.filterable,
|
|
653
|
+
values:
|
|
654
|
+
entry.kind === 'enum' || entry.kind === 'computed'
|
|
655
|
+
? Object.entries(entry.values).map(([value, label]) => ({
|
|
656
|
+
key: value,
|
|
657
|
+
label
|
|
658
|
+
}))
|
|
659
|
+
: undefined
|
|
660
|
+
})
|
|
661
|
+
)
|
|
662
|
+
}
|
|
663
|
+
}
|