measurr 0.6.0 → 0.7.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/README.md +84 -7
- package/dist/analytics.d.ts +56 -3
- package/dist/dataset.d.ts +47 -4
- package/dist/dialects/clickhouse.d.ts +26 -0
- package/dist/dialects/clickhouse.js +101 -0
- package/dist/handler.js +2 -2
- package/dist/{index-z5xv3ffk.js → index-g0ah4ehn.js} +112 -10
- package/dist/{index-xf6qpmgy.js → index-psw9s799.js} +1 -1
- package/dist/{index-pgccd363.js → index-rgqsrd72.js} +23 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/metrics.js +2 -2
- package/dist/plan.d.ts +7 -1
- package/dist/query.d.ts +2 -2
- package/dist/testing.js +5 -4
- package/package.json +9 -2
- package/src/analytics.ts +173 -9
- package/src/dataset.ts +82 -5
- package/src/dialects/clickhouse.ts +147 -0
- package/src/index.ts +14 -3
- package/src/plan.ts +48 -7
- package/src/query.ts +17 -3
- package/src/testing.ts +7 -2
|
@@ -62,8 +62,19 @@ var measure2 = {
|
|
|
62
62
|
numerator,
|
|
63
63
|
denominator
|
|
64
64
|
};
|
|
65
|
-
}
|
|
65
|
+
},
|
|
66
|
+
resolved
|
|
66
67
|
};
|
|
68
|
+
function resolved(options) {
|
|
69
|
+
return {
|
|
70
|
+
kind: "resolved",
|
|
71
|
+
result: options.result,
|
|
72
|
+
label: options.label,
|
|
73
|
+
description: options.description,
|
|
74
|
+
access: options.access,
|
|
75
|
+
uses: options.uses ?? []
|
|
76
|
+
};
|
|
77
|
+
}
|
|
67
78
|
function base(options) {
|
|
68
79
|
return {
|
|
69
80
|
label: options.label,
|
|
@@ -222,6 +233,15 @@ function validateDataset(dataset) {
|
|
|
222
233
|
}
|
|
223
234
|
if (entry.kind === "ratio")
|
|
224
235
|
checkRatio(measures, key, [], fail);
|
|
236
|
+
if (entry.kind === "resolved") {
|
|
237
|
+
for (const used of entry.uses) {
|
|
238
|
+
const usedEntry = measures[used];
|
|
239
|
+
if (!usedEntry)
|
|
240
|
+
fail(`measure ${key} uses unknown measure ${used}`);
|
|
241
|
+
if (usedEntry?.kind === "resolved")
|
|
242
|
+
fail(`measure ${key} uses ${used}, which is resolved too`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
225
245
|
}
|
|
226
246
|
const dimensions = dataset.dimensions;
|
|
227
247
|
for (const [key, entry] of Object.entries(dimensions)) {
|
|
@@ -266,6 +286,8 @@ function checkRatio(measures, key, path, fail) {
|
|
|
266
286
|
const entry = measures[key];
|
|
267
287
|
if (!entry)
|
|
268
288
|
fail(`ratio measure ${path.at(-1)} names unknown measure ${key}`);
|
|
289
|
+
if (entry?.kind === "resolved")
|
|
290
|
+
fail(`ratio measure ${path.at(-1)} names ${key}, which is resolved`);
|
|
269
291
|
if (entry?.kind !== "ratio")
|
|
270
292
|
return;
|
|
271
293
|
checkRatio(measures, entry.numerator, [...path, key], fail);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export { type AccessKey, type Analytics, type AnalyticsCache, AnalyticsError, type AnalyticsErrorCode, type AnalyticsErrorDetails, type AnalyticsErrorOf, type AnalyticsOptions, type AnalyticsQuery, type AnalyticsQueryEvent, type AnalyticsResult, type AnalyticsSource, createAnalytics, type DatasetCatalogEntry, type FieldKey, isAnalyticsError } from './analytics.js';
|
|
1
|
+
export { type AccessKey, type Analytics, type AnalyticsCache, AnalyticsError, type AnalyticsErrorCode, type AnalyticsErrorDetails, type AnalyticsErrorOf, type AnalyticsOptions, type AnalyticsQuery, type AnalyticsQueryEvent, type AnalyticsResolvers, type AnalyticsResult, type AnalyticsSource, createAnalytics, type DatasetCatalogEntry, type FieldKey, isAnalyticsError, type MeasureResolver, type ResolvedKey, type ResolverInput, type ResolverOutput } from './analytics.js';
|
|
2
2
|
export { CompileError, type CompiledStatement, type Dialect, type JoinClause, type SelectItem, type SelectStatement, type SqlRenderer, decodeNumber, renderStatement } from './compile.js';
|
|
3
|
-
export { type AnyDataset, type AnyDimension, type AnyMeasure, type Dataset, type DimensionValue, defineDataset, dimension, type MeasureValue, measure, type Ratio } from './dataset.js';
|
|
3
|
+
export { type AnyDataset, type AnyDimension, type AnyMeasure, type Dataset, type DimensionValue, defineDataset, dimension, type MeasureValue, measure, type Ratio, type ResolvedMeasure } from './dataset.js';
|
|
4
4
|
export { and, caseWhen, coalesce, type ColumnKey, eq, Expr, type ExprNode, exists, gt, gte, inList, isNotNull, isNull, literal, lt, lte, minutesAgo, ne, not, or, type SqlType, Table, type TableRef, table, toText } from './expr.js';
|
|
5
5
|
export { analyticsToolGuidance } from './guidance.js';
|
|
6
6
|
export type { PeriodInput, PeriodPreset } from './period.js';
|
|
7
|
-
export { allTenants, type Tenant, type TenantId } from './plan.js';
|
|
7
|
+
export { allTenants, type NormalizedFilter, type Tenant, type TenantId } from './plan.js';
|
|
8
8
|
export { AnalyticsResultSchema, type DatasetFilter, type DatasetQuery, type QueryResult, type ResultOf, type ResultPeriod } from './query.js';
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
AnalyticsError2,
|
|
5
5
|
isAnalyticsError2,
|
|
6
6
|
createAnalytics2
|
|
7
|
-
} from "./index-
|
|
7
|
+
} from "./index-g0ah4ehn.js";
|
|
8
8
|
import {
|
|
9
9
|
CompileError2,
|
|
10
10
|
decodeNumber2,
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
measure2,
|
|
15
15
|
dimension2,
|
|
16
16
|
defineDataset2
|
|
17
|
-
} from "./index-
|
|
17
|
+
} from "./index-rgqsrd72.js";
|
|
18
18
|
import {
|
|
19
19
|
Expr2,
|
|
20
20
|
Table2,
|
package/dist/metrics.js
CHANGED
package/dist/plan.d.ts
CHANGED
|
@@ -64,6 +64,11 @@ export type PlanContext = {
|
|
|
64
64
|
/** Keys for filters on open dimensions, after label resolution, by dimension. */
|
|
65
65
|
resolvedValues: ReadonlyMap<string, readonly (string | number)[]>;
|
|
66
66
|
};
|
|
67
|
+
/**
|
|
68
|
+
* The measures the database computes for `keys`: a resolved measure brings the measures it
|
|
69
|
+
* uses instead of itself.
|
|
70
|
+
*/
|
|
71
|
+
export declare function sqlMeasures(dataset: AnyDataset, keys: readonly string[]): string[];
|
|
67
72
|
/** Output column aliases. Keys come from definitions, never from input. */
|
|
68
73
|
export declare const columnAlias: {
|
|
69
74
|
measure: (key: string) => string;
|
|
@@ -85,10 +90,11 @@ export type ShapedValue = {
|
|
|
85
90
|
label: string;
|
|
86
91
|
};
|
|
87
92
|
export type ShapedRow = Record<string, ShapedValue | number | null>;
|
|
88
|
-
/** Decodes measures in one raw row; counts are never null. */
|
|
93
|
+
/** Decodes measures in one raw row; counts are never null. Resolved measures are left out. */
|
|
89
94
|
export declare function shapeMeasures(dataset: AnyDataset, measures: readonly string[], raw: Record<string, unknown>, dialect: Dialect): Record<string, number | null>;
|
|
90
95
|
export declare function shapeRows(dataset: AnyDataset, query: NormalizedQuery, rawRows: readonly Record<string, unknown>[], dialect: Dialect): {
|
|
91
96
|
rows: ShapedRow[];
|
|
97
|
+
kept: readonly Record<string, unknown>[];
|
|
92
98
|
truncated: boolean;
|
|
93
99
|
};
|
|
94
100
|
/** Notes a reader needs to read the numbers right. */
|
package/dist/query.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import type { AnyDataset, Dataset, DimensionValue, MeasureValue } from './dataset.js';
|
|
2
|
+
import type { AnyDataset, Dataset, DimensionValue, MeasureValue, SqlMeasureKey } from './dataset.js';
|
|
3
3
|
import { type PeriodInput } from './period.js';
|
|
4
4
|
export declare const MAX_MEASURES = 4;
|
|
5
5
|
export declare const MAX_GROUP_BY = 2;
|
|
@@ -61,7 +61,7 @@ export type DatasetQuery<DS> = DS extends Dataset<infer K, infer M, infer Dm, in
|
|
|
61
61
|
time?: T | undefined;
|
|
62
62
|
compareToPrevious?: boolean | undefined;
|
|
63
63
|
sort?: {
|
|
64
|
-
by:
|
|
64
|
+
by: SqlMeasureKey<M> | GroupableKey<Dm>;
|
|
65
65
|
direction?: 'asc' | 'desc' | undefined;
|
|
66
66
|
} | undefined;
|
|
67
67
|
limit?: number | undefined;
|
package/dist/testing.js
CHANGED
|
@@ -3,11 +3,11 @@ import {
|
|
|
3
3
|
allTenants2,
|
|
4
4
|
isAnalyticsError2,
|
|
5
5
|
createAnalytics2
|
|
6
|
-
} from "./index-
|
|
6
|
+
} from "./index-g0ah4ehn.js";
|
|
7
7
|
import {
|
|
8
8
|
measure2,
|
|
9
9
|
dimension2
|
|
10
|
-
} from "./index-
|
|
10
|
+
} from "./index-rgqsrd72.js";
|
|
11
11
|
|
|
12
12
|
// src/testing.ts
|
|
13
13
|
var COUNT = "__contractCount";
|
|
@@ -17,10 +17,11 @@ async function checkDatasetContract(options) {
|
|
|
17
17
|
const timezone = options.timezone ?? "Pacific/Auckland";
|
|
18
18
|
const period = options.period ?? { last: { days: 365 } };
|
|
19
19
|
const emptyTenant = options.emptyTenant ?? (typeof options.tenant === "number" ? -1 : "__contract_empty_tenant__");
|
|
20
|
+
const sqlMeasures = Object.entries(dataset.measures).filter(([, entry]) => entry.kind !== "resolved");
|
|
20
21
|
const probe = {
|
|
21
22
|
...dataset,
|
|
22
23
|
measures: {
|
|
23
|
-
...
|
|
24
|
+
...Object.fromEntries(sqlMeasures),
|
|
24
25
|
[COUNT]: measure2.count({ label: "Rows" })
|
|
25
26
|
},
|
|
26
27
|
dimensions: {
|
|
@@ -67,7 +68,7 @@ async function checkDatasetContract(options) {
|
|
|
67
68
|
message: `${scope} has no rows in the period; the checks below prove little`
|
|
68
69
|
});
|
|
69
70
|
}
|
|
70
|
-
for (const key of
|
|
71
|
+
for (const [key] of sqlMeasures) {
|
|
71
72
|
await check(`measure ${key}`, async () => {
|
|
72
73
|
const value = (await run({ measures: [key] })).totals[key];
|
|
73
74
|
if (value !== null && typeof value !== "number")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "measurr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "A small typed semantic layer: datasets of measures and dimensions compiled to one SQL statement per dialect.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"analytics",
|
|
23
23
|
"sql",
|
|
24
24
|
"postgres",
|
|
25
|
+
"clickhouse",
|
|
25
26
|
"zod",
|
|
26
27
|
"llm-tools"
|
|
27
28
|
],
|
|
@@ -45,6 +46,12 @@
|
|
|
45
46
|
"import": "./dist/dialects/postgres.js",
|
|
46
47
|
"default": "./dist/dialects/postgres.js"
|
|
47
48
|
},
|
|
49
|
+
"./clickhouse": {
|
|
50
|
+
"types": "./dist/dialects/clickhouse.d.ts",
|
|
51
|
+
"bun": "./src/dialects/clickhouse.ts",
|
|
52
|
+
"import": "./dist/dialects/clickhouse.js",
|
|
53
|
+
"default": "./dist/dialects/clickhouse.js"
|
|
54
|
+
},
|
|
48
55
|
"./shapes": {
|
|
49
56
|
"types": "./dist/shapes.d.ts",
|
|
50
57
|
"bun": "./src/shapes.ts",
|
|
@@ -72,7 +79,7 @@
|
|
|
72
79
|
},
|
|
73
80
|
"scripts": {
|
|
74
81
|
"build": "rm -rf dist && bun run build:js && bun run build:types",
|
|
75
|
-
"build:js": "bun build ./src/index.ts ./src/dialects/postgres.ts ./src/shapes.ts ./src/handler.ts ./src/metrics.ts ./src/testing.ts --root ./src --outdir ./dist --target node --packages external --splitting",
|
|
82
|
+
"build:js": "bun build ./src/index.ts ./src/dialects/postgres.ts ./src/dialects/clickhouse.ts ./src/shapes.ts ./src/handler.ts ./src/metrics.ts ./src/testing.ts --root ./src --outdir ./dist --target node --packages external --splitting",
|
|
76
83
|
"build:types": "tsgo -p tsconfig.build.json",
|
|
77
84
|
"prepublishOnly": "bun run build",
|
|
78
85
|
"test": "bun test",
|
package/src/analytics.ts
CHANGED
|
@@ -4,9 +4,13 @@ import type {
|
|
|
4
4
|
AnyDataset,
|
|
5
5
|
AnyDimension,
|
|
6
6
|
AnyMeasure,
|
|
7
|
-
Dataset
|
|
7
|
+
Dataset,
|
|
8
|
+
DimensionValue,
|
|
9
|
+
MeasureValue,
|
|
10
|
+
ResolvedMeasure
|
|
8
11
|
} from './dataset.js'
|
|
9
12
|
import {
|
|
13
|
+
type NormalizedFilter,
|
|
10
14
|
type NormalizedQuery,
|
|
11
15
|
type ParsedQuery,
|
|
12
16
|
type PlanContext,
|
|
@@ -31,6 +35,7 @@ import {
|
|
|
31
35
|
type DatasetQuery,
|
|
32
36
|
type QueryResult,
|
|
33
37
|
type ResultOf,
|
|
38
|
+
type ResultPeriod,
|
|
34
39
|
buildQuerySchema,
|
|
35
40
|
datasetResultSchema
|
|
36
41
|
} from './query.js'
|
|
@@ -128,6 +133,65 @@ export type AccessKey<D> =
|
|
|
128
133
|
? NonNullable<(M & Dm)[keyof (M & Dm)]['access']>
|
|
129
134
|
: never
|
|
130
135
|
|
|
136
|
+
/** The resolved measures of a dataset. */
|
|
137
|
+
export type ResolvedKey<D> =
|
|
138
|
+
D extends Dataset<string, infer M>
|
|
139
|
+
? {
|
|
140
|
+
[K in keyof M & string]: M[K] extends { kind: 'resolved' } ? K : never
|
|
141
|
+
}[keyof M & string]
|
|
142
|
+
: never
|
|
143
|
+
|
|
144
|
+
/** The values of the measures resolved measure `K` uses. */
|
|
145
|
+
type UsedValues<D, K> =
|
|
146
|
+
D extends Dataset<string, infer M>
|
|
147
|
+
? M[K & keyof M] extends ResolvedMeasure<MeasureResult, infer U>
|
|
148
|
+
? { [Used in U & keyof M]: MeasureValue<M[Used]> }
|
|
149
|
+
: never
|
|
150
|
+
: never
|
|
151
|
+
|
|
152
|
+
type MeasureResult = ResolvedMeasure['result']
|
|
153
|
+
|
|
154
|
+
/** What a resolver gets for one period of one query. */
|
|
155
|
+
export type ResolverInput<D, K, Ctx> =
|
|
156
|
+
D extends Dataset<string, infer _M, infer Dm>
|
|
157
|
+
? {
|
|
158
|
+
/**
|
|
159
|
+
* The result rows in order: their grouped dimensions and the measures `K` uses. One
|
|
160
|
+
* row when the query has no `groupBy`.
|
|
161
|
+
*/
|
|
162
|
+
rows: readonly {
|
|
163
|
+
dimensions: { [G in keyof Dm]?: DimensionValue<Dm[G]> }
|
|
164
|
+
values: UsedValues<D, K>
|
|
165
|
+
}[]
|
|
166
|
+
/** The used measures over every row of the period, ungrouped. */
|
|
167
|
+
totals: UsedValues<D, K>
|
|
168
|
+
groupBy: readonly (keyof Dm & string)[]
|
|
169
|
+
/** The query's filters, open-dimension labels already resolved to keys. */
|
|
170
|
+
filters: readonly NormalizedFilter[]
|
|
171
|
+
/** The period being resolved: the query's, or its previous one. */
|
|
172
|
+
period: ResultPeriod
|
|
173
|
+
tenant: Tenant
|
|
174
|
+
ctx: Ctx
|
|
175
|
+
}
|
|
176
|
+
: never
|
|
177
|
+
|
|
178
|
+
/** One value per row, in the same order, and one for the totals. */
|
|
179
|
+
export type ResolverOutput = {
|
|
180
|
+
rows: readonly (number | null)[]
|
|
181
|
+
totals: number | null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export type MeasureResolver<D, K, Ctx> = (
|
|
185
|
+
input: ResolverInput<D, K, Ctx>
|
|
186
|
+
) => ResolverOutput | Promise<ResolverOutput>
|
|
187
|
+
|
|
188
|
+
/** The code for every resolved measure, by dataset and measure. */
|
|
189
|
+
export type AnalyticsResolvers<DS extends readonly AnyDataset[], Ctx> = {
|
|
190
|
+
[D in DS[number] as [ResolvedKey<D>] extends [never] ? never : D['key']]: {
|
|
191
|
+
[K in ResolvedKey<D>]: MeasureResolver<D, K, Ctx>
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
131
195
|
export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
|
|
132
196
|
datasets: DS
|
|
133
197
|
/** Databases by name; a dataset's `source` picks one, or the only one is used. */
|
|
@@ -179,6 +243,13 @@ export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
|
|
|
179
243
|
* swallowed: a failing cache is a miss, never a failed query.
|
|
180
244
|
*/
|
|
181
245
|
cache?: AnalyticsCache<Ctx, DS[number]['key']> | undefined
|
|
246
|
+
/**
|
|
247
|
+
* The code for `measure.resolved` measures, by dataset and measure key: called once per
|
|
248
|
+
* period with the result rows and the values of the measures each one uses. Required for
|
|
249
|
+
* every resolved measure (`createAnalytics` throws otherwise). What it throws fails the
|
|
250
|
+
* query; a value that is not a finite number counts as null.
|
|
251
|
+
*/
|
|
252
|
+
resolvers?: NoInfer<AnalyticsResolvers<DS, Ctx>> | undefined
|
|
182
253
|
}
|
|
183
254
|
|
|
184
255
|
/**
|
|
@@ -272,6 +343,9 @@ export type AnalyticsResult<A, Q> =
|
|
|
272
343
|
|
|
273
344
|
type ShapedResult = QueryResult<Row, Row>
|
|
274
345
|
|
|
346
|
+
// biome-ignore lint/suspicious/noExplicitAny: resolvers are typed per dataset where they are declared.
|
|
347
|
+
type AnyResolver = (input: any) => ResolverOutput | Promise<ResolverOutput>
|
|
348
|
+
|
|
275
349
|
/** A measure or dimension marked with `access`, with every access key it needs. */
|
|
276
350
|
type RestrictedField = {
|
|
277
351
|
kind: 'measure' | 'dimension'
|
|
@@ -318,6 +392,33 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
318
392
|
restrictedOf.set(dataset.key, restricted)
|
|
319
393
|
}
|
|
320
394
|
|
|
395
|
+
const resolvers = (options.resolvers ?? {}) as Record<
|
|
396
|
+
string,
|
|
397
|
+
Record<string, AnyResolver> | undefined
|
|
398
|
+
>
|
|
399
|
+
for (const [datasetKey, entries] of Object.entries(resolvers)) {
|
|
400
|
+
const dataset = byKey.get(datasetKey)
|
|
401
|
+
if (!dataset)
|
|
402
|
+
throw new Error(`Resolvers name unknown dataset ${datasetKey}`)
|
|
403
|
+
for (const key of Object.keys(entries ?? {})) {
|
|
404
|
+
const entry: AnyMeasure | undefined = dataset.measures[key]
|
|
405
|
+
if (entry?.kind !== 'resolved') {
|
|
406
|
+
throw new Error(
|
|
407
|
+
`Resolvers name ${datasetKey}.${key}, which is not a resolved measure`
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
for (const dataset of datasets) {
|
|
413
|
+
for (const [key, entry] of Object.entries<AnyMeasure>(dataset.measures)) {
|
|
414
|
+
if (entry.kind === 'resolved' && !resolvers[dataset.key]?.[key]) {
|
|
415
|
+
throw new Error(
|
|
416
|
+
`Dataset ${dataset.key} resolves ${key} in the app, but createAnalytics has no resolver for it`
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
321
422
|
const now = options.now ?? (() => new Date())
|
|
322
423
|
const querySchemas = new Map<string, z.ZodType>()
|
|
323
424
|
const resultSchemas = new Map<string, z.ZodType>()
|
|
@@ -507,7 +608,10 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
507
608
|
return { dataset, query, tenant, timezone, period, previous, now: at }
|
|
508
609
|
}
|
|
509
610
|
|
|
510
|
-
const runQuery = async (
|
|
611
|
+
const runQuery = async (
|
|
612
|
+
prepared: Prepared,
|
|
613
|
+
ctx: Ctx
|
|
614
|
+
): Promise<ShapedResult> => {
|
|
511
615
|
const { dataset, query, tenant, timezone, period, previous, now } = prepared
|
|
512
616
|
const source = sourceFor(dataset)
|
|
513
617
|
const { dialect } = source
|
|
@@ -523,6 +627,8 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
523
627
|
dialect
|
|
524
628
|
)
|
|
525
629
|
|
|
630
|
+
const range = ({ from, to }: ResolvedPeriod) => ({ from, to, timezone })
|
|
631
|
+
|
|
526
632
|
const runPeriod = async (target: ResolvedPeriod | null) => {
|
|
527
633
|
const statements = periodStatements(dataset, query, {
|
|
528
634
|
tenant,
|
|
@@ -536,14 +642,63 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
536
642
|
statements.totals ? run(statements.totals) : null
|
|
537
643
|
])
|
|
538
644
|
|
|
539
|
-
const { rows, truncated } = shapeRows(
|
|
645
|
+
const { rows, kept, truncated } = shapeRows(
|
|
646
|
+
dataset,
|
|
647
|
+
query,
|
|
648
|
+
rawRows,
|
|
649
|
+
dialect
|
|
650
|
+
)
|
|
540
651
|
const totalsRow = (rawTotals ?? rawRows)[0] ?? {}
|
|
541
652
|
const totals = shapeMeasures(dataset, query.measures, totalsRow, dialect)
|
|
542
653
|
|
|
654
|
+
for (const key of query.measures) {
|
|
655
|
+
const entry: AnyMeasure | undefined = dataset.measures[key]
|
|
656
|
+
if (entry?.kind !== 'resolved') continue
|
|
657
|
+
|
|
658
|
+
const used = (raw: Row) =>
|
|
659
|
+
shapeMeasures(dataset, entry.uses, raw, dialect)
|
|
660
|
+
const resolver = resolvers[dataset.key]?.[key]
|
|
661
|
+
if (!resolver) throw new Error(`No resolver for ${dataset.key}.${key}`)
|
|
662
|
+
|
|
663
|
+
const output = await resolver({
|
|
664
|
+
rows: kept.map((raw, index) => ({
|
|
665
|
+
dimensions: Object.fromEntries(
|
|
666
|
+
query.groupBy.map((group) => [group, rows[index]?.[group]])
|
|
667
|
+
),
|
|
668
|
+
values: used(raw)
|
|
669
|
+
})),
|
|
670
|
+
totals: used(totalsRow),
|
|
671
|
+
groupBy: query.groupBy,
|
|
672
|
+
filters: query.filters.map((filter) => {
|
|
673
|
+
const resolved = resolvedValues.get(filter.dimension)
|
|
674
|
+
return resolved && 'values' in filter
|
|
675
|
+
? { ...filter, values: [...resolved] }
|
|
676
|
+
: filter
|
|
677
|
+
}),
|
|
678
|
+
period: target ? range(target) : { all: true, timezone },
|
|
679
|
+
tenant,
|
|
680
|
+
ctx
|
|
681
|
+
})
|
|
682
|
+
if (output.rows.length !== rows.length) {
|
|
683
|
+
throw new Error(
|
|
684
|
+
`The resolver for ${dataset.key}.${key} returned ${output.rows.length} values for ${rows.length} rows`
|
|
685
|
+
)
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const value = (raw: number | null | undefined) => {
|
|
689
|
+
const finite =
|
|
690
|
+
typeof raw === 'number' && Number.isFinite(raw) ? raw : null
|
|
691
|
+
return entry.result === 'count' ? (finite ?? 0) : finite
|
|
692
|
+
}
|
|
693
|
+
rows.forEach((row, index) => {
|
|
694
|
+
row[key] = value(output.rows[index])
|
|
695
|
+
})
|
|
696
|
+
totals[key] = value(output.totals)
|
|
697
|
+
}
|
|
698
|
+
|
|
543
699
|
return { rows, totals, truncated }
|
|
544
700
|
}
|
|
545
701
|
|
|
546
|
-
const range = ({ from, to }: ResolvedPeriod) => ({ from, to, timezone })
|
|
547
702
|
const [current, before] = await Promise.all([
|
|
548
703
|
runPeriod(period),
|
|
549
704
|
previous &&
|
|
@@ -619,7 +774,7 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
619
774
|
let result = key ? await cachedResult(key, entry) : null
|
|
620
775
|
if (key) cache = result ? 'hit' : 'miss'
|
|
621
776
|
if (!result) {
|
|
622
|
-
result = await runQuery(prepared)
|
|
777
|
+
result = await runQuery(prepared, ctx)
|
|
623
778
|
if (key) store(key, result, entry)
|
|
624
779
|
}
|
|
625
780
|
|
|
@@ -918,14 +1073,23 @@ function decodeKey(value: unknown, dialect: Dialect): string | number {
|
|
|
918
1073
|
*/
|
|
919
1074
|
function restrictedFields(dataset: AnyDataset): RestrictedField[] {
|
|
920
1075
|
const measures: Record<string, AnyMeasure> = dataset.measures
|
|
921
|
-
// defineDataset refuses ratio cycles.
|
|
1076
|
+
// defineDataset refuses ratio cycles, and resolved measures use only SQL measures.
|
|
922
1077
|
const accessOf = (key: string): string[] => {
|
|
923
1078
|
const entry = measures[key]
|
|
924
1079
|
if (!entry) return []
|
|
925
1080
|
const own = entry.access === undefined ? [] : [entry.access]
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1081
|
+
switch (entry.kind) {
|
|
1082
|
+
case 'ratio':
|
|
1083
|
+
return [
|
|
1084
|
+
...own,
|
|
1085
|
+
...accessOf(entry.numerator),
|
|
1086
|
+
...accessOf(entry.denominator)
|
|
1087
|
+
]
|
|
1088
|
+
case 'resolved':
|
|
1089
|
+
return [...own, ...entry.uses.flatMap(accessOf)]
|
|
1090
|
+
default:
|
|
1091
|
+
return own
|
|
1092
|
+
}
|
|
929
1093
|
}
|
|
930
1094
|
|
|
931
1095
|
return [
|
package/src/dataset.ts
CHANGED
|
@@ -76,7 +76,29 @@ export type RatioMeasure<
|
|
|
76
76
|
readonly denominator: B
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
/**
|
|
80
|
+
* A value the app computes after the query runs, from the measures it `uses` in each row and
|
|
81
|
+
* from anything else it can reach (another database, a forecast). The `resolvers` option of
|
|
82
|
+
* `createAnalytics` supplies the code.
|
|
83
|
+
*/
|
|
84
|
+
export type ResolvedMeasure<
|
|
85
|
+
R extends MeasureResult = MeasureResult,
|
|
86
|
+
U extends string = string
|
|
87
|
+
> = {
|
|
88
|
+
readonly kind: 'resolved'
|
|
89
|
+
readonly result: R
|
|
90
|
+
readonly label: string
|
|
91
|
+
readonly description: string | undefined
|
|
92
|
+
/** A resolved measure also needs the access of the measures it uses. */
|
|
93
|
+
readonly access: string | undefined
|
|
94
|
+
readonly uses: readonly U[]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type AnyMeasure =
|
|
98
|
+
| AggregateMeasure
|
|
99
|
+
| ShareMeasure
|
|
100
|
+
| RatioMeasure
|
|
101
|
+
| ResolvedMeasure
|
|
80
102
|
|
|
81
103
|
/** The TypeScript type of a measure's value in a result row. */
|
|
82
104
|
export type MeasureValue<M> = M extends { result: 'count' }
|
|
@@ -198,6 +220,41 @@ export const measure = {
|
|
|
198
220
|
numerator,
|
|
199
221
|
denominator
|
|
200
222
|
}
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
resolved
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* A value the app computes after the query runs, for numbers one statement cannot give:
|
|
230
|
+
* another database, a forecast, a per-day average. `uses` names the measures of this dataset
|
|
231
|
+
* the resolver reads from each row; they are fetched even when the query does not ask for
|
|
232
|
+
* them. The code goes in `createAnalytics({ resolvers })`. Queries cannot sort by it, since
|
|
233
|
+
* the database limits the rows before the value exists.
|
|
234
|
+
*/
|
|
235
|
+
function resolved<R extends MeasureResult, Access extends string = never>(
|
|
236
|
+
options: Described<Access> & { result: R; uses?: undefined }
|
|
237
|
+
): ResolvedMeasure<R, never> & Marked<NoInfer<Access>>
|
|
238
|
+
function resolved<
|
|
239
|
+
R extends MeasureResult,
|
|
240
|
+
const U extends string,
|
|
241
|
+
Access extends string = never
|
|
242
|
+
>(
|
|
243
|
+
options: Described<Access> & { result: R; uses: readonly U[] }
|
|
244
|
+
): ResolvedMeasure<R, U> & Marked<NoInfer<Access>>
|
|
245
|
+
function resolved(
|
|
246
|
+
options: Described & {
|
|
247
|
+
result: MeasureResult
|
|
248
|
+
uses?: readonly string[] | undefined
|
|
249
|
+
}
|
|
250
|
+
): ResolvedMeasure {
|
|
251
|
+
return {
|
|
252
|
+
kind: 'resolved',
|
|
253
|
+
result: options.result,
|
|
254
|
+
label: options.label,
|
|
255
|
+
description: options.description,
|
|
256
|
+
access: options.access,
|
|
257
|
+
uses: options.uses ?? []
|
|
201
258
|
}
|
|
202
259
|
}
|
|
203
260
|
|
|
@@ -618,14 +675,24 @@ export type Dataset<
|
|
|
618
675
|
// biome-ignore lint/suspicious/noExplicitAny: the widest dataset, used only as a constraint.
|
|
619
676
|
export type AnyDataset = Dataset<string, any, any, string>
|
|
620
677
|
|
|
621
|
-
/**
|
|
678
|
+
/** Measures the database computes: everything but resolved measures. */
|
|
679
|
+
export type SqlMeasureKey<M> = {
|
|
680
|
+
[Key in keyof M & string]: M[Key] extends { kind: 'resolved' } ? never : Key
|
|
681
|
+
}[keyof M & string]
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Ratio measures may only name other SQL measures of the same dataset, and resolved measures
|
|
685
|
+
* may only use SQL measures.
|
|
686
|
+
*/
|
|
622
687
|
type CheckMeasures<M> = {
|
|
623
688
|
[Key in keyof M]: M[Key] extends { kind: 'ratio' }
|
|
624
689
|
? {
|
|
625
|
-
numerator: Exclude<
|
|
626
|
-
denominator: Exclude<
|
|
690
|
+
numerator: Exclude<SqlMeasureKey<M>, Key>
|
|
691
|
+
denominator: Exclude<SqlMeasureKey<M>, Key>
|
|
627
692
|
}
|
|
628
|
-
:
|
|
693
|
+
: M[Key] extends { kind: 'resolved' }
|
|
694
|
+
? { uses: readonly SqlMeasureKey<M>[] }
|
|
695
|
+
: unknown
|
|
629
696
|
}
|
|
630
697
|
|
|
631
698
|
/** Time dimensions may only name declared time fields. */
|
|
@@ -764,6 +831,14 @@ function validateDataset<DS extends AnyDataset>(dataset: DS): DS {
|
|
|
764
831
|
if (entry.denominator) check(entry.denominator, `measure ${key}`)
|
|
765
832
|
}
|
|
766
833
|
if (entry.kind === 'ratio') checkRatio(measures, key, [], fail)
|
|
834
|
+
if (entry.kind === 'resolved') {
|
|
835
|
+
for (const used of entry.uses) {
|
|
836
|
+
const usedEntry = measures[used]
|
|
837
|
+
if (!usedEntry) fail(`measure ${key} uses unknown measure ${used}`)
|
|
838
|
+
if (usedEntry?.kind === 'resolved')
|
|
839
|
+
fail(`measure ${key} uses ${used}, which is resolved too`)
|
|
840
|
+
}
|
|
841
|
+
}
|
|
767
842
|
}
|
|
768
843
|
|
|
769
844
|
const dimensions: Record<string, AnyDimension> = dataset.dimensions
|
|
@@ -820,6 +895,8 @@ function checkRatio(
|
|
|
820
895
|
|
|
821
896
|
const entry = measures[key]
|
|
822
897
|
if (!entry) fail(`ratio measure ${path.at(-1)} names unknown measure ${key}`)
|
|
898
|
+
if (entry?.kind === 'resolved')
|
|
899
|
+
fail(`ratio measure ${path.at(-1)} names ${key}, which is resolved`)
|
|
823
900
|
if (entry?.kind !== 'ratio') return
|
|
824
901
|
|
|
825
902
|
checkRatio(measures, entry.numerator, [...path, key], fail)
|