@vaultgradient/pipequery-cli 0.7.0 → 0.10.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.
Files changed (35) hide show
  1. package/README.md +98 -13
  2. package/__tests__/clickhouse-pushdown-compile.test.ts +121 -0
  3. package/__tests__/event-log.test.ts +267 -0
  4. package/__tests__/mongo-pushdown-compile.test.ts +160 -0
  5. package/__tests__/mysql-pushdown-compile.test.ts +122 -0
  6. package/__tests__/mysql-pushdown.smoke.test.ts +77 -0
  7. package/__tests__/postgres-pushdown-compile.test.ts +240 -0
  8. package/__tests__/postgres-pushdown.smoke.test.ts +158 -0
  9. package/__tests__/pushdown-routing.test.ts +144 -0
  10. package/__tests__/snowflake-pushdown-compile.test.ts +74 -0
  11. package/dist/index.js +3642 -2172
  12. package/package.json +12 -2
  13. package/src/commands/telegram.ts +10 -0
  14. package/src/config/schema.ts +68 -1
  15. package/src/index.ts +2 -1
  16. package/src/mcp/provider.ts +4 -7
  17. package/src/server/routes/control.ts +1 -3
  18. package/src/server/routes/dynamic.ts +2 -4
  19. package/src/server/sources/clickhouse.ts +153 -0
  20. package/src/server/sources/manager.ts +71 -1
  21. package/src/server/sources/mongodb.ts +165 -0
  22. package/src/server/sources/mysql.ts +44 -0
  23. package/src/server/sources/postgres.ts +50 -0
  24. package/src/server/sources/pushdown/clickhouse.ts +17 -0
  25. package/src/server/sources/pushdown/compile.ts +328 -0
  26. package/src/server/sources/pushdown/dialect.ts +88 -0
  27. package/src/server/sources/pushdown/mongodb.ts +450 -0
  28. package/src/server/sources/pushdown/mysql.ts +17 -0
  29. package/src/server/sources/pushdown/postgres.ts +19 -0
  30. package/src/server/sources/pushdown/snowflake.ts +17 -0
  31. package/src/server/sources/snowflake.ts +158 -0
  32. package/src/server/sources/types.ts +27 -0
  33. package/src/telegram/bot.ts +198 -12
  34. package/src/telegram/event-log.ts +234 -0
  35. package/tsup.config.ts +1 -1
@@ -2,6 +2,8 @@ import mysql from 'mysql2/promise';
2
2
  import type { MysqlSourceConfig } from '../../config/schema.js';
3
3
  import { parseDuration } from '../../utils/parseDuration.js';
4
4
  import { expandEnv } from '../../utils/expandEnv.js';
5
+ import { parseQuery } from '../../engine.js';
6
+ import { compileMysqlPushdown } from './pushdown/mysql.js';
5
7
  import type { SourceAdapter, SourceStatus } from './types.js';
6
8
 
7
9
  const DEFAULT_INTERVAL_MS = 30_000;
@@ -80,6 +82,48 @@ export class MysqlSourceAdapter implements SourceAdapter {
80
82
  await this.fetch();
81
83
  }
82
84
 
85
+ /**
86
+ * Adapter-native push-down. Compiles the pipe expression to MySQL SQL
87
+ * (see ./pushdown/mysql.ts) and runs it directly. SourceManager.runQuery
88
+ * dispatches here automatically when the pipeline shape is eligible —
89
+ * unsupported shapes return `{ ok: false, reason }` and the caller falls
90
+ * back to the in-process engine.
91
+ *
92
+ * Mirrors PostgresSourceAdapter.runPushdown — same shape, same result
93
+ * type. Both adapters route through the dialect-parametric compiler in
94
+ * compile.ts.
95
+ */
96
+ async runPushdown(expression: string): Promise<
97
+ | { ok: true; rows: unknown[]; sql: string; params: unknown[] }
98
+ | { ok: false; reason: string }
99
+ > {
100
+ if (!this.pool) {
101
+ return { ok: false, reason: 'MySQL pool not initialized — call start() first' };
102
+ }
103
+ let pipeline;
104
+ try {
105
+ pipeline = parseQuery(expression);
106
+ } catch (err) {
107
+ return { ok: false, reason: `parse error: ${err instanceof Error ? err.message : String(err)}` };
108
+ }
109
+ const compiled = compileMysqlPushdown(pipeline, this.config.query);
110
+ if (!compiled.ok) {
111
+ return { ok: false, reason: compiled.reason };
112
+ }
113
+ try {
114
+ const [rows] = await this.pool.query(compiled.compiled.sql, compiled.compiled.params);
115
+ const asArray = Array.isArray(rows) ? (rows as unknown[]) : [];
116
+ return {
117
+ ok: true,
118
+ rows: asArray,
119
+ sql: compiled.compiled.sql,
120
+ params: compiled.compiled.params,
121
+ };
122
+ } catch (err) {
123
+ return { ok: false, reason: `mysql error: ${err instanceof Error ? err.message : String(err)}` };
124
+ }
125
+ }
126
+
83
127
  private async fetch(): Promise<void> {
84
128
  if (!this.pool) {
85
129
  this.error = 'MySQL pool not initialized';
@@ -3,6 +3,8 @@ import type { PostgresSourceConfig } from '../../config/schema.js';
3
3
  import { parseDuration } from '../../utils/parseDuration.js';
4
4
  import { expandEnv } from '../../utils/expandEnv.js';
5
5
  import type { SourceAdapter, SourceStatus } from './types.js';
6
+ import { parseQuery } from '../../engine.js';
7
+ import { compilePostgresPushdown } from './pushdown/postgres.js';
6
8
 
7
9
  const DEFAULT_INTERVAL_MS = 30_000;
8
10
  const DEFAULT_MAX_ROWS = 10_000;
@@ -86,6 +88,54 @@ export class PostgresSourceAdapter implements SourceAdapter {
86
88
  await this.fetch();
87
89
  }
88
90
 
91
+ /**
92
+ * Push-down execution prototype. Compiles a pipequery expression rooted at
93
+ * this source into a single Postgres query (where / sort / first only in
94
+ * v1) and runs it directly against the database — no in-memory
95
+ * materialization of the user's source query, no polling, no ring buffer.
96
+ *
97
+ * Returns:
98
+ * - `{ ok: true, rows, sql, params, fullyPushed: true }` on a clean push-down
99
+ * - `{ ok: false, reason }` when the AST contains operators we don't yet
100
+ * translate. Caller should fall back to the in-process engine on the
101
+ * ring-buffered data instead.
102
+ *
103
+ * This is an opt-in API; the default getData() / fetch() polling loop is
104
+ * unchanged so existing users see no behaviour change.
105
+ */
106
+ async runPushdown(expression: string): Promise<
107
+ | { ok: true; rows: unknown[]; sql: string; params: unknown[]; fullyPushed: true }
108
+ | { ok: false; reason: string }
109
+ > {
110
+ if (!this.pool) {
111
+ return { ok: false, reason: 'Postgres pool not initialized — call start() first' };
112
+ }
113
+ let pipeline;
114
+ try {
115
+ pipeline = parseQuery(expression);
116
+ } catch (err) {
117
+ return { ok: false, reason: `parse error: ${err instanceof Error ? err.message : String(err)}` };
118
+ }
119
+
120
+ const compiled = compilePostgresPushdown(pipeline, this.config.query);
121
+ if (!compiled.ok) {
122
+ return { ok: false, reason: compiled.reason };
123
+ }
124
+
125
+ try {
126
+ const res = await this.pool.query(compiled.compiled.sql, compiled.compiled.params);
127
+ return {
128
+ ok: true,
129
+ rows: res.rows,
130
+ sql: compiled.compiled.sql,
131
+ params: compiled.compiled.params,
132
+ fullyPushed: true,
133
+ };
134
+ } catch (err) {
135
+ return { ok: false, reason: `postgres error: ${err instanceof Error ? err.message : String(err)}` };
136
+ }
137
+ }
138
+
89
139
  private async fetch(): Promise<void> {
90
140
  if (!this.pool) {
91
141
  this.error = 'Postgres pool not initialized';
@@ -0,0 +1,17 @@
1
+ // Pipe-AST → ClickHouse SQL compiler.
2
+ //
3
+ // Thin shim over the dialect-parametric compileForDialect; same operator +
4
+ // aggregate set as Postgres / MySQL / Snowflake. ClickHouse uses
5
+ // backtick-quoted identifiers and `?` positional placeholders — both
6
+ // captured in CLICKHOUSE_DIALECT.
7
+
8
+ import type { Pipeline } from '../../../../../src/engine/types.js';
9
+ import { compileForDialect, type CompileResult } from './compile.js';
10
+ import { CLICKHOUSE_DIALECT } from './dialect.js';
11
+
12
+ export function compileClickhousePushdown(
13
+ pipeline: Pipeline,
14
+ baseQuery: string,
15
+ ): CompileResult {
16
+ return compileForDialect(pipeline, baseQuery, CLICKHOUSE_DIALECT);
17
+ }
@@ -0,0 +1,328 @@
1
+ // Pipe-AST → SQL compiler (dialect-parametric).
2
+ //
3
+ // Translates a Pipeline AST whose source is a single SQL-backed source into
4
+ // a parameterized SQL query. Every emission goes through a SqlDialect, so
5
+ // adding a new engine reduces to writing a dialect (see dialect.ts).
6
+ //
7
+ // Supported operators:
8
+ // - WhereOp with BinaryExpr / UnaryExpr / FieldAccess / literals
9
+ // - SortOp with FieldAccess criteria
10
+ // - FirstOp → LIMIT N
11
+ // - SelectOp projection (FieldAccess / arithmetic / AliasExpr)
12
+ // - DistinctOp full-row dedup (no DISTINCT ON; portable across dialects)
13
+ // - RollupOp → GROUP BY + aggregates in projection
14
+ // - AggregateOp pipeline-terminal aggregate (e.g. `orders | sum(total)`)
15
+ //
16
+ // Supported aggregate functions: sum, avg, min, max, count, distinct_count.
17
+ // Anything else (median, stddev, percentile, vwap, sharpe, …) declines so
18
+ // the in-process engine handles them — those have no clean portable SQL
19
+ // translation across the dialects we care about.
20
+ //
21
+ // Operator-order rules:
22
+ // - WhereOp must precede grouping (RollupOp / AggregateOp). After
23
+ // grouping, "where" is HAVING — declined for now.
24
+ // - SortOp / FirstOp accepted at any position; the SQL clauses are always
25
+ // emitted at the end so they apply post-grouping when grouping exists.
26
+ // - SelectOp / DistinctOp must come before grouping; grouping owns its
27
+ // own projection.
28
+ // - At most one grouping op (RollupOp xor AggregateOp).
29
+
30
+ import type {
31
+ AggregateFnName,
32
+ Expression,
33
+ Operation,
34
+ Pipeline,
35
+ } from '../../../../../src/engine/types.js';
36
+ import type { SqlDialect } from './dialect.js';
37
+
38
+ export interface CompiledPushdown {
39
+ /** Parameterized SQL using the dialect's placeholder syntax. */
40
+ sql: string;
41
+ /** Values to bind, in order. */
42
+ params: unknown[];
43
+ }
44
+
45
+ export type CompileResult =
46
+ | { ok: true; compiled: CompiledPushdown }
47
+ | { ok: false; reason: string };
48
+
49
+ interface CompilerCtx {
50
+ /** Subquery wrapping the user-provided source query. */
51
+ baseSelect: string;
52
+ /** Running parameter array; mutated as we walk. */
53
+ params: unknown[];
54
+ /** Dialect for quoting + placeholder rendering. */
55
+ dialect: SqlDialect;
56
+ }
57
+
58
+ const BINARY_OP_SQL: Record<string, string | undefined> = {
59
+ '>': '>',
60
+ '>=': '>=',
61
+ '<': '<',
62
+ '<=': '<=',
63
+ '==': '=',
64
+ '!=': '<>',
65
+ '&&': 'AND',
66
+ '||': 'OR',
67
+ '+': '+',
68
+ '-': '-',
69
+ '*': '*',
70
+ '/': '/',
71
+ '%': '%',
72
+ };
73
+
74
+ /**
75
+ * Aggregate functions we know how to translate to portable SQL. Both
76
+ * Postgres and MySQL accept these identically — no per-dialect overrides
77
+ * needed yet. The function takes the rendered argument SQL (or `null` for
78
+ * the no-arg `count()` shape) and produces the call site.
79
+ */
80
+ const SQL_AGGREGATE_FN: Partial<Record<AggregateFnName, (arg: string | null) => string>> = {
81
+ sum: (arg) => `SUM(${arg ?? '*'})`,
82
+ avg: (arg) => `AVG(${arg ?? '*'})`,
83
+ min: (arg) => `MIN(${arg ?? '*'})`,
84
+ max: (arg) => `MAX(${arg ?? '*'})`,
85
+ count: (arg) => `COUNT(${arg ?? '*'})`,
86
+ distinct_count: (arg) => `COUNT(DISTINCT ${arg ?? '*'})`,
87
+ };
88
+
89
+ export function compileForDialect(
90
+ pipeline: Pipeline,
91
+ baseQuery: string,
92
+ dialect: SqlDialect,
93
+ ): CompileResult {
94
+ const ctx: CompilerCtx = {
95
+ baseSelect: stripTrailingSemicolon(baseQuery),
96
+ params: [],
97
+ dialect,
98
+ };
99
+
100
+ let where: string | null = null;
101
+ let orderBy: string | null = null;
102
+ let limit: number | null = null;
103
+ let selectClause: string | null = null;
104
+ let distinctClause = false;
105
+ let groupByClause: string | null = null;
106
+ let grouped = false;
107
+
108
+ for (const op of pipeline.operations) {
109
+ switch (op.kind) {
110
+ case 'WhereOp': {
111
+ if (grouped) {
112
+ return { ok: false, reason: 'where after grouping (HAVING) is not yet pushable' };
113
+ }
114
+ const compiled = compileExpr(op.condition, ctx);
115
+ if (compiled === null) {
116
+ return { ok: false, reason: 'where(): unsupported expression node' };
117
+ }
118
+ where = where ? `(${where}) AND (${compiled})` : compiled;
119
+ break;
120
+ }
121
+
122
+ case 'SortOp': {
123
+ const parts: string[] = [];
124
+ for (const c of op.criteria) {
125
+ if (c.expression.kind !== 'FieldAccess' || c.expression.path.length !== 1) {
126
+ return { ok: false, reason: 'sort(): only single-field paths supported' };
127
+ }
128
+ const ident = ctx.dialect.quoteIdent(c.expression.path[0]);
129
+ parts.push(`${ident} ${c.direction === 'desc' ? 'DESC' : 'ASC'}`);
130
+ }
131
+ orderBy = parts.join(', ');
132
+ break;
133
+ }
134
+
135
+ case 'FirstOp': {
136
+ limit = op.count;
137
+ break;
138
+ }
139
+
140
+ case 'SelectOp': {
141
+ if (grouped) {
142
+ return { ok: false, reason: 'select after rollup is not yet pushable' };
143
+ }
144
+ if (selectClause !== null) {
145
+ return { ok: false, reason: 'multiple select() ops in one pipeline' };
146
+ }
147
+ const parts: string[] = [];
148
+ for (const f of op.fields) {
149
+ const compiled = compileSelectField(f, ctx, false);
150
+ if (compiled === null) {
151
+ return { ok: false, reason: 'select(): unsupported field expression' };
152
+ }
153
+ parts.push(compiled);
154
+ }
155
+ selectClause = parts.join(', ');
156
+ break;
157
+ }
158
+
159
+ case 'DistinctOp': {
160
+ if (op.fields !== undefined && op.fields.length > 0) {
161
+ return { ok: false, reason: 'distinct(field) is not yet pushable; full-row distinct works' };
162
+ }
163
+ if (grouped) {
164
+ return { ok: false, reason: 'distinct after grouping is not yet pushable' };
165
+ }
166
+ distinctClause = true;
167
+ break;
168
+ }
169
+
170
+ case 'RollupOp': {
171
+ if (grouped) {
172
+ return { ok: false, reason: 'multiple grouping ops (rollup/aggregate) in one pipeline' };
173
+ }
174
+ if (selectClause !== null) {
175
+ return { ok: false, reason: 'rollup after select is not yet pushable' };
176
+ }
177
+ const keyParts: string[] = [];
178
+ const groupByParts: string[] = [];
179
+ for (const k of op.keys) {
180
+ if (k.kind !== 'FieldAccess' || k.path.length !== 1) {
181
+ return { ok: false, reason: 'rollup(): keys must be single-field paths' };
182
+ }
183
+ const ident = ctx.dialect.quoteIdent(k.path[0]);
184
+ keyParts.push(ident);
185
+ groupByParts.push(ident);
186
+ }
187
+ const aggParts: string[] = [];
188
+ for (const a of op.aggregates) {
189
+ const compiled = compileSelectField(a, ctx, true);
190
+ if (compiled === null) {
191
+ return { ok: false, reason: 'rollup(): unsupported aggregate expression' };
192
+ }
193
+ aggParts.push(compiled);
194
+ }
195
+ selectClause = [...keyParts, ...aggParts].join(', ');
196
+ groupByClause = groupByParts.length > 0 ? groupByParts.join(', ') : null;
197
+ grouped = true;
198
+ break;
199
+ }
200
+
201
+ case 'AggregateOp': {
202
+ if (grouped) {
203
+ return { ok: false, reason: 'multiple grouping ops (rollup/aggregate) in one pipeline' };
204
+ }
205
+ if (selectClause !== null) {
206
+ return { ok: false, reason: 'aggregate after select is not yet pushable' };
207
+ }
208
+ const fn = SQL_AGGREGATE_FN[op.function];
209
+ if (!fn) {
210
+ return { ok: false, reason: `aggregate "${op.function}" has no SQL push-down` };
211
+ }
212
+ if (op.args && op.args.length > 0) {
213
+ return { ok: false, reason: `aggregate "${op.function}" with extra args is not yet pushable` };
214
+ }
215
+ const argSql = op.field === undefined ? null : compileExpr(op.field, ctx);
216
+ if (op.field !== undefined && argSql === null) {
217
+ return { ok: false, reason: `aggregate "${op.function}": unsupported argument expression` };
218
+ }
219
+ selectClause = `${fn(argSql)} AS ${ctx.dialect.quoteIdent(op.function)}`;
220
+ grouped = true;
221
+ break;
222
+ }
223
+
224
+ default:
225
+ return { ok: false, reason: `${(op as Operation).kind}: not yet pushable` };
226
+ }
227
+ }
228
+
229
+ let sql = `SELECT ${distinctClause ? 'DISTINCT ' : ''}${selectClause ?? '*'} FROM (${ctx.baseSelect}) AS pq_src`;
230
+ if (where) sql += ` WHERE ${where}`;
231
+ if (groupByClause) sql += ` GROUP BY ${groupByClause}`;
232
+ if (orderBy) sql += ` ORDER BY ${orderBy}`;
233
+ if (limit !== null) sql += ` LIMIT ${limit}`;
234
+
235
+ return { ok: true, compiled: { sql, params: ctx.params } };
236
+ }
237
+
238
+ // ─── Expression compilers ───────────────────────────────────────────────────
239
+
240
+ function compileSelectField(expr: Expression, ctx: CompilerCtx, aggregateContext: boolean): string | null {
241
+ if (expr.kind === 'AliasExpr') {
242
+ const inner = compileSelectField(expr.expression, ctx, aggregateContext);
243
+ if (inner === null) return null;
244
+ return `${inner} AS ${ctx.dialect.quoteIdent(expr.alias)}`;
245
+ }
246
+ if (expr.kind === 'FunctionCall') {
247
+ if (!aggregateContext) return null;
248
+ return compileAggregateCall(expr.name, expr.args, ctx);
249
+ }
250
+ return compileExpr(expr, ctx);
251
+ }
252
+
253
+ function compileAggregateCall(name: string, args: Expression[], ctx: CompilerCtx): string | null {
254
+ const fn = SQL_AGGREGATE_FN[name as AggregateFnName];
255
+ if (!fn) return null;
256
+ if (args.length === 0) return fn(null);
257
+ if (args.length > 1) return null;
258
+ const argSql = compileExpr(args[0], ctx);
259
+ if (argSql === null) return null;
260
+ return fn(argSql);
261
+ }
262
+
263
+ function compileExpr(expr: Expression, ctx: CompilerCtx): string | null {
264
+ switch (expr.kind) {
265
+ case 'NumberLiteral':
266
+ case 'StringLiteral':
267
+ case 'BooleanLiteral':
268
+ return ctx.dialect.bindLiteral(expr.value, ctx.params);
269
+ case 'NullLiteral':
270
+ return 'NULL';
271
+ case 'FieldAccess': {
272
+ if (expr.path.length !== 1) return null;
273
+ return ctx.dialect.quoteIdent(expr.path[0]);
274
+ }
275
+ case 'BinaryExpr': {
276
+ const opSql = BINARY_OP_SQL[expr.operator];
277
+ if (!opSql) return null;
278
+ const left = compileExpr(expr.left, ctx);
279
+ const right = compileExpr(expr.right, ctx);
280
+ if (left === null || right === null) return null;
281
+
282
+ // x = NULL never matches in either Postgres or MySQL; need
283
+ // IS NULL / IS NOT NULL.
284
+ if (expr.right.kind === 'NullLiteral') {
285
+ if (expr.operator === '==') return `${left} IS NULL`;
286
+ if (expr.operator === '!=') return `${left} IS NOT NULL`;
287
+ }
288
+ if (expr.left.kind === 'NullLiteral') {
289
+ if (expr.operator === '==') return `${right} IS NULL`;
290
+ if (expr.operator === '!=') return `${right} IS NOT NULL`;
291
+ }
292
+
293
+ return `(${left} ${opSql} ${right})`;
294
+ }
295
+ case 'UnaryExpr': {
296
+ const operand = compileExpr(expr.operand, ctx);
297
+ if (operand === null) return null;
298
+ if (expr.operator === '!') return `(NOT ${operand})`;
299
+ if (expr.operator === '-') return `(-${operand})`;
300
+ return null;
301
+ }
302
+ case 'FunctionCall':
303
+ case 'AliasExpr':
304
+ return null;
305
+ }
306
+ }
307
+
308
+ // ─── Helpers ────────────────────────────────────────────────────────────────
309
+
310
+ function stripTrailingSemicolon(sql: string): string {
311
+ return sql.trim().replace(/;+\s*$/, '');
312
+ }
313
+
314
+ const PUSHABLE_OPS: ReadonlySet<Operation['kind']> = new Set([
315
+ 'WhereOp',
316
+ 'SortOp',
317
+ 'FirstOp',
318
+ 'SelectOp',
319
+ 'DistinctOp',
320
+ 'RollupOp',
321
+ 'AggregateOp',
322
+ ]);
323
+
324
+ /** Convenience helper: does the pipeline only contain operators we *might*
325
+ * push down? Doesn't validate expression nodes. */
326
+ export function isPushableShape(pipeline: Pipeline): boolean {
327
+ return pipeline.operations.every((op) => PUSHABLE_OPS.has(op.kind));
328
+ }
@@ -0,0 +1,88 @@
1
+ // SQL dialect abstraction for the pipe-AST → SQL compilers.
2
+ //
3
+ // The compiler in compile.ts is dialect-agnostic — every place that emits
4
+ // SQL goes through one of the methods here. Adding a new engine (Snowflake,
5
+ // ClickHouse, BigQuery, etc.) reduces to: write a dialect, plug it in.
6
+
7
+ export interface SqlDialect {
8
+ /**
9
+ * Quote an identifier (column / alias). Embedded special characters
10
+ * must be escaped per dialect rules so the result can never break out
11
+ * of the quoted span.
12
+ * - Postgres / Snowflake: `"name"`, internal `"` doubled
13
+ * - MySQL / ClickHouse: `` `name` ``, internal `` ` `` doubled
14
+ */
15
+ quoteIdent(name: string): string;
16
+
17
+ /**
18
+ * Embed a literal value in the emitted SQL. Dialects that use parameter
19
+ * binding push the value into `params` and return a placeholder
20
+ * (`$N`, `?`); dialects that don't bind (ClickHouse over HTTP) inline
21
+ * the value as a properly-escaped literal and leave `params` untouched.
22
+ *
23
+ * The compiler doesn't care which path the dialect takes — it just
24
+ * gets back a string to splice into the SQL.
25
+ */
26
+ bindLiteral(value: unknown, params: unknown[]): string;
27
+ }
28
+
29
+ export const POSTGRES_DIALECT: SqlDialect = {
30
+ quoteIdent: (name) => `"${name.replace(/"/g, '""')}"`,
31
+ bindLiteral: (value, params) => {
32
+ params.push(value);
33
+ return `$${params.length}`;
34
+ },
35
+ };
36
+
37
+ export const MYSQL_DIALECT: SqlDialect = {
38
+ quoteIdent: (name) => `\`${name.replace(/`/g, '``')}\``,
39
+ bindLiteral: (value, params) => {
40
+ params.push(value);
41
+ return '?';
42
+ },
43
+ };
44
+
45
+ /**
46
+ * Snowflake speaks ANSI SQL with double-quoted identifiers (case-sensitive)
47
+ * and accepts `?` positional binds.
48
+ */
49
+ export const SNOWFLAKE_DIALECT: SqlDialect = {
50
+ quoteIdent: (name) => `"${name.replace(/"/g, '""')}"`,
51
+ bindLiteral: (value, params) => {
52
+ params.push(value);
53
+ return '?';
54
+ },
55
+ };
56
+
57
+ /**
58
+ * ClickHouse accepts both backticks and double quotes for identifiers; we
59
+ * use backticks for parity with MySQL conventions. The HTTP client uses
60
+ * named `{name:Type}` binds (which require per-param type tagging the
61
+ * dialect interface doesn't carry), so we inline literals with proper
62
+ * escape instead. Safe: literal values come from typed AST nodes
63
+ * (NumberLiteral / StringLiteral / BooleanLiteral / NullLiteral) — never
64
+ * from raw user strings.
65
+ */
66
+ export const CLICKHOUSE_DIALECT: SqlDialect = {
67
+ quoteIdent: (name) => `\`${name.replace(/`/g, '``')}\``,
68
+ bindLiteral: (value) => clickhouseLiteral(value),
69
+ };
70
+
71
+ function clickhouseLiteral(value: unknown): string {
72
+ if (value === null || value === undefined) return 'NULL';
73
+ if (typeof value === 'boolean') return value ? '1' : '0';
74
+ if (typeof value === 'number') {
75
+ if (!Number.isFinite(value)) return 'NULL';
76
+ return String(value);
77
+ }
78
+ if (typeof value === 'string') {
79
+ // Order matters: backslash MUST be doubled first so subsequent
80
+ // inserted backslashes (from quote escaping) don't get re-doubled.
81
+ // ClickHouse accepts literal control chars inside string literals;
82
+ // only backslash and single quote require escape to prevent injection.
83
+ const escaped = value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
84
+ return `'${escaped}'`;
85
+ }
86
+ // Fallback: stringify and escape — covers Date, BigInt, etc. as best-effort.
87
+ return clickhouseLiteral(String(value));
88
+ }