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/compile.ts
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AggregateFn,
|
|
3
|
+
type ExprNode,
|
|
4
|
+
type ParamValue,
|
|
5
|
+
type TableRef,
|
|
6
|
+
type TimeBucketUnit,
|
|
7
|
+
type TimePartName,
|
|
8
|
+
rootOf,
|
|
9
|
+
walk
|
|
10
|
+
} from './expr.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A planned query, still free of SQL. The planner builds it; a dialect renders it. Joins are
|
|
14
|
+
* not listed: the renderer adds exactly the joins the statement's columns need.
|
|
15
|
+
*/
|
|
16
|
+
export type SelectStatement = {
|
|
17
|
+
readonly from: TableRef
|
|
18
|
+
/**
|
|
19
|
+
* Joins for standalone tables (a many-to-many link table) that apply only when one of
|
|
20
|
+
* their columns is used.
|
|
21
|
+
*/
|
|
22
|
+
readonly virtualJoins: readonly JoinClause[]
|
|
23
|
+
readonly where: readonly ExprNode[]
|
|
24
|
+
readonly select: readonly SelectItem[]
|
|
25
|
+
/** Indexes into `select`. */
|
|
26
|
+
readonly groupBy: readonly number[]
|
|
27
|
+
readonly orderBy: readonly { index: number; direction: 'asc' | 'desc' }[]
|
|
28
|
+
readonly limit: number | null
|
|
29
|
+
/** The moment the query runs; `minutesAgo` expressions count back from it. */
|
|
30
|
+
readonly now: Date
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type SelectItem = { readonly alias: string; readonly node: ExprNode }
|
|
34
|
+
|
|
35
|
+
export type JoinClause = {
|
|
36
|
+
readonly table: TableRef
|
|
37
|
+
readonly type: 'left' | 'inner'
|
|
38
|
+
readonly on: ExprNode
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** SQL text plus its positional parameters. */
|
|
42
|
+
export type CompiledStatement = {
|
|
43
|
+
readonly text: string
|
|
44
|
+
readonly values: readonly unknown[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Everything a database needs: it renders statements and decodes what comes back. A dialect
|
|
49
|
+
* has no driver dependency; the app's `execute` runs the SQL. Postgres ships in this package;
|
|
50
|
+
* another database plugs in by implementing this, usually through a `SqlRenderer`.
|
|
51
|
+
*/
|
|
52
|
+
export type Dialect = {
|
|
53
|
+
readonly name: string
|
|
54
|
+
compile(statement: SelectStatement): CompiledStatement
|
|
55
|
+
/**
|
|
56
|
+
* Numbers may arrive as strings or bigints (numerics, 64-bit integers), or as decimal
|
|
57
|
+
* objects (Prisma's `Decimal`).
|
|
58
|
+
*/
|
|
59
|
+
decodeNumber(value: unknown): number | null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The places where SQL differs between databases. `renderStatement` handles the rest. */
|
|
63
|
+
export type SqlRenderer = {
|
|
64
|
+
readonly name: string
|
|
65
|
+
quote(identifier: string): string
|
|
66
|
+
/** Registers a parameter and returns its placeholder. `index` is 1-based. */
|
|
67
|
+
param(
|
|
68
|
+
value: ParamValue,
|
|
69
|
+
index: number
|
|
70
|
+
): { placeholder: string; value: unknown }
|
|
71
|
+
aggregate(fn: AggregateFn, arg: string | null, where: string | null): string
|
|
72
|
+
/** Floating-point division that is null when dividing by zero. */
|
|
73
|
+
divide(left: string, right: string): string
|
|
74
|
+
toText(expr: string): string
|
|
75
|
+
/** A `YYYY-MM-DD` string of the bucket's first day in `timezone`. */
|
|
76
|
+
timeBucket(unit: TimeBucketUnit, expr: string, timezone: string): string
|
|
77
|
+
/** An integer: ISO weekday 1-7 or hour 0-23 in `timezone`. */
|
|
78
|
+
timePart(part: TimePartName, expr: string, timezone: string): string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class CompileError extends Error {
|
|
82
|
+
override readonly name = 'CompileError'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Decodes numbers the way drivers return them: numbers, numeric strings, bigints and decimal
|
|
87
|
+
* objects with a `toNumber()` method (Prisma's `Decimal`, decimal.js). Anything else is null.
|
|
88
|
+
*/
|
|
89
|
+
export function decodeNumber(value: unknown): number | null {
|
|
90
|
+
if (value === null || value === undefined) return null
|
|
91
|
+
if (typeof value === 'number') return Number.isNaN(value) ? null : value
|
|
92
|
+
if (typeof value === 'bigint') return Number(value)
|
|
93
|
+
if (typeof value === 'string') {
|
|
94
|
+
// Number('') is 0; an empty string is no number.
|
|
95
|
+
const parsed = value.trim() === '' ? Number.NaN : Number(value)
|
|
96
|
+
return Number.isNaN(parsed) ? null : parsed
|
|
97
|
+
}
|
|
98
|
+
if (
|
|
99
|
+
typeof value === 'object' &&
|
|
100
|
+
'toNumber' in value &&
|
|
101
|
+
typeof value.toNumber === 'function'
|
|
102
|
+
) {
|
|
103
|
+
return decodeNumber(value.toNumber())
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function renderStatement(
|
|
110
|
+
statement: SelectStatement,
|
|
111
|
+
renderer: SqlRenderer
|
|
112
|
+
): CompiledStatement {
|
|
113
|
+
return new RenderContext(renderer, statement.now).render(statement)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
class RenderContext {
|
|
117
|
+
private readonly aliases = new Map<TableRef, string>()
|
|
118
|
+
private readonly usedAliases = new Set<string>()
|
|
119
|
+
private readonly values: unknown[] = []
|
|
120
|
+
|
|
121
|
+
constructor(
|
|
122
|
+
private readonly renderer: SqlRenderer,
|
|
123
|
+
private readonly now: Date
|
|
124
|
+
) {}
|
|
125
|
+
|
|
126
|
+
render(statement: SelectStatement): CompiledStatement {
|
|
127
|
+
const r = this.renderer
|
|
128
|
+
const nodes = [
|
|
129
|
+
...statement.select.map((item) => item.node),
|
|
130
|
+
...statement.where
|
|
131
|
+
]
|
|
132
|
+
const joins = collectJoins(nodes, statement.from, statement.virtualJoins)
|
|
133
|
+
|
|
134
|
+
const select = statement.select.map(
|
|
135
|
+
(item) => `${this.expr(item.node)} AS ${r.quote(item.alias)}`
|
|
136
|
+
)
|
|
137
|
+
const lines = [
|
|
138
|
+
`SELECT ${select.join(', ')}`,
|
|
139
|
+
`FROM ${this.tableSource(statement.from, joins)}`
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
if (statement.where.length > 0) {
|
|
143
|
+
lines.push(
|
|
144
|
+
`WHERE ${statement.where.map((node) => this.expr(node)).join(' AND ')}`
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
if (statement.groupBy.length > 0) {
|
|
148
|
+
// By position: a repeated expression would bind its parameters again, and Postgres
|
|
149
|
+
// treats `$1` in SELECT and `$3` in GROUP BY as different expressions.
|
|
150
|
+
const refs = statement.groupBy.map((index) => {
|
|
151
|
+
if (!statement.select[index])
|
|
152
|
+
throw new CompileError(
|
|
153
|
+
`GROUP BY refers to missing select item ${index}`
|
|
154
|
+
)
|
|
155
|
+
return String(index + 1)
|
|
156
|
+
})
|
|
157
|
+
lines.push(`GROUP BY ${refs.join(', ')}`)
|
|
158
|
+
}
|
|
159
|
+
if (statement.orderBy.length > 0) {
|
|
160
|
+
const refs = statement.orderBy.map(({ index, direction }) => {
|
|
161
|
+
const item = statement.select[index]
|
|
162
|
+
if (!item)
|
|
163
|
+
throw new CompileError(
|
|
164
|
+
`ORDER BY refers to missing select item ${index}`
|
|
165
|
+
)
|
|
166
|
+
return `${r.quote(item.alias)} ${direction === 'asc' ? 'ASC' : 'DESC'}`
|
|
167
|
+
})
|
|
168
|
+
lines.push(`ORDER BY ${refs.join(', ')}`)
|
|
169
|
+
}
|
|
170
|
+
if (statement.limit !== null)
|
|
171
|
+
lines.push(`LIMIT ${Math.trunc(statement.limit)}`)
|
|
172
|
+
|
|
173
|
+
return { text: lines.join('\n'), values: this.values }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private alias(ref: TableRef): string {
|
|
177
|
+
const existing = this.aliases.get(ref)
|
|
178
|
+
if (existing) return existing
|
|
179
|
+
|
|
180
|
+
const base = ref.alias ?? ref.name
|
|
181
|
+
let alias = base
|
|
182
|
+
for (let n = 2; this.usedAliases.has(alias); n++) alias = `${base}_${n}`
|
|
183
|
+
|
|
184
|
+
this.aliases.set(ref, alias)
|
|
185
|
+
this.usedAliases.add(alias)
|
|
186
|
+
return alias
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private tableSource(root: TableRef, joins: readonly JoinClause[]): string {
|
|
190
|
+
const q = this.renderer.quote
|
|
191
|
+
const parts = [`${q(root.name)} AS ${q(this.alias(root))}`]
|
|
192
|
+
|
|
193
|
+
for (const join of joins) {
|
|
194
|
+
const keyword = join.type === 'left' ? 'LEFT JOIN' : 'INNER JOIN'
|
|
195
|
+
const source = `${q(join.table.name)} AS ${q(this.alias(join.table))}`
|
|
196
|
+
parts.push(`${keyword} ${source} ON ${this.expr(join.on)}`)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return parts.join(' ')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* `SELECT 1 FROM <table and its joins> WHERE <where>`. The FROM is rendered first so table
|
|
204
|
+
* aliases are assigned in reading order.
|
|
205
|
+
*/
|
|
206
|
+
private existsSubquery(table: TableRef, where: ExprNode): string {
|
|
207
|
+
const from = this.tableSource(table, collectJoins([where], table, []))
|
|
208
|
+
|
|
209
|
+
return `SELECT 1 FROM ${from} WHERE ${this.expr(where)}`
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private param(value: ParamValue): string {
|
|
213
|
+
const index = this.values.length + 1
|
|
214
|
+
const { placeholder, value: bound } = this.renderer.param(value, index)
|
|
215
|
+
this.values.push(bound)
|
|
216
|
+
|
|
217
|
+
return placeholder
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private expr(node: ExprNode): string {
|
|
221
|
+
const r = this.renderer
|
|
222
|
+
|
|
223
|
+
switch (node.kind) {
|
|
224
|
+
case 'column':
|
|
225
|
+
return `${r.quote(this.alias(node.table))}.${r.quote(node.column)}`
|
|
226
|
+
case 'param':
|
|
227
|
+
return this.param(node.value)
|
|
228
|
+
case 'minutesAgo':
|
|
229
|
+
return this.param(new Date(this.now.getTime() - node.minutes * 60_000))
|
|
230
|
+
case 'compare':
|
|
231
|
+
return `(${this.expr(node.left)} ${node.op} ${this.expr(node.right)})`
|
|
232
|
+
case 'logical': {
|
|
233
|
+
if (node.items.length === 0)
|
|
234
|
+
return node.op === 'and' ? '(1 = 1)' : '(1 = 0)'
|
|
235
|
+
const joiner = node.op === 'and' ? ' AND ' : ' OR '
|
|
236
|
+
return `(${node.items.map((item) => this.expr(item)).join(joiner)})`
|
|
237
|
+
}
|
|
238
|
+
case 'not':
|
|
239
|
+
return `(NOT ${this.expr(node.item)})`
|
|
240
|
+
case 'isNull':
|
|
241
|
+
return `(${this.expr(node.item)} IS ${node.negated ? 'NOT ' : ''}NULL)`
|
|
242
|
+
case 'in': {
|
|
243
|
+
if (node.values.length === 0)
|
|
244
|
+
return node.negated ? '(1 = 1)' : '(1 = 0)'
|
|
245
|
+
const values = node.values.map((value) => this.expr(value)).join(', ')
|
|
246
|
+
return `(${this.expr(node.item)} ${node.negated ? 'NOT IN' : 'IN'} (${values}))`
|
|
247
|
+
}
|
|
248
|
+
case 'case': {
|
|
249
|
+
const branches = node.branches
|
|
250
|
+
.map(
|
|
251
|
+
(branch) =>
|
|
252
|
+
`WHEN ${this.expr(branch.when)} THEN ${this.expr(branch.result)}`
|
|
253
|
+
)
|
|
254
|
+
.join(' ')
|
|
255
|
+
const otherwise = node.otherwise
|
|
256
|
+
? ` ELSE ${this.expr(node.otherwise)}`
|
|
257
|
+
: ''
|
|
258
|
+
return `(CASE ${branches}${otherwise} END)`
|
|
259
|
+
}
|
|
260
|
+
case 'exists': {
|
|
261
|
+
const body = this.existsSubquery(node.table, node.where)
|
|
262
|
+
return `(${node.negated ? 'NOT ' : ''}EXISTS (${body}))`
|
|
263
|
+
}
|
|
264
|
+
case 'coalesce':
|
|
265
|
+
return `coalesce(${node.items.map((item) => this.expr(item)).join(', ')})`
|
|
266
|
+
case 'arithmetic': {
|
|
267
|
+
const left = this.expr(node.left)
|
|
268
|
+
const right = this.expr(node.right)
|
|
269
|
+
if (node.op === '/') return r.divide(left, right)
|
|
270
|
+
return `(${left} ${node.op} ${right})`
|
|
271
|
+
}
|
|
272
|
+
case 'lower':
|
|
273
|
+
return `lower(${this.expr(node.item)})`
|
|
274
|
+
case 'toText':
|
|
275
|
+
return r.toText(this.expr(node.item))
|
|
276
|
+
case 'aggregate':
|
|
277
|
+
return r.aggregate(
|
|
278
|
+
node.fn,
|
|
279
|
+
node.arg ? this.expr(node.arg) : null,
|
|
280
|
+
node.where ? this.expr(node.where) : null
|
|
281
|
+
)
|
|
282
|
+
case 'timeBucket':
|
|
283
|
+
return r.timeBucket(node.unit, this.expr(node.item), node.timezone)
|
|
284
|
+
case 'timePart':
|
|
285
|
+
return r.timePart(node.part, this.expr(node.item), node.timezone)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The joins `nodes` need, parents before children and `ON` dependencies before the join that
|
|
292
|
+
* uses them. Columns of other roots (such as a subquery's own table) are left alone.
|
|
293
|
+
*/
|
|
294
|
+
export function collectJoins(
|
|
295
|
+
nodes: readonly ExprNode[],
|
|
296
|
+
root: TableRef,
|
|
297
|
+
virtualJoins: readonly JoinClause[]
|
|
298
|
+
): JoinClause[] {
|
|
299
|
+
const virtualByRoot = new Map(virtualJoins.map((join) => [join.table, join]))
|
|
300
|
+
const ordered: JoinClause[] = []
|
|
301
|
+
const added = new Set<TableRef>([root])
|
|
302
|
+
|
|
303
|
+
const visit = (node: ExprNode) =>
|
|
304
|
+
walk(node, (inner) => {
|
|
305
|
+
if (inner.kind !== 'column') return
|
|
306
|
+
const columnRoot = rootOf(inner.table)
|
|
307
|
+
if (columnRoot === root || virtualByRoot.has(columnRoot))
|
|
308
|
+
ensure(inner.table)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
const ensure = (ref: TableRef) => {
|
|
312
|
+
if (added.has(ref)) return
|
|
313
|
+
|
|
314
|
+
if (ref.join) {
|
|
315
|
+
ensure(ref.join.parent)
|
|
316
|
+
added.add(ref)
|
|
317
|
+
visit(ref.join.on)
|
|
318
|
+
ordered.push({ table: ref, type: ref.join.type, on: ref.join.on })
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const virtual = virtualByRoot.get(ref)
|
|
323
|
+
if (!virtual) return
|
|
324
|
+
added.add(ref)
|
|
325
|
+
visit(virtual.on)
|
|
326
|
+
ordered.push(virtual)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
for (const node of nodes) visit(node)
|
|
330
|
+
|
|
331
|
+
return ordered
|
|
332
|
+
}
|