lopata 0.19.2 → 0.20.1
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/dist/types/bindings/ai-search.d.ts +44 -0
- package/dist/types/bindings/analytics-engine-sql.d.ts +107 -0
- package/dist/types/bindings/artifacts-git-http.d.ts +19 -0
- package/dist/types/bindings/artifacts.d.ts +108 -0
- package/dist/types/bindings/flagship.d.ts +37 -0
- package/dist/types/bindings/vpc-network.d.ts +22 -0
- package/dist/types/bindings/worker-loader-entry.d.ts +71 -0
- package/dist/types/bindings/worker-loader.d.ts +81 -0
- package/dist/types/config.d.ts +23 -0
- package/dist/types/env.d.ts +3 -1
- package/dist/types/generation-manager.d.ts +6 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/dist/types/vite-plugin/dev-server-plugin.d.ts +8 -0
- package/dist/types/worker-thread/executor.d.ts +2 -0
- package/dist/types/worker-thread/protocol.d.ts +2 -0
- package/dist/types/worker-thread/thread-env.d.ts +3 -1
- package/package.json +1 -1
- package/src/bindings/ai-search.ts +185 -0
- package/src/bindings/analytics-engine-sql.ts +1138 -0
- package/src/bindings/artifacts-git-http.ts +249 -0
- package/src/bindings/artifacts.ts +453 -0
- package/src/bindings/do-worker-env.ts +4 -0
- package/src/bindings/flagship.ts +117 -0
- package/src/bindings/vpc-network.ts +40 -0
- package/src/bindings/worker-loader-entry.ts +143 -0
- package/src/bindings/worker-loader.ts +325 -0
- package/src/cli/dev.ts +25 -3
- package/src/config.ts +20 -0
- package/src/db.ts +54 -0
- package/src/env.ts +71 -0
- package/src/generation-manager.ts +5 -1
- package/src/generation.ts +20 -3
- package/src/plugin.ts +37 -0
- package/src/vite-plugin/dev-server-plugin.ts +18 -2
- package/src/worker-thread/entry.ts +1 -0
- package/src/worker-thread/executor.ts +3 -0
- package/src/worker-thread/protocol.ts +2 -0
- package/src/worker-thread/thread-env.ts +72 -1
|
@@ -0,0 +1,1138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local emulation of the Cloudflare Workers Analytics Engine **SQL API**.
|
|
3
|
+
*
|
|
4
|
+
* Cloudflare has no Worker binding for *reading* Analytics Engine data — you
|
|
5
|
+
* POST a ClickHouse-flavoured SQL string to
|
|
6
|
+
* https://api.cloudflare.com/client/v4/accounts/<acc>/analytics_engine/sql
|
|
7
|
+
* with `Authorization: Bearer <token>`. Lopata intercepts that fetch (see
|
|
8
|
+
* `plugin.ts`) and serves it from the local `analytics_engine` SQLite table so
|
|
9
|
+
* the *same* worker code runs locally and in production unchanged.
|
|
10
|
+
*
|
|
11
|
+
* Interception is gated on the token: a missing `Authorization` header or the
|
|
12
|
+
* sentinel `Bearer local` is served locally; any real bearer token falls
|
|
13
|
+
* through to the actual Cloudflare API. Drive the token from env (e.g. `local`
|
|
14
|
+
* in `.dev.vars`, the real secret in prod) to keep one code path. Note that
|
|
15
|
+
* `writeDataPoint` always writes locally — only the query fetch is gated.
|
|
16
|
+
*
|
|
17
|
+
* SPIKE SCOPE — this is a pragmatic subset, not a full ClickHouse engine:
|
|
18
|
+
* SELECT <items> FROM <dataset>
|
|
19
|
+
* [WHERE <cond>] [GROUP BY <exprs>] [ORDER BY <exprs>] [LIMIT n] [FORMAT f]
|
|
20
|
+
* with the functions/operators people actually use for dashboards (counts,
|
|
21
|
+
* sums, time filters, time bucketing). Unsupported constructs throw a clear
|
|
22
|
+
* error instead of silently returning wrong numbers.
|
|
23
|
+
*
|
|
24
|
+
* Key local simplifications (correct *because* there's no sampling locally):
|
|
25
|
+
* - `_sample_interval` is always 1, so `sum(_sample_interval)` == `count()`.
|
|
26
|
+
* - The `timestamp` column is stored as **ms epoch**; ClickHouse treats it as
|
|
27
|
+
* a `DateTime` (seconds). We canonicalise to **seconds** on read so
|
|
28
|
+
* `now()` / `INTERVAL` arithmetic lines up, then render `DateTime` *output*
|
|
29
|
+
* columns as ClickHouse `YYYY-MM-DD HH:MM:SS` (UTC) strings to match the real
|
|
30
|
+
* SQL API — so `new Date(row.bucket)` behaves the same locally as in prod.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { Database } from 'bun:sqlite'
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// URL matching
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
const SQL_PATH_RE = /\/accounts\/[^/]+\/analytics_engine\/sql\/?$/
|
|
40
|
+
|
|
41
|
+
/** True for the Cloudflare Analytics Engine SQL API endpoint. */
|
|
42
|
+
export function isAnalyticsEngineSqlUrl(url: string): boolean {
|
|
43
|
+
try {
|
|
44
|
+
const u = new URL(url)
|
|
45
|
+
return u.hostname === 'api.cloudflare.com' && SQL_PATH_RE.test(u.pathname)
|
|
46
|
+
} catch {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether an AE SQL API request should be served from the local store rather
|
|
53
|
+
* than forwarded to Cloudflare. A missing `Authorization` header or the
|
|
54
|
+
* `Bearer local` sentinel means local; any real bearer token passes through.
|
|
55
|
+
*/
|
|
56
|
+
export function isLocalAnalyticsEngineToken(auth: string | null | undefined): boolean {
|
|
57
|
+
return !auth || auth.trim().toLowerCase() === 'bearer local'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Tokenizer
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
interface Token {
|
|
65
|
+
type: 'num' | 'str' | 'ident' | 'op' | 'punct'
|
|
66
|
+
value: string
|
|
67
|
+
/** Uppercased value for idents/ops — used for keyword matching. */
|
|
68
|
+
upper: string
|
|
69
|
+
start: number
|
|
70
|
+
end: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const MULTI_OPS = ['<=', '>=', '!=', '<>']
|
|
74
|
+
const SINGLE_OPS = new Set(['+', '-', '*', '/', '%', '=', '<', '>'])
|
|
75
|
+
const PUNCT = new Set(['(', ')', ',', '.', ';'])
|
|
76
|
+
|
|
77
|
+
class SqlError extends Error {}
|
|
78
|
+
|
|
79
|
+
function tokenize(sql: string): Token[] {
|
|
80
|
+
const toks: Token[] = []
|
|
81
|
+
let i = 0
|
|
82
|
+
const n = sql.length
|
|
83
|
+
while (i < n) {
|
|
84
|
+
const c = sql[i]!
|
|
85
|
+
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
|
|
86
|
+
i++
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
// line comments
|
|
90
|
+
if (c === '-' && sql[i + 1] === '-') {
|
|
91
|
+
while (i < n && sql[i] !== '\n') i++
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
// block comments
|
|
95
|
+
if (c === '/' && sql[i + 1] === '*') {
|
|
96
|
+
i += 2
|
|
97
|
+
while (i < n && !(sql[i] === '*' && sql[i + 1] === '/')) i++
|
|
98
|
+
i += 2
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
// string literal (single quotes, '' escape)
|
|
102
|
+
if (c === "'") {
|
|
103
|
+
const start = i
|
|
104
|
+
i++
|
|
105
|
+
let value = ''
|
|
106
|
+
while (i < n) {
|
|
107
|
+
if (sql[i] === "'") {
|
|
108
|
+
if (sql[i + 1] === "'") {
|
|
109
|
+
value += "'"
|
|
110
|
+
i += 2
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
i++
|
|
114
|
+
break
|
|
115
|
+
}
|
|
116
|
+
value += sql[i]
|
|
117
|
+
i++
|
|
118
|
+
}
|
|
119
|
+
toks.push({ type: 'str', value, upper: value, start, end: i })
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
// backtick / double-quoted identifier
|
|
123
|
+
if (c === '`' || c === '"') {
|
|
124
|
+
const quote = c
|
|
125
|
+
const start = i
|
|
126
|
+
i++
|
|
127
|
+
let value = ''
|
|
128
|
+
while (i < n && sql[i] !== quote) {
|
|
129
|
+
value += sql[i]
|
|
130
|
+
i++
|
|
131
|
+
}
|
|
132
|
+
i++ // closing quote
|
|
133
|
+
toks.push({ type: 'ident', value, upper: value.toUpperCase(), start, end: i })
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
// number — at most one decimal point (a second `.` ends the token, so
|
|
137
|
+
// `1.2.3` tokenizes as `1.2` `.` `3` and fails loudly at parse time rather
|
|
138
|
+
// than silently becoming `NaN`).
|
|
139
|
+
if (c >= '0' && c <= '9') {
|
|
140
|
+
const start = i
|
|
141
|
+
let seenDot = false
|
|
142
|
+
while (i < n) {
|
|
143
|
+
const ch = sql[i]!
|
|
144
|
+
if (ch >= '0' && ch <= '9') {
|
|
145
|
+
i++
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
if (ch === '.' && !seenDot) {
|
|
149
|
+
seenDot = true
|
|
150
|
+
i++
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
break
|
|
154
|
+
}
|
|
155
|
+
const value = sql.slice(start, i)
|
|
156
|
+
toks.push({ type: 'num', value, upper: value, start, end: i })
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
// identifier / keyword
|
|
160
|
+
if (/[A-Za-z_]/.test(c)) {
|
|
161
|
+
const start = i
|
|
162
|
+
while (i < n && /[A-Za-z0-9_]/.test(sql[i]!)) i++
|
|
163
|
+
const value = sql.slice(start, i)
|
|
164
|
+
toks.push({ type: 'ident', value, upper: value.toUpperCase(), start, end: i })
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
// multi-char operators
|
|
168
|
+
const two = sql.slice(i, i + 2)
|
|
169
|
+
if (MULTI_OPS.includes(two)) {
|
|
170
|
+
toks.push({ type: 'op', value: two, upper: two, start: i, end: i + 2 })
|
|
171
|
+
i += 2
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
if (SINGLE_OPS.has(c)) {
|
|
175
|
+
toks.push({ type: 'op', value: c, upper: c, start: i, end: i + 1 })
|
|
176
|
+
i++
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
if (PUNCT.has(c)) {
|
|
180
|
+
toks.push({ type: 'punct', value: c, upper: c, start: i, end: i + 1 })
|
|
181
|
+
i++
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
throw new SqlError(`Unexpected character '${c}' at position ${i}`)
|
|
185
|
+
}
|
|
186
|
+
return toks
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// AST
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
type Node =
|
|
194
|
+
| { kind: 'num'; value: number }
|
|
195
|
+
| { kind: 'str'; value: string }
|
|
196
|
+
| { kind: 'col'; name: string }
|
|
197
|
+
| { kind: 'star' }
|
|
198
|
+
| { kind: 'func'; name: string; args: Node[]; params?: Node[]; distinct?: boolean }
|
|
199
|
+
| { kind: 'interval'; seconds: number }
|
|
200
|
+
| { kind: 'binary'; op: string; left: Node; right: Node }
|
|
201
|
+
| { kind: 'unary'; op: string; operand: Node }
|
|
202
|
+
| { kind: 'in'; expr: Node; list: Node[]; negate: boolean }
|
|
203
|
+
| { kind: 'like'; expr: Node; pattern: Node; negate: boolean }
|
|
204
|
+
| { kind: 'between'; expr: Node; low: Node; high: Node; negate: boolean }
|
|
205
|
+
| { kind: 'isnull'; expr: Node; negate: boolean }
|
|
206
|
+
|
|
207
|
+
interface SelectItem {
|
|
208
|
+
expr: Node
|
|
209
|
+
/** Output column name (alias, or the raw source text of the expression). */
|
|
210
|
+
name: string
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
interface OrderItem {
|
|
214
|
+
expr: Node
|
|
215
|
+
desc: boolean
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
interface ParsedQuery {
|
|
219
|
+
select: SelectItem[]
|
|
220
|
+
dataset: string
|
|
221
|
+
where?: Node
|
|
222
|
+
groupBy: Node[]
|
|
223
|
+
orderBy: OrderItem[]
|
|
224
|
+
limit?: number
|
|
225
|
+
format: string
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const CLAUSE_KEYWORDS = new Set(['FROM', 'WHERE', 'GROUP', 'ORDER', 'LIMIT', 'FORMAT', 'BY', 'ASC', 'DESC', 'AS'])
|
|
229
|
+
|
|
230
|
+
// Recognised SQL keywords the emulation deliberately doesn't implement — surfaced
|
|
231
|
+
// with a clearer error than a generic "unexpected token".
|
|
232
|
+
const UNSUPPORTED_KEYWORDS = new Set([
|
|
233
|
+
'HAVING',
|
|
234
|
+
'OFFSET',
|
|
235
|
+
'JOIN',
|
|
236
|
+
'INNER',
|
|
237
|
+
'LEFT',
|
|
238
|
+
'RIGHT',
|
|
239
|
+
'FULL',
|
|
240
|
+
'UNION',
|
|
241
|
+
'WITH',
|
|
242
|
+
])
|
|
243
|
+
|
|
244
|
+
const INTERVAL_UNITS: Record<string, number> = {
|
|
245
|
+
SECOND: 1,
|
|
246
|
+
SECONDS: 1,
|
|
247
|
+
MINUTE: 60,
|
|
248
|
+
MINUTES: 60,
|
|
249
|
+
HOUR: 3600,
|
|
250
|
+
HOURS: 3600,
|
|
251
|
+
DAY: 86400,
|
|
252
|
+
DAYS: 86400,
|
|
253
|
+
WEEK: 604800,
|
|
254
|
+
WEEKS: 604800,
|
|
255
|
+
// Calendar months/years aren't fixed-length; approximate (documented limitation).
|
|
256
|
+
MONTH: 2592000,
|
|
257
|
+
MONTHS: 2592000,
|
|
258
|
+
YEAR: 31536000,
|
|
259
|
+
YEARS: 31536000,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Binary operator precedence (higher binds tighter).
|
|
263
|
+
const PRECEDENCE: Record<string, number> = {
|
|
264
|
+
OR: 1,
|
|
265
|
+
AND: 2,
|
|
266
|
+
'=': 3,
|
|
267
|
+
'!=': 3,
|
|
268
|
+
'<>': 3,
|
|
269
|
+
'<': 3,
|
|
270
|
+
'<=': 3,
|
|
271
|
+
'>': 3,
|
|
272
|
+
'>=': 3,
|
|
273
|
+
'+': 4,
|
|
274
|
+
'-': 4,
|
|
275
|
+
'*': 5,
|
|
276
|
+
'/': 5,
|
|
277
|
+
'%': 5,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
class Parser {
|
|
281
|
+
private pos = 0
|
|
282
|
+
constructor(private toks: Token[], private sql: string) {}
|
|
283
|
+
|
|
284
|
+
private peek(): Token | undefined {
|
|
285
|
+
return this.toks[this.pos]
|
|
286
|
+
}
|
|
287
|
+
private next(): Token {
|
|
288
|
+
const t = this.toks[this.pos]
|
|
289
|
+
if (!t) throw new SqlError('Unexpected end of query')
|
|
290
|
+
this.pos++
|
|
291
|
+
return t
|
|
292
|
+
}
|
|
293
|
+
private eof(): boolean {
|
|
294
|
+
return this.pos >= this.toks.length
|
|
295
|
+
}
|
|
296
|
+
private expectKeyword(kw: string): void {
|
|
297
|
+
const t = this.peek()
|
|
298
|
+
if (!t || t.type !== 'ident' || t.upper !== kw) {
|
|
299
|
+
throw new SqlError(`Expected ${kw}${t ? `, got '${t.value}'` : ''}`)
|
|
300
|
+
}
|
|
301
|
+
this.pos++
|
|
302
|
+
}
|
|
303
|
+
private isKeyword(kw: string): boolean {
|
|
304
|
+
const t = this.peek()
|
|
305
|
+
return !!t && t.type === 'ident' && t.upper === kw
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
parse(): ParsedQuery {
|
|
309
|
+
this.expectKeyword('SELECT')
|
|
310
|
+
const select = this.parseSelectList()
|
|
311
|
+
this.expectKeyword('FROM')
|
|
312
|
+
const dataset = this.parseDataset()
|
|
313
|
+
|
|
314
|
+
let where: Node | undefined
|
|
315
|
+
if (this.isKeyword('WHERE')) {
|
|
316
|
+
this.next()
|
|
317
|
+
where = this.parseExpr(0)
|
|
318
|
+
}
|
|
319
|
+
const groupBy: Node[] = []
|
|
320
|
+
if (this.isKeyword('GROUP')) {
|
|
321
|
+
this.next()
|
|
322
|
+
this.expectKeyword('BY')
|
|
323
|
+
groupBy.push(this.parseExpr(0))
|
|
324
|
+
while (this.peek()?.value === ',') {
|
|
325
|
+
this.next()
|
|
326
|
+
groupBy.push(this.parseExpr(0))
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const orderBy: OrderItem[] = []
|
|
330
|
+
if (this.isKeyword('ORDER')) {
|
|
331
|
+
this.next()
|
|
332
|
+
this.expectKeyword('BY')
|
|
333
|
+
orderBy.push(this.parseOrderItem())
|
|
334
|
+
while (this.peek()?.value === ',') {
|
|
335
|
+
this.next()
|
|
336
|
+
orderBy.push(this.parseOrderItem())
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
let limit: number | undefined
|
|
340
|
+
if (this.isKeyword('LIMIT')) {
|
|
341
|
+
this.next()
|
|
342
|
+
const t = this.next()
|
|
343
|
+
if (t.type !== 'num') throw new SqlError(`Expected number after LIMIT, got '${t.value}'`)
|
|
344
|
+
limit = Number.parseInt(t.value, 10)
|
|
345
|
+
}
|
|
346
|
+
let format = 'JSON'
|
|
347
|
+
if (this.isKeyword('FORMAT')) {
|
|
348
|
+
this.next()
|
|
349
|
+
format = this.next().value.toUpperCase()
|
|
350
|
+
}
|
|
351
|
+
// allow trailing semicolon
|
|
352
|
+
if (this.peek()?.value === ';') this.next()
|
|
353
|
+
if (!this.eof()) {
|
|
354
|
+
const t = this.peek()!
|
|
355
|
+
if (UNSUPPORTED_KEYWORDS.has(t.upper)) {
|
|
356
|
+
throw new SqlError(`'${t.value}' is not supported by the local Analytics Engine emulation`)
|
|
357
|
+
}
|
|
358
|
+
throw new SqlError(`Unexpected token '${t.value}' — the local Analytics Engine emulation supports a subset of the SQL API`)
|
|
359
|
+
}
|
|
360
|
+
return { select, dataset, where, groupBy, orderBy, limit, format }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private parseSelectList(): SelectItem[] {
|
|
364
|
+
const items: SelectItem[] = []
|
|
365
|
+
for (;;) {
|
|
366
|
+
const startTok = this.peek()
|
|
367
|
+
const expr = this.parseExpr(0)
|
|
368
|
+
const endTok = this.toks[this.pos - 1]!
|
|
369
|
+
let name: string
|
|
370
|
+
if (this.isKeyword('AS')) {
|
|
371
|
+
this.next()
|
|
372
|
+
name = this.next().value
|
|
373
|
+
} else if (this.peek()?.type === 'ident' && !CLAUSE_KEYWORDS.has(this.peek()!.upper)) {
|
|
374
|
+
// implicit alias: `expr alias`
|
|
375
|
+
name = this.next().value
|
|
376
|
+
} else {
|
|
377
|
+
// no alias → use the raw source text of the expression (ClickHouse behaviour)
|
|
378
|
+
name = this.sql.slice(startTok!.start, endTok.end).trim()
|
|
379
|
+
}
|
|
380
|
+
items.push({ expr, name })
|
|
381
|
+
if (this.peek()?.value === ',') {
|
|
382
|
+
this.next()
|
|
383
|
+
continue
|
|
384
|
+
}
|
|
385
|
+
break
|
|
386
|
+
}
|
|
387
|
+
return items
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private parseDataset(): string {
|
|
391
|
+
const t = this.next()
|
|
392
|
+
if (t.type !== 'ident' && t.type !== 'str') {
|
|
393
|
+
throw new SqlError(`Expected dataset name after FROM, got '${t.value}'`)
|
|
394
|
+
}
|
|
395
|
+
return t.value
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private parseOrderItem(): OrderItem {
|
|
399
|
+
const expr = this.parseExpr(0)
|
|
400
|
+
let desc = false
|
|
401
|
+
if (this.isKeyword('ASC')) this.next()
|
|
402
|
+
else if (this.isKeyword('DESC')) {
|
|
403
|
+
this.next()
|
|
404
|
+
desc = true
|
|
405
|
+
}
|
|
406
|
+
return { expr, desc }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// --- Pratt expression parser ---
|
|
410
|
+
|
|
411
|
+
private parseExpr(minPrec: number): Node {
|
|
412
|
+
let left = this.parsePrefix()
|
|
413
|
+
for (;;) {
|
|
414
|
+
const t = this.peek()
|
|
415
|
+
if (!t) break
|
|
416
|
+
// Postfix predicates (IN / LIKE / BETWEEN / IS [NOT] NULL) bind at
|
|
417
|
+
// comparison precedence (3). Skip them when parsing a tighter operand.
|
|
418
|
+
if (t.type === 'ident' && 3 >= minPrec) {
|
|
419
|
+
const u = t.upper
|
|
420
|
+
if (u === 'IN' || u === 'LIKE' || u === 'BETWEEN' || u === 'IS') {
|
|
421
|
+
left = this.parsePredicate(left, false)
|
|
422
|
+
continue
|
|
423
|
+
}
|
|
424
|
+
if (u === 'NOT') {
|
|
425
|
+
const after = this.toks[this.pos + 1]
|
|
426
|
+
if (after?.type === 'ident' && (after.upper === 'IN' || after.upper === 'LIKE' || after.upper === 'BETWEEN')) {
|
|
427
|
+
this.next() // consume NOT
|
|
428
|
+
left = this.parsePredicate(left, true)
|
|
429
|
+
continue
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const op = t.type === 'op' ? t.value : t.type === 'ident' && (t.upper === 'AND' || t.upper === 'OR') ? t.upper : undefined
|
|
434
|
+
if (!op) break
|
|
435
|
+
const prec = PRECEDENCE[op]
|
|
436
|
+
if (prec === undefined || prec < minPrec) break
|
|
437
|
+
this.next()
|
|
438
|
+
const right = this.parseExpr(prec + 1)
|
|
439
|
+
left = { kind: 'binary', op, left, right }
|
|
440
|
+
}
|
|
441
|
+
return left
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Parse a postfix predicate after `left`: IN (...), LIKE p, BETWEEN a AND b, IS [NOT] NULL. */
|
|
445
|
+
private parsePredicate(left: Node, negate: boolean): Node {
|
|
446
|
+
const kw = this.next().upper
|
|
447
|
+
if (kw === 'IN') {
|
|
448
|
+
if (this.next().value !== '(') throw new SqlError('Expected ( after IN')
|
|
449
|
+
const list: Node[] = []
|
|
450
|
+
if (this.peek()?.value !== ')') {
|
|
451
|
+
list.push(this.parseExpr(0))
|
|
452
|
+
while (this.peek()?.value === ',') {
|
|
453
|
+
this.next()
|
|
454
|
+
list.push(this.parseExpr(0))
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (this.next().value !== ')') throw new SqlError('Expected ) to close IN list')
|
|
458
|
+
return { kind: 'in', expr: left, list, negate }
|
|
459
|
+
}
|
|
460
|
+
if (kw === 'LIKE') {
|
|
461
|
+
// Parse the pattern above AND/OR/comparison so it doesn't swallow the rest.
|
|
462
|
+
return { kind: 'like', expr: left, pattern: this.parseExpr(4), negate }
|
|
463
|
+
}
|
|
464
|
+
if (kw === 'BETWEEN') {
|
|
465
|
+
const low = this.parseExpr(4)
|
|
466
|
+
this.expectKeyword('AND')
|
|
467
|
+
const high = this.parseExpr(4)
|
|
468
|
+
return { kind: 'between', expr: left, low, high, negate }
|
|
469
|
+
}
|
|
470
|
+
// IS [NOT] NULL
|
|
471
|
+
let neg = false
|
|
472
|
+
if (this.isKeyword('NOT')) {
|
|
473
|
+
this.next()
|
|
474
|
+
neg = true
|
|
475
|
+
}
|
|
476
|
+
this.expectKeyword('NULL')
|
|
477
|
+
return { kind: 'isnull', expr: left, negate: neg }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Logical `NOT` is a low-precedence prefix: it binds looser than comparison
|
|
482
|
+
* (so `NOT a = b` is `NOT (a = b)`, matching SQL/ClickHouse) but tighter than
|
|
483
|
+
* AND/OR. We capture the comparison-level operand to its right, then return so
|
|
484
|
+
* the binary loop in `parseExpr` can still pick up any following AND/OR.
|
|
485
|
+
* (Postfix `NOT IN/LIKE/BETWEEN` is handled separately inside `parseExpr`.)
|
|
486
|
+
*/
|
|
487
|
+
private parsePrefix(): Node {
|
|
488
|
+
const t = this.peek()
|
|
489
|
+
if (t?.type === 'ident' && t.upper === 'NOT') {
|
|
490
|
+
this.next()
|
|
491
|
+
// Operand at comparison precedence (3): includes comparisons and tighter
|
|
492
|
+
// operators, but stops before AND (2) / OR (1).
|
|
493
|
+
return { kind: 'unary', op: 'NOT', operand: this.parseExpr(3) }
|
|
494
|
+
}
|
|
495
|
+
return this.parseUnary()
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
private parseUnary(): Node {
|
|
499
|
+
const t = this.peek()
|
|
500
|
+
if (t?.type === 'op' && t.value === '-') {
|
|
501
|
+
this.next()
|
|
502
|
+
return { kind: 'unary', op: '-', operand: this.parseUnary() }
|
|
503
|
+
}
|
|
504
|
+
return this.parsePrimary()
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private parsePrimary(): Node {
|
|
508
|
+
const t = this.next()
|
|
509
|
+
if (t.type === 'num') {
|
|
510
|
+
const value = Number(t.value)
|
|
511
|
+
if (Number.isNaN(value)) throw new SqlError(`Invalid numeric literal '${t.value}'`)
|
|
512
|
+
return { kind: 'num', value }
|
|
513
|
+
}
|
|
514
|
+
if (t.type === 'str') return { kind: 'str', value: t.value }
|
|
515
|
+
if (t.type === 'op' && t.value === '*') return { kind: 'star' }
|
|
516
|
+
if (t.value === '(') {
|
|
517
|
+
const inner = this.parseExpr(0)
|
|
518
|
+
if (this.next().value !== ')') throw new SqlError('Expected )')
|
|
519
|
+
return inner
|
|
520
|
+
}
|
|
521
|
+
if (t.type === 'ident') {
|
|
522
|
+
// INTERVAL 'n' UNIT / INTERVAL n UNIT
|
|
523
|
+
if (t.upper === 'INTERVAL') return this.parseInterval()
|
|
524
|
+
// function call?
|
|
525
|
+
if (this.peek()?.value === '(') {
|
|
526
|
+
return this.parseFunc(t.value)
|
|
527
|
+
}
|
|
528
|
+
return { kind: 'col', name: t.value }
|
|
529
|
+
}
|
|
530
|
+
throw new SqlError(`Unexpected token '${t.value}'`)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private parseInterval(): Node {
|
|
534
|
+
const amountTok = this.next()
|
|
535
|
+
const amount = Number(amountTok.value)
|
|
536
|
+
if (Number.isNaN(amount)) throw new SqlError(`Invalid INTERVAL amount '${amountTok.value}'`)
|
|
537
|
+
const unitTok = this.next()
|
|
538
|
+
const secs = INTERVAL_UNITS[unitTok.upper]
|
|
539
|
+
if (secs === undefined) throw new SqlError(`Unsupported INTERVAL unit '${unitTok.value}'`)
|
|
540
|
+
return { kind: 'interval', seconds: amount * secs }
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private parseFunc(name: string): Node {
|
|
544
|
+
const { args: first, distinct } = this.parseArgList(name)
|
|
545
|
+
// Parametric aggregates like quantileWeighted(0.5)(value, weight): a second arg list.
|
|
546
|
+
// The first list holds the parameters (e.g. the level), the second the arguments.
|
|
547
|
+
if (this.peek()?.value === '(') {
|
|
548
|
+
const { args } = this.parseArgList(name)
|
|
549
|
+
return { kind: 'func', name, args, params: first }
|
|
550
|
+
}
|
|
551
|
+
return { kind: 'func', name, args: first, distinct }
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private parseArgList(name: string): { args: Node[]; distinct: boolean } {
|
|
555
|
+
this.next() // (
|
|
556
|
+
let distinct = false
|
|
557
|
+
if (this.isKeyword('DISTINCT')) {
|
|
558
|
+
this.next()
|
|
559
|
+
distinct = true
|
|
560
|
+
}
|
|
561
|
+
const args: Node[] = []
|
|
562
|
+
if (this.peek()?.value !== ')') {
|
|
563
|
+
args.push(this.parseExpr(0))
|
|
564
|
+
while (this.peek()?.value === ',') {
|
|
565
|
+
this.next()
|
|
566
|
+
args.push(this.parseExpr(0))
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (this.next().value !== ')') throw new SqlError(`Expected ) after ${name}(...)`)
|
|
570
|
+
return { args, distinct }
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ---------------------------------------------------------------------------
|
|
575
|
+
// Emitter: AST → SQLite expression (+ result type for `meta`)
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
|
|
578
|
+
interface EmitCtx {
|
|
579
|
+
/** Baked `now()` value in epoch seconds (no UDFs in bun:sqlite). */
|
|
580
|
+
nowSeconds: number
|
|
581
|
+
/** SELECT output aliases — referenceable from GROUP BY / ORDER BY. */
|
|
582
|
+
aliases: Set<string>
|
|
583
|
+
/** Set when a LIKE is emitted, so the runner can force case-sensitive matching. */
|
|
584
|
+
usesLike: boolean
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const COL_RE = /^(blob([1-9]|1[0-9]|20)|double([1-9]|1[0-9]|20)|index1|_sample_interval|timestamp|dataset)$/
|
|
588
|
+
|
|
589
|
+
function colType(name: string): string {
|
|
590
|
+
if (name === 'timestamp') return 'DateTime'
|
|
591
|
+
if (name.startsWith('double')) return 'Float64'
|
|
592
|
+
if (name === '_sample_interval') return 'UInt32'
|
|
593
|
+
return 'String'
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function quoteStr(s: string): string {
|
|
597
|
+
return `'${s.replace(/'/g, "''")}'`
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function emit(node: Node, ctx: EmitCtx): string {
|
|
601
|
+
switch (node.kind) {
|
|
602
|
+
case 'num':
|
|
603
|
+
return String(node.value)
|
|
604
|
+
case 'str':
|
|
605
|
+
return quoteStr(node.value)
|
|
606
|
+
case 'star':
|
|
607
|
+
return '*'
|
|
608
|
+
case 'interval':
|
|
609
|
+
return String(node.seconds)
|
|
610
|
+
case 'col': {
|
|
611
|
+
// Canonicalise stored ms-epoch timestamp to ClickHouse-style seconds.
|
|
612
|
+
if (node.name === 'timestamp') return '(timestamp / 1000.0)'
|
|
613
|
+
if (COL_RE.test(node.name)) return node.name
|
|
614
|
+
// A reference to a SELECT alias (valid in GROUP BY / ORDER BY).
|
|
615
|
+
if (ctx.aliases.has(node.name)) return JSON.stringify(node.name)
|
|
616
|
+
throw new SqlError(`Unknown column '${node.name}'`)
|
|
617
|
+
}
|
|
618
|
+
case 'unary':
|
|
619
|
+
return node.op === 'NOT' ? `(NOT ${emit(node.operand, ctx)})` : `(-${emit(node.operand, ctx)})`
|
|
620
|
+
case 'binary': {
|
|
621
|
+
// ClickHouse `/` is always floating-point; SQLite `/` truncates when both
|
|
622
|
+
// operands are integers — force real division to match.
|
|
623
|
+
if (node.op === '/') return `(${emit(node.left, ctx)} * 1.0 / ${emit(node.right, ctx)})`
|
|
624
|
+
const op = node.op === '<>' ? '!=' : node.op
|
|
625
|
+
return `(${emit(node.left, ctx)} ${op} ${emit(node.right, ctx)})`
|
|
626
|
+
}
|
|
627
|
+
case 'in': {
|
|
628
|
+
const list = node.list.map(n => emit(n, ctx)).join(', ')
|
|
629
|
+
return `(${emit(node.expr, ctx)} ${node.negate ? 'NOT IN' : 'IN'} (${list}))`
|
|
630
|
+
}
|
|
631
|
+
case 'like':
|
|
632
|
+
// ClickHouse LIKE is case-sensitive; SQLite's is not. Flagged here so the
|
|
633
|
+
// runner flips `PRAGMA case_sensitive_like` for the query.
|
|
634
|
+
ctx.usesLike = true
|
|
635
|
+
return `(${emit(node.expr, ctx)} ${node.negate ? 'NOT LIKE' : 'LIKE'} ${emit(node.pattern, ctx)})`
|
|
636
|
+
case 'between':
|
|
637
|
+
return `(${emit(node.expr, ctx)} ${node.negate ? 'NOT BETWEEN' : 'BETWEEN'} ${emit(node.low, ctx)} AND ${emit(node.high, ctx)})`
|
|
638
|
+
case 'isnull':
|
|
639
|
+
return `(${emit(node.expr, ctx)} IS ${node.negate ? 'NOT NULL' : 'NULL'})`
|
|
640
|
+
case 'func':
|
|
641
|
+
return emitFunc(node, ctx)
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const QUANTILE_FNS = new Set(['quantile', 'quantileexact', 'quantileweighted', 'quantileexactweighted'])
|
|
646
|
+
|
|
647
|
+
/** Non-quantile aggregate functions. */
|
|
648
|
+
const PLAIN_AGGREGATE_FNS = new Set(['count', 'sum', 'avg', 'min', 'max'])
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Whether `node` contains a non-quantile aggregate anywhere in its subtree. Used
|
|
652
|
+
* to reject aggregates where they aren't allowed (WHERE / GROUP BY) and nested
|
|
653
|
+
* inside another aggregate — both of which SQLite would otherwise surface as an
|
|
654
|
+
* opaque "misuse of aggregate" error.
|
|
655
|
+
*/
|
|
656
|
+
function containsPlainAggregate(node: Node): boolean {
|
|
657
|
+
switch (node.kind) {
|
|
658
|
+
case 'func':
|
|
659
|
+
if (PLAIN_AGGREGATE_FNS.has(node.name.toLowerCase())) return true
|
|
660
|
+
return node.args.some(containsPlainAggregate) || (node.params?.some(containsPlainAggregate) ?? false)
|
|
661
|
+
case 'binary':
|
|
662
|
+
return containsPlainAggregate(node.left) || containsPlainAggregate(node.right)
|
|
663
|
+
case 'unary':
|
|
664
|
+
return containsPlainAggregate(node.operand)
|
|
665
|
+
case 'in':
|
|
666
|
+
return containsPlainAggregate(node.expr) || node.list.some(containsPlainAggregate)
|
|
667
|
+
case 'like':
|
|
668
|
+
return containsPlainAggregate(node.expr) || containsPlainAggregate(node.pattern)
|
|
669
|
+
case 'between':
|
|
670
|
+
return containsPlainAggregate(node.expr) || containsPlainAggregate(node.low) || containsPlainAggregate(node.high)
|
|
671
|
+
case 'isnull':
|
|
672
|
+
return containsPlainAggregate(node.expr)
|
|
673
|
+
default:
|
|
674
|
+
return false
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Expected `[min, max]` argument counts per function, enforced before emit so a
|
|
680
|
+
* wrong-arity call fails with a clear error instead of emitting a literal
|
|
681
|
+
* `undefined` (e.g. `if(x, 1)` → `... ELSE undefined`, which SQLite then reads as
|
|
682
|
+
* an unknown column).
|
|
683
|
+
*/
|
|
684
|
+
const FN_ARITY: Record<string, [number, number]> = {
|
|
685
|
+
count: [0, 1],
|
|
686
|
+
sum: [1, 1],
|
|
687
|
+
avg: [1, 1],
|
|
688
|
+
min: [1, 1],
|
|
689
|
+
max: [1, 1],
|
|
690
|
+
touint32: [1, 1],
|
|
691
|
+
touint64: [1, 1],
|
|
692
|
+
toint32: [1, 1],
|
|
693
|
+
toint64: [1, 1],
|
|
694
|
+
tofloat64: [1, 1],
|
|
695
|
+
tofloat32: [1, 1],
|
|
696
|
+
tostring: [1, 1],
|
|
697
|
+
todatetime: [1, 1],
|
|
698
|
+
intdiv: [2, 2],
|
|
699
|
+
tostartofinterval: [2, 2],
|
|
700
|
+
tostartofminute: [1, 1],
|
|
701
|
+
tostartofhour: [1, 1],
|
|
702
|
+
tostartofday: [1, 1],
|
|
703
|
+
abs: [1, 1],
|
|
704
|
+
round: [1, 2],
|
|
705
|
+
min2: [2, 2],
|
|
706
|
+
max2: [2, 2],
|
|
707
|
+
coalesce: [1, Number.POSITIVE_INFINITY],
|
|
708
|
+
length: [1, 1],
|
|
709
|
+
lower: [1, 1],
|
|
710
|
+
upper: [1, 1],
|
|
711
|
+
floor: [1, 1],
|
|
712
|
+
if: [3, 3],
|
|
713
|
+
now: [0, 0],
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function checkArity(node: Extract<Node, { kind: 'func' }>): void {
|
|
717
|
+
const arity = FN_ARITY[node.name.toLowerCase()]
|
|
718
|
+
if (!arity) return
|
|
719
|
+
const [min, max] = arity
|
|
720
|
+
const got = node.args.length
|
|
721
|
+
if (got >= min && got <= max) return
|
|
722
|
+
const noun = (n: number) => (n === 1 ? 'argument' : 'arguments')
|
|
723
|
+
const expected = max === Number.POSITIVE_INFINITY
|
|
724
|
+
? `at least ${min} ${noun(min)}`
|
|
725
|
+
: min === max
|
|
726
|
+
? `exactly ${min} ${noun(min)}`
|
|
727
|
+
: `${min} to ${max} arguments`
|
|
728
|
+
throw new SqlError(`Function '${node.name}' expects ${expected}, got ${got}`)
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function emitFunc(node: Extract<Node, { kind: 'func' }>, ctx: EmitCtx): string {
|
|
732
|
+
const fn = node.name.toLowerCase()
|
|
733
|
+
if (QUANTILE_FNS.has(fn)) {
|
|
734
|
+
throw new SqlError(`Function '${node.name}' is only supported in the SELECT list, not inside other expressions`)
|
|
735
|
+
}
|
|
736
|
+
if (PLAIN_AGGREGATE_FNS.has(fn) && node.args.some(containsPlainAggregate)) {
|
|
737
|
+
throw new SqlError(`Aggregate function '${node.name}' cannot be nested inside another aggregate`)
|
|
738
|
+
}
|
|
739
|
+
checkArity(node)
|
|
740
|
+
const a = node.args.map(arg => emit(arg, ctx))
|
|
741
|
+
switch (fn) {
|
|
742
|
+
// --- aggregates ---
|
|
743
|
+
case 'count':
|
|
744
|
+
if (node.distinct && a.length === 1) return `COUNT(DISTINCT ${a[0]})`
|
|
745
|
+
return 'COUNT(*)'
|
|
746
|
+
case 'sum':
|
|
747
|
+
// ClickHouse sum() over zero/all-NULL rows is 0; SQLite SUM is NULL.
|
|
748
|
+
return `COALESCE(SUM(${a[0]}), 0)`
|
|
749
|
+
case 'avg':
|
|
750
|
+
return `AVG(${a[0]})`
|
|
751
|
+
case 'min':
|
|
752
|
+
return `MIN(${a[0]})`
|
|
753
|
+
case 'max':
|
|
754
|
+
return `MAX(${a[0]})`
|
|
755
|
+
// --- type casts (ClickHouse → SQLite) ---
|
|
756
|
+
case 'touint32':
|
|
757
|
+
case 'touint64':
|
|
758
|
+
case 'toint32':
|
|
759
|
+
case 'toint64':
|
|
760
|
+
return `CAST(${a[0]} AS INTEGER)`
|
|
761
|
+
case 'tofloat64':
|
|
762
|
+
case 'tofloat32':
|
|
763
|
+
return `CAST(${a[0]} AS REAL)`
|
|
764
|
+
case 'tostring':
|
|
765
|
+
return `CAST(${a[0]} AS TEXT)`
|
|
766
|
+
case 'todatetime':
|
|
767
|
+
// Our DateTime is just epoch seconds; identity.
|
|
768
|
+
return `(${a[0]})`
|
|
769
|
+
// --- integer / time bucketing ---
|
|
770
|
+
case 'intdiv':
|
|
771
|
+
return `(CAST(${a[0]} AS INTEGER) / CAST(${a[1]} AS INTEGER))`
|
|
772
|
+
case 'tostartofinterval': {
|
|
773
|
+
// toStartOfInterval(ts, INTERVAL n unit) → floor to bucket (seconds)
|
|
774
|
+
const secs = node.args[1]?.kind === 'interval' ? node.args[1].seconds : undefined
|
|
775
|
+
if (secs === undefined) throw new SqlError('toStartOfInterval requires an INTERVAL literal as its 2nd argument')
|
|
776
|
+
return `((CAST(${a[0]} AS INTEGER) / ${secs}) * ${secs})`
|
|
777
|
+
}
|
|
778
|
+
case 'tostartofminute':
|
|
779
|
+
return `((CAST(${a[0]} AS INTEGER) / 60) * 60)`
|
|
780
|
+
case 'tostartofhour':
|
|
781
|
+
return `((CAST(${a[0]} AS INTEGER) / 3600) * 3600)`
|
|
782
|
+
case 'tostartofday':
|
|
783
|
+
return `((CAST(${a[0]} AS INTEGER) / 86400) * 86400)`
|
|
784
|
+
// --- misc passthrough (SQLite has these) ---
|
|
785
|
+
case 'abs':
|
|
786
|
+
case 'round':
|
|
787
|
+
case 'min2':
|
|
788
|
+
case 'max2':
|
|
789
|
+
case 'coalesce':
|
|
790
|
+
case 'length':
|
|
791
|
+
case 'lower':
|
|
792
|
+
case 'upper':
|
|
793
|
+
return `${fn.toUpperCase()}(${a.join(', ')})`
|
|
794
|
+
case 'floor':
|
|
795
|
+
return `CAST(${a[0]} AS INTEGER)`
|
|
796
|
+
case 'if':
|
|
797
|
+
return `(CASE WHEN ${a[0]} THEN ${a[1]} ELSE ${a[2]} END)`
|
|
798
|
+
case 'now':
|
|
799
|
+
return String(ctx.nowSeconds)
|
|
800
|
+
default:
|
|
801
|
+
throw new SqlError(`Function '${node.name}' is not supported by the local Analytics Engine emulation yet`)
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function inferType(node: Node): string {
|
|
806
|
+
switch (node.kind) {
|
|
807
|
+
case 'num':
|
|
808
|
+
return Number.isInteger(node.value) ? 'UInt64' : 'Float64'
|
|
809
|
+
case 'str':
|
|
810
|
+
return 'String'
|
|
811
|
+
case 'col':
|
|
812
|
+
return colType(node.name)
|
|
813
|
+
case 'binary':
|
|
814
|
+
return ['=', '!=', '<>', '<', '<=', '>', '>=', 'AND', 'OR'].includes(node.op) ? 'UInt8' : 'Float64'
|
|
815
|
+
case 'unary':
|
|
816
|
+
return node.op === 'NOT' ? 'UInt8' : inferType(node.operand)
|
|
817
|
+
case 'in':
|
|
818
|
+
case 'like':
|
|
819
|
+
case 'between':
|
|
820
|
+
case 'isnull':
|
|
821
|
+
return 'UInt8'
|
|
822
|
+
case 'func': {
|
|
823
|
+
const fn = node.name.toLowerCase()
|
|
824
|
+
if (fn === 'count') return 'UInt64'
|
|
825
|
+
if (['sum', 'avg', 'tofloat64', 'tofloat32', 'abs', 'round'].includes(fn)) return 'Float64'
|
|
826
|
+
if (['min', 'max', 'coalesce', 'if'].includes(fn)) return node.args[0] ? inferType(node.args[0]) : 'String'
|
|
827
|
+
if (['touint32', 'touint64', 'toint32', 'toint64', 'intdiv', 'floor', 'length'].includes(fn)) return 'UInt64'
|
|
828
|
+
if (fn.startsWith('tostartof') || fn === 'todatetime' || fn === 'now') return 'DateTime'
|
|
829
|
+
if (['lower', 'upper', 'tostring'].includes(fn)) return 'String'
|
|
830
|
+
return 'Float64'
|
|
831
|
+
}
|
|
832
|
+
default:
|
|
833
|
+
return 'String'
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// ---------------------------------------------------------------------------
|
|
838
|
+
// Public: translate + run
|
|
839
|
+
// ---------------------------------------------------------------------------
|
|
840
|
+
|
|
841
|
+
/** A quantile that's collected per-group via `group_concat` and computed in JS. */
|
|
842
|
+
interface QuantileSpec {
|
|
843
|
+
/** Internal SQLite column holding the comma-joined values for the group. */
|
|
844
|
+
tempCol: string
|
|
845
|
+
/** Output column name. */
|
|
846
|
+
outputName: string
|
|
847
|
+
/** Quantile level in [0, 1]. */
|
|
848
|
+
level: number
|
|
849
|
+
/**
|
|
850
|
+
* `quantile`/`quantileWeighted` interpolate linearly (ClickHouse default);
|
|
851
|
+
* the `*Exact*` variants return an actual element (nearest-rank).
|
|
852
|
+
*/
|
|
853
|
+
interpolate: boolean
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Post-processing applied in JS when the query uses quantile functions.
|
|
858
|
+
* `bun:sqlite` has no UDFs and no percentile aggregate, so we collect the
|
|
859
|
+
* per-group values with `group_concat` and finish the computation here. ORDER BY
|
|
860
|
+
* / LIMIT then also have to run in JS (they may reference a computed quantile).
|
|
861
|
+
*/
|
|
862
|
+
interface PostProcess {
|
|
863
|
+
quantiles: QuantileSpec[]
|
|
864
|
+
orderBy: { name: string; desc: boolean }[]
|
|
865
|
+
limit?: number
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
export interface TranslatedQuery {
|
|
869
|
+
sqlite: string
|
|
870
|
+
columns: { name: string; type: string }[]
|
|
871
|
+
format: string
|
|
872
|
+
/** `SELECT *` — column metadata is derived from the result rows at run time. */
|
|
873
|
+
hasStar?: boolean
|
|
874
|
+
/** Present only when the query uses quantile functions. */
|
|
875
|
+
postProcess?: PostProcess
|
|
876
|
+
/** The query uses LIKE — run it with `PRAGMA case_sensitive_like=ON` (ClickHouse semantics). */
|
|
877
|
+
caseSensitiveLike?: boolean
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** Build `meta` from result-row keys (used for `SELECT *`, where columns aren't known up front). */
|
|
881
|
+
function deriveMetaFromRows(rows: Record<string, unknown>[]): { name: string; type: string }[] {
|
|
882
|
+
const keys = rows.length ? Object.keys(rows[0]!) : []
|
|
883
|
+
return keys.map(k => ({ name: k, type: colType(k) }))
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Render an epoch-seconds value as a ClickHouse `DateTime` string
|
|
888
|
+
* (`YYYY-MM-DD HH:MM:SS`, UTC) — the form the real Analytics Engine SQL API
|
|
889
|
+
* returns. Fractional seconds are truncated (ClickHouse `DateTime` is
|
|
890
|
+
* second-resolution).
|
|
891
|
+
*/
|
|
892
|
+
function formatDateTime(epochSeconds: number): string {
|
|
893
|
+
const d = new Date(Math.floor(epochSeconds) * 1000)
|
|
894
|
+
const p = (n: number) => String(n).padStart(2, '0')
|
|
895
|
+
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Convert `DateTime` output columns from their internal numeric form to ClickHouse
|
|
900
|
+
* datetime strings, in place — the final projection step so prod and local agree
|
|
901
|
+
* on the result shape. Non-`*` queries canonicalise timestamps to **seconds** in
|
|
902
|
+
* the emitted SQL; `SELECT *` returns the raw `timestamp` column, stored as **ms**.
|
|
903
|
+
* NULL DateTimes (e.g. `max(timestamp)` over an empty dataset) are left untouched.
|
|
904
|
+
*/
|
|
905
|
+
function formatDateTimeColumns(data: Record<string, unknown>[], t: TranslatedQuery): void {
|
|
906
|
+
if (t.hasStar) {
|
|
907
|
+
for (const row of data) {
|
|
908
|
+
const v = row.timestamp
|
|
909
|
+
if (typeof v === 'number') row.timestamp = formatDateTime(v / 1000)
|
|
910
|
+
}
|
|
911
|
+
return
|
|
912
|
+
}
|
|
913
|
+
const dtCols = t.columns.filter(c => c.type === 'DateTime').map(c => c.name)
|
|
914
|
+
for (const row of data) {
|
|
915
|
+
for (const name of dtCols) {
|
|
916
|
+
const v = row[name]
|
|
917
|
+
if (typeof v === 'number') row[name] = formatDateTime(v)
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function isQuantile(node: Node): node is Extract<Node, { kind: 'func' }> {
|
|
923
|
+
return node.kind === 'func' && QUANTILE_FNS.has(node.name.toLowerCase())
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function quantileLevel(node: Extract<Node, { kind: 'func' }>): number {
|
|
927
|
+
if (!node.params || node.params.length !== 1 || node.params[0]!.kind !== 'num') {
|
|
928
|
+
throw new SqlError(`${node.name} requires a single numeric level, e.g. ${node.name}(0.95)(double1)`)
|
|
929
|
+
}
|
|
930
|
+
const level = (node.params[0] as { value: number }).value
|
|
931
|
+
if (level < 0 || level > 1) throw new SqlError(`${node.name} level must be between 0 and 1`)
|
|
932
|
+
if (node.args.length < 1) throw new SqlError(`${node.name} requires a value argument`)
|
|
933
|
+
return level
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/** Translate a Cloudflare Analytics Engine SQL string into a SQLite query. */
|
|
937
|
+
export function translateAnalyticsEngineSql(sql: string, nowSeconds: number): TranslatedQuery {
|
|
938
|
+
const parsed = new Parser(tokenize(sql), sql).parse()
|
|
939
|
+
const ctx: EmitCtx = { nowSeconds, aliases: new Set(), usesLike: false }
|
|
940
|
+
const hasQuantiles = parsed.select.some(item => isQuantile(item.expr))
|
|
941
|
+
const hasStar = parsed.select.some(item => item.expr.kind === 'star')
|
|
942
|
+
if (hasStar && hasQuantiles) throw new SqlError('SELECT * cannot be combined with quantile functions')
|
|
943
|
+
// Aggregates aren't valid here; reject with a clear message instead of letting
|
|
944
|
+
// SQLite raise an opaque "misuse of aggregate" error.
|
|
945
|
+
if (parsed.where && containsPlainAggregate(parsed.where)) {
|
|
946
|
+
throw new SqlError('Aggregate functions are not allowed in the WHERE clause')
|
|
947
|
+
}
|
|
948
|
+
if (parsed.groupBy.some(containsPlainAggregate)) {
|
|
949
|
+
throw new SqlError('Aggregate functions are not allowed in GROUP BY')
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// SELECT + WHERE are emitted before aliases become referenceable (SQLite, like
|
|
953
|
+
// most engines, does not allow output aliases in WHERE).
|
|
954
|
+
const quantiles: QuantileSpec[] = []
|
|
955
|
+
const selectParts = parsed.select.map((item, i) => {
|
|
956
|
+
// `*` is emitted bare — SQLite rejects an alias on a wildcard.
|
|
957
|
+
if (item.expr.kind === 'star') return '*'
|
|
958
|
+
if (isQuantile(item.expr)) {
|
|
959
|
+
const level = quantileLevel(item.expr)
|
|
960
|
+
const tempCol = `__q${i}`
|
|
961
|
+
// The `*Exact*` variants return a real element; plain `quantile`/`quantileWeighted` interpolate.
|
|
962
|
+
const interpolate = !item.expr.name.toLowerCase().includes('exact')
|
|
963
|
+
quantiles.push({ tempCol, outputName: item.name, level, interpolate })
|
|
964
|
+
// Collect the (locally unweighted) values for this group; computed in JS.
|
|
965
|
+
return `group_concat(${emit(item.expr.args[0]!, ctx)}) AS ${JSON.stringify(tempCol)}`
|
|
966
|
+
}
|
|
967
|
+
return `${emit(item.expr, ctx)} AS ${JSON.stringify(item.name)}`
|
|
968
|
+
})
|
|
969
|
+
const columns = parsed.select
|
|
970
|
+
.filter(item => item.expr.kind !== 'star')
|
|
971
|
+
.map(item => ({
|
|
972
|
+
name: item.name,
|
|
973
|
+
type: isQuantile(item.expr) ? 'Float64' : inferType(item.expr),
|
|
974
|
+
}))
|
|
975
|
+
|
|
976
|
+
let sqlite = `SELECT ${selectParts.join(', ')} FROM analytics_engine`
|
|
977
|
+
|
|
978
|
+
const conditions = [`dataset = ${quoteStr(parsed.dataset)}`]
|
|
979
|
+
if (parsed.where) conditions.push(emit(parsed.where, ctx))
|
|
980
|
+
sqlite += ` WHERE ${conditions.join(' AND ')}`
|
|
981
|
+
|
|
982
|
+
// GROUP BY / ORDER BY may reference SELECT aliases.
|
|
983
|
+
for (const item of parsed.select) ctx.aliases.add(item.name)
|
|
984
|
+
if (parsed.groupBy.length) sqlite += ` GROUP BY ${parsed.groupBy.map(g => emit(g, ctx)).join(', ')}`
|
|
985
|
+
|
|
986
|
+
if (!hasQuantiles) {
|
|
987
|
+
if (parsed.orderBy.length) {
|
|
988
|
+
sqlite += ` ORDER BY ${parsed.orderBy.map(o => `${emit(o.expr, ctx)} ${o.desc ? 'DESC' : 'ASC'}`).join(', ')}`
|
|
989
|
+
}
|
|
990
|
+
if (parsed.limit !== undefined) sqlite += ` LIMIT ${parsed.limit}`
|
|
991
|
+
return { sqlite, columns, format: parsed.format, hasStar, caseSensitiveLike: ctx.usesLike }
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Quantile path: ORDER BY / LIMIT run in JS after the quantiles are computed.
|
|
995
|
+
const outputNames = new Set(columns.map(c => c.name))
|
|
996
|
+
const orderBy = parsed.orderBy.map(o => {
|
|
997
|
+
if (o.expr.kind !== 'col' || !outputNames.has(o.expr.name)) {
|
|
998
|
+
throw new SqlError('ORDER BY must reference a selected column when using quantile functions')
|
|
999
|
+
}
|
|
1000
|
+
return { name: o.expr.name, desc: o.desc }
|
|
1001
|
+
})
|
|
1002
|
+
return { sqlite, columns, format: parsed.format, caseSensitiveLike: ctx.usesLike, postProcess: { quantiles, orderBy, limit: parsed.limit } }
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Compute a quantile from collected values.
|
|
1007
|
+
* - `interpolate` (plain `quantile`/`quantileWeighted`): linear interpolation at
|
|
1008
|
+
* position `level * (n-1)` (the "type 7" definition used by ClickHouse `quantile`,
|
|
1009
|
+
* NumPy, and Excel PERCENTILE.INC).
|
|
1010
|
+
* - otherwise (`*Exact*` variants): nearest-rank — returns an actual element.
|
|
1011
|
+
*/
|
|
1012
|
+
function computeQuantile(values: number[], level: number, interpolate: boolean): number | null {
|
|
1013
|
+
if (values.length === 0) return null
|
|
1014
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
1015
|
+
const n = sorted.length
|
|
1016
|
+
if (!interpolate) {
|
|
1017
|
+
const idx = Math.round(level * (n - 1))
|
|
1018
|
+
return sorted[Math.min(Math.max(idx, 0), n - 1)]!
|
|
1019
|
+
}
|
|
1020
|
+
const pos = level * (n - 1)
|
|
1021
|
+
const lo = Math.floor(pos)
|
|
1022
|
+
const hi = Math.ceil(pos)
|
|
1023
|
+
if (lo === hi) return sorted[lo]!
|
|
1024
|
+
return sorted[lo]! + (pos - lo) * (sorted[hi]! - sorted[lo]!)
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function parseConcatNumbers(value: unknown): number[] {
|
|
1028
|
+
if (value == null) return []
|
|
1029
|
+
return String(value)
|
|
1030
|
+
.split(',')
|
|
1031
|
+
.map(Number)
|
|
1032
|
+
.filter(n => !Number.isNaN(n))
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function applyPostProcess(
|
|
1036
|
+
rows: Record<string, unknown>[],
|
|
1037
|
+
pp: PostProcess,
|
|
1038
|
+
columns: { name: string }[],
|
|
1039
|
+
): Record<string, unknown>[] {
|
|
1040
|
+
// Compute each quantile from its group_concat'd values, then reproject rows in
|
|
1041
|
+
// declared column order (so output keys match `meta`).
|
|
1042
|
+
let out = rows.map(row => {
|
|
1043
|
+
for (const q of pp.quantiles) {
|
|
1044
|
+
row[q.outputName] = computeQuantile(parseConcatNumbers(row[q.tempCol]), q.level, q.interpolate)
|
|
1045
|
+
}
|
|
1046
|
+
const projected: Record<string, unknown> = {}
|
|
1047
|
+
for (const c of columns) projected[c.name] = row[c.name]
|
|
1048
|
+
return projected
|
|
1049
|
+
})
|
|
1050
|
+
|
|
1051
|
+
if (pp.orderBy.length) {
|
|
1052
|
+
out = out.sort((a, b) => {
|
|
1053
|
+
for (const { name, desc } of pp.orderBy) {
|
|
1054
|
+
const av = a[name]
|
|
1055
|
+
const bv = b[name]
|
|
1056
|
+
let cmp: number
|
|
1057
|
+
if (typeof av === 'number' && typeof bv === 'number') cmp = av - bv
|
|
1058
|
+
else cmp = String(av).localeCompare(String(bv))
|
|
1059
|
+
if (cmp !== 0) return desc ? -cmp : cmp
|
|
1060
|
+
}
|
|
1061
|
+
return 0
|
|
1062
|
+
})
|
|
1063
|
+
}
|
|
1064
|
+
if (pp.limit !== undefined) out = out.slice(0, pp.limit)
|
|
1065
|
+
return out
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
export interface AnalyticsEngineSqlResult {
|
|
1069
|
+
meta: { name: string; type: string }[]
|
|
1070
|
+
data: Record<string, unknown>[]
|
|
1071
|
+
rows: number
|
|
1072
|
+
rows_before_limit_at_least: number
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Run a translated query, forcing case-sensitive LIKE when needed. The
|
|
1077
|
+
* `case_sensitive_like` pragma is connection-global, but the query runs
|
|
1078
|
+
* synchronously between toggling it on and off, so no other binding sharing the
|
|
1079
|
+
* connection can observe the flip.
|
|
1080
|
+
*/
|
|
1081
|
+
function executeTranslated(db: Database, t: TranslatedQuery): Record<string, unknown>[] {
|
|
1082
|
+
if (!t.caseSensitiveLike) return db.query(t.sqlite).all() as Record<string, unknown>[]
|
|
1083
|
+
db.run('PRAGMA case_sensitive_like=ON')
|
|
1084
|
+
try {
|
|
1085
|
+
return db.query(t.sqlite).all() as Record<string, unknown>[]
|
|
1086
|
+
} finally {
|
|
1087
|
+
db.run('PRAGMA case_sensitive_like=OFF')
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/** Translate + execute a Cloudflare Analytics Engine SQL query against SQLite. */
|
|
1092
|
+
export function runAnalyticsEngineSql(db: Database, sql: string, nowMs: number = Date.now()): AnalyticsEngineSqlResult {
|
|
1093
|
+
const translated = translateAnalyticsEngineSql(sql, Math.floor(nowMs / 1000))
|
|
1094
|
+
let data = executeTranslated(db, translated)
|
|
1095
|
+
if (translated.postProcess) data = applyPostProcess(data, translated.postProcess, translated.columns)
|
|
1096
|
+
formatDateTimeColumns(data, translated)
|
|
1097
|
+
const meta = translated.hasStar ? deriveMetaFromRows(data) : translated.columns
|
|
1098
|
+
return {
|
|
1099
|
+
meta,
|
|
1100
|
+
data,
|
|
1101
|
+
rows: data.length,
|
|
1102
|
+
rows_before_limit_at_least: data.length,
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Run an Analytics Engine SQL string and build a Cloudflare-shaped `Response`
|
|
1108
|
+
* (JSON by default, NDJSON for `FORMAT JSONEachRow`, or a 400 with the error
|
|
1109
|
+
* message). Synchronous so callers can wrap it in their own tracing span.
|
|
1110
|
+
*/
|
|
1111
|
+
export function buildAnalyticsEngineSqlResponse(db: Database, sql: string): Response {
|
|
1112
|
+
const trimmed = sql.trim()
|
|
1113
|
+
if (!trimmed) return new Response('Empty query', { status: 400 })
|
|
1114
|
+
try {
|
|
1115
|
+
const translated = translateAnalyticsEngineSql(trimmed, Math.floor(Date.now() / 1000))
|
|
1116
|
+
let data = executeTranslated(db, translated)
|
|
1117
|
+
if (translated.postProcess) data = applyPostProcess(data, translated.postProcess, translated.columns)
|
|
1118
|
+
formatDateTimeColumns(data, translated)
|
|
1119
|
+
if (translated.format === 'JSONEACHROW') {
|
|
1120
|
+
const body = data.map(row => JSON.stringify(row)).join('\n')
|
|
1121
|
+
return new Response(body, { headers: { 'content-type': 'application/x-ndjson' } })
|
|
1122
|
+
}
|
|
1123
|
+
const meta = translated.hasStar ? deriveMetaFromRows(data) : translated.columns
|
|
1124
|
+
const result: AnalyticsEngineSqlResult = { meta, data, rows: data.length, rows_before_limit_at_least: data.length }
|
|
1125
|
+
return new Response(JSON.stringify(result), { headers: { 'content-type': 'application/json' } })
|
|
1126
|
+
} catch (err) {
|
|
1127
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
1128
|
+
return new Response(message, { status: 400 })
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Handle an intercepted POST to the Analytics Engine SQL API. Returns a
|
|
1134
|
+
* Cloudflare-shaped JSON `Response` (or a 400 with the error message).
|
|
1135
|
+
*/
|
|
1136
|
+
export async function handleAnalyticsEngineSqlRequest(db: Database, request: Request): Promise<Response> {
|
|
1137
|
+
return buildAnalyticsEngineSqlResponse(db, await request.text())
|
|
1138
|
+
}
|