measurr 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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
+ }