@vaultgradient/pipequery-cli 0.7.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -13
- package/__tests__/clickhouse-pushdown-compile.test.ts +121 -0
- package/__tests__/event-log.test.ts +267 -0
- package/__tests__/mongo-pushdown-compile.test.ts +160 -0
- package/__tests__/mysql-pushdown-compile.test.ts +122 -0
- package/__tests__/mysql-pushdown.smoke.test.ts +77 -0
- package/__tests__/postgres-pushdown-compile.test.ts +240 -0
- package/__tests__/postgres-pushdown.smoke.test.ts +158 -0
- package/__tests__/pushdown-routing.test.ts +144 -0
- package/__tests__/snowflake-pushdown-compile.test.ts +74 -0
- package/dist/index.js +3642 -2172
- package/package.json +11 -2
- package/src/commands/telegram.ts +10 -0
- package/src/config/schema.ts +68 -1
- package/src/index.ts +2 -1
- package/src/mcp/provider.ts +4 -7
- package/src/server/routes/control.ts +1 -3
- package/src/server/routes/dynamic.ts +2 -4
- package/src/server/sources/clickhouse.ts +153 -0
- package/src/server/sources/manager.ts +71 -1
- package/src/server/sources/mongodb.ts +165 -0
- package/src/server/sources/mysql.ts +44 -0
- package/src/server/sources/postgres.ts +50 -0
- package/src/server/sources/pushdown/clickhouse.ts +17 -0
- package/src/server/sources/pushdown/compile.ts +328 -0
- package/src/server/sources/pushdown/dialect.ts +88 -0
- package/src/server/sources/pushdown/mongodb.ts +450 -0
- package/src/server/sources/pushdown/mysql.ts +17 -0
- package/src/server/sources/pushdown/postgres.ts +19 -0
- package/src/server/sources/pushdown/snowflake.ts +17 -0
- package/src/server/sources/snowflake.ts +158 -0
- package/src/server/sources/types.ts +27 -0
- package/src/telegram/bot.ts +198 -12
- package/src/telegram/event-log.ts +234 -0
|
@@ -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
|
+
}
|