@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.
- 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 +12 -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
- package/tsup.config.ts +1 -1
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
// Pipe-AST → MongoDB query/aggregation compiler.
|
|
2
|
+
//
|
|
3
|
+
// Mongo isn't SQL, so this lives outside the dialect-parametric
|
|
4
|
+
// compileForDialect. The translator emits one of two shapes:
|
|
5
|
+
//
|
|
6
|
+
// 1. A simple `find()` plan when the pipeline has only WhereOp / SortOp /
|
|
7
|
+
// FirstOp / SelectOp. This is the cheap path — Mongo's find indexes
|
|
8
|
+
// can be used directly.
|
|
9
|
+
//
|
|
10
|
+
// 2. An `aggregate()` pipeline when grouping (RollupOp / AggregateOp) is
|
|
11
|
+
// present. We fall back to aggregate even for small adjustments like
|
|
12
|
+
// `select` ahead of grouping because the projection-then-group flow
|
|
13
|
+
// maps cleanly to `$project` + `$group`.
|
|
14
|
+
//
|
|
15
|
+
// Operator set (parity with the SQL compilers where it makes sense):
|
|
16
|
+
// - WhereOp → $match (BinaryExpr, UnaryExpr, FieldAccess, literals)
|
|
17
|
+
// - SortOp → $sort (single-field paths)
|
|
18
|
+
// - FirstOp → $limit / find().limit()
|
|
19
|
+
// - SelectOp → $project / find() projection
|
|
20
|
+
// - DistinctOp no DISTINCT keyword in mongo — full-row dedup is
|
|
21
|
+
// expressed via $group on $$ROOT and $first; declined
|
|
22
|
+
// for v1, in-process handles it
|
|
23
|
+
// - RollupOp → $group + projection
|
|
24
|
+
// - AggregateOp pipeline-terminal aggregate → $group with one accumulator
|
|
25
|
+
//
|
|
26
|
+
// Aggregate functions translated: sum, avg, min, max, count, distinct_count.
|
|
27
|
+
// (Same set as the SQL compilers; everything exotic falls back to in-process.)
|
|
28
|
+
|
|
29
|
+
import type {
|
|
30
|
+
AggregateFnName,
|
|
31
|
+
Expression,
|
|
32
|
+
Operation,
|
|
33
|
+
Pipeline,
|
|
34
|
+
} from '../../../../../src/engine/types.js';
|
|
35
|
+
|
|
36
|
+
export type MongoPlan =
|
|
37
|
+
| { kind: 'find'; filter: Record<string, unknown>; sort?: Record<string, 1 | -1>; limit?: number; projection?: Record<string, 0 | 1 | string | { $expr?: unknown }> }
|
|
38
|
+
| { kind: 'aggregate'; pipeline: Record<string, unknown>[] };
|
|
39
|
+
|
|
40
|
+
export type CompileResult =
|
|
41
|
+
| { ok: true; compiled: MongoPlan }
|
|
42
|
+
| { ok: false; reason: string };
|
|
43
|
+
|
|
44
|
+
// MongoDB comparison operators keyed by pipequery operator.
|
|
45
|
+
const CMP_TO_MONGO: Record<string, string | undefined> = {
|
|
46
|
+
'>': '$gt',
|
|
47
|
+
'>=': '$gte',
|
|
48
|
+
'<': '$lt',
|
|
49
|
+
'<=': '$lte',
|
|
50
|
+
'==': '$eq',
|
|
51
|
+
'!=': '$ne',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Aggregate-function → Mongo $group accumulator.
|
|
55
|
+
const MONGO_AGGREGATE_FN: Partial<Record<AggregateFnName, (arg: string | null) => Record<string, unknown>>> = {
|
|
56
|
+
sum: (arg) => ({ $sum: arg ?? 1 }), // sum(*) → $sum: 1 (count rows)
|
|
57
|
+
avg: (arg) => ({ $avg: arg ?? null }),
|
|
58
|
+
min: (arg) => ({ $min: arg ?? null }),
|
|
59
|
+
max: (arg) => ({ $max: arg ?? null }),
|
|
60
|
+
count: () => ({ $sum: 1 }), // count(*) — Mongo idiom
|
|
61
|
+
distinct_count: (arg) => ({ $addToSet: arg ?? null }), // size taken in $project
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function compileMongoPushdown(
|
|
65
|
+
pipeline: Pipeline,
|
|
66
|
+
defaultFilter: Record<string, unknown> | undefined,
|
|
67
|
+
): CompileResult {
|
|
68
|
+
// Two-pass: first decide whether we need find() or aggregate(), then emit.
|
|
69
|
+
let needsAggregate = false;
|
|
70
|
+
for (const op of pipeline.operations) {
|
|
71
|
+
if (op.kind === 'RollupOp' || op.kind === 'AggregateOp') needsAggregate = true;
|
|
72
|
+
if (op.kind === 'DistinctOp') {
|
|
73
|
+
return { ok: false, reason: 'mongo distinct() is not yet pushable; in-process handles it' };
|
|
74
|
+
}
|
|
75
|
+
if (
|
|
76
|
+
op.kind !== 'WhereOp' &&
|
|
77
|
+
op.kind !== 'SortOp' &&
|
|
78
|
+
op.kind !== 'FirstOp' &&
|
|
79
|
+
op.kind !== 'SelectOp' &&
|
|
80
|
+
op.kind !== 'RollupOp' &&
|
|
81
|
+
op.kind !== 'AggregateOp'
|
|
82
|
+
) {
|
|
83
|
+
return { ok: false, reason: `${(op as Operation).kind}: not yet pushable to mongo` };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return needsAggregate
|
|
88
|
+
? compileAggregatePipeline(pipeline, defaultFilter)
|
|
89
|
+
: compileFindPlan(pipeline, defaultFilter);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── find() path ────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
function compileFindPlan(
|
|
95
|
+
pipeline: Pipeline,
|
|
96
|
+
defaultFilter: Record<string, unknown> | undefined,
|
|
97
|
+
): CompileResult {
|
|
98
|
+
let filter: Record<string, unknown> | null = null;
|
|
99
|
+
let sort: Record<string, 1 | -1> | undefined;
|
|
100
|
+
let limit: number | undefined;
|
|
101
|
+
let projection: Record<string, 1> | undefined;
|
|
102
|
+
|
|
103
|
+
for (const op of pipeline.operations) {
|
|
104
|
+
if (op.kind === 'WhereOp') {
|
|
105
|
+
const compiled = compileFilter(op.condition);
|
|
106
|
+
if (compiled === null) {
|
|
107
|
+
return { ok: false, reason: 'where(): unsupported expression node' };
|
|
108
|
+
}
|
|
109
|
+
filter = filter ? { $and: [filter, compiled] } : compiled;
|
|
110
|
+
} else if (op.kind === 'SortOp') {
|
|
111
|
+
sort = {};
|
|
112
|
+
for (const c of op.criteria) {
|
|
113
|
+
if (c.expression.kind !== 'FieldAccess' || c.expression.path.length !== 1) {
|
|
114
|
+
return { ok: false, reason: 'sort(): only single-field paths supported' };
|
|
115
|
+
}
|
|
116
|
+
sort[c.expression.path[0]] = c.direction === 'desc' ? -1 : 1;
|
|
117
|
+
}
|
|
118
|
+
} else if (op.kind === 'FirstOp') {
|
|
119
|
+
limit = op.count;
|
|
120
|
+
} else if (op.kind === 'SelectOp') {
|
|
121
|
+
projection = {};
|
|
122
|
+
for (const f of op.fields) {
|
|
123
|
+
if (f.kind === 'FieldAccess' && f.path.length === 1) {
|
|
124
|
+
projection[f.path[0]] = 1;
|
|
125
|
+
} else if (f.kind === 'AliasExpr' && f.expression.kind === 'FieldAccess' && f.expression.path.length === 1) {
|
|
126
|
+
// Mongo doesn't rename via projection in find() (it does in aggregate).
|
|
127
|
+
// Decline mid-pipe so aggregate path picks it up; but if it's only
|
|
128
|
+
// a select with bare-field aliases we could project both… simplest:
|
|
129
|
+
// decline and let aggregate handle alias renaming.
|
|
130
|
+
return { ok: false, reason: 'select() with alias requires aggregate path' };
|
|
131
|
+
} else {
|
|
132
|
+
return { ok: false, reason: 'select(): only bare field projections in find() path' };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Mongo find() always returns _id unless explicitly excluded; suppress
|
|
136
|
+
// it so the result row shape matches what users expect from select().
|
|
137
|
+
(projection as Record<string, 0 | 1>)._id = 0;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const finalFilter = mergeFilters(defaultFilter, filter ?? undefined);
|
|
142
|
+
return {
|
|
143
|
+
ok: true,
|
|
144
|
+
compiled: {
|
|
145
|
+
kind: 'find',
|
|
146
|
+
filter: finalFilter,
|
|
147
|
+
...(sort ? { sort } : {}),
|
|
148
|
+
...(limit !== undefined ? { limit } : {}),
|
|
149
|
+
...(projection ? { projection } : {}),
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── aggregate() path ───────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
function compileAggregatePipeline(
|
|
157
|
+
pipeline: Pipeline,
|
|
158
|
+
defaultFilter: Record<string, unknown> | undefined,
|
|
159
|
+
): CompileResult {
|
|
160
|
+
const stages: Record<string, unknown>[] = [];
|
|
161
|
+
|
|
162
|
+
// Default filter (from yaml) becomes the first $match stage so polled
|
|
163
|
+
// results are consistent across the find() and aggregate() paths.
|
|
164
|
+
if (defaultFilter && Object.keys(defaultFilter).length > 0) {
|
|
165
|
+
stages.push({ $match: { ...defaultFilter } });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let grouped = false;
|
|
169
|
+
let postGroupProjection: Record<string, unknown> | null = null;
|
|
170
|
+
|
|
171
|
+
for (const op of pipeline.operations) {
|
|
172
|
+
switch (op.kind) {
|
|
173
|
+
case 'WhereOp': {
|
|
174
|
+
if (grouped) {
|
|
175
|
+
return { ok: false, reason: 'where after grouping (HAVING) is not yet pushable' };
|
|
176
|
+
}
|
|
177
|
+
const compiled = compileFilter(op.condition);
|
|
178
|
+
if (compiled === null) {
|
|
179
|
+
return { ok: false, reason: 'where(): unsupported expression node' };
|
|
180
|
+
}
|
|
181
|
+
stages.push({ $match: compiled });
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
case 'SortOp': {
|
|
185
|
+
const sortStage: Record<string, 1 | -1> = {};
|
|
186
|
+
for (const c of op.criteria) {
|
|
187
|
+
if (c.expression.kind !== 'FieldAccess' || c.expression.path.length !== 1) {
|
|
188
|
+
return { ok: false, reason: 'sort(): only single-field paths supported' };
|
|
189
|
+
}
|
|
190
|
+
sortStage[c.expression.path[0]] = c.direction === 'desc' ? -1 : 1;
|
|
191
|
+
}
|
|
192
|
+
stages.push({ $sort: sortStage });
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
case 'FirstOp': {
|
|
196
|
+
stages.push({ $limit: op.count });
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
case 'SelectOp': {
|
|
200
|
+
if (grouped) {
|
|
201
|
+
return { ok: false, reason: 'select after rollup is not yet pushable' };
|
|
202
|
+
}
|
|
203
|
+
const proj: Record<string, unknown> = { _id: 0 };
|
|
204
|
+
for (const f of op.fields) {
|
|
205
|
+
if (f.kind === 'FieldAccess' && f.path.length === 1) {
|
|
206
|
+
proj[f.path[0]] = 1;
|
|
207
|
+
} else if (f.kind === 'AliasExpr' && f.expression.kind === 'FieldAccess' && f.expression.path.length === 1) {
|
|
208
|
+
proj[f.alias] = `$${f.expression.path[0]}`;
|
|
209
|
+
} else {
|
|
210
|
+
return { ok: false, reason: 'select(): unsupported field expression in mongo' };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
stages.push({ $project: proj });
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
case 'RollupOp': {
|
|
217
|
+
if (grouped) {
|
|
218
|
+
return { ok: false, reason: 'multiple grouping ops in one pipeline' };
|
|
219
|
+
}
|
|
220
|
+
const groupIdResult = compileGroupId(op.keys);
|
|
221
|
+
if (!groupIdResult.ok) {
|
|
222
|
+
return { ok: false, reason: 'rollup(): keys must be single-field paths' };
|
|
223
|
+
}
|
|
224
|
+
const groupStage: Record<string, unknown> = { _id: groupIdResult.value };
|
|
225
|
+
const distinctCountAliases: string[] = [];
|
|
226
|
+
|
|
227
|
+
for (const a of op.aggregates) {
|
|
228
|
+
// Aggregates may be wrapped in AliasExpr (e.g. `sum(amount) as total`)
|
|
229
|
+
// or bare (e.g. `sum(amount)`).
|
|
230
|
+
const alias = a.kind === 'AliasExpr' ? a.alias : null;
|
|
231
|
+
const inner = a.kind === 'AliasExpr' ? a.expression : a;
|
|
232
|
+
if (inner.kind !== 'FunctionCall') {
|
|
233
|
+
return { ok: false, reason: 'rollup(): aggregate must be a function call' };
|
|
234
|
+
}
|
|
235
|
+
const fn = MONGO_AGGREGATE_FN[inner.name as AggregateFnName];
|
|
236
|
+
if (!fn) {
|
|
237
|
+
return { ok: false, reason: `aggregate "${inner.name}" has no mongo push-down` };
|
|
238
|
+
}
|
|
239
|
+
let argRef: string | null = null;
|
|
240
|
+
if (inner.args.length === 1) {
|
|
241
|
+
const arg = inner.args[0];
|
|
242
|
+
if (arg.kind !== 'FieldAccess' || arg.path.length !== 1) {
|
|
243
|
+
return { ok: false, reason: 'rollup(): aggregate argument must be a single-field path' };
|
|
244
|
+
}
|
|
245
|
+
argRef = `$${arg.path[0]}`;
|
|
246
|
+
} else if (inner.args.length > 1) {
|
|
247
|
+
return { ok: false, reason: `aggregate "${inner.name}" with extra args not yet pushable` };
|
|
248
|
+
}
|
|
249
|
+
const fieldName = alias ?? inner.name;
|
|
250
|
+
groupStage[fieldName] = fn(argRef);
|
|
251
|
+
if (inner.name === 'distinct_count') {
|
|
252
|
+
distinctCountAliases.push(fieldName);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
stages.push({ $group: groupStage });
|
|
257
|
+
|
|
258
|
+
// distinct_count produces a Set in the $group stage; convert to a
|
|
259
|
+
// count via a follow-up $project. Other accumulators are scalar
|
|
260
|
+
// already and pass through unchanged.
|
|
261
|
+
const projAfterGroup: Record<string, unknown> = {
|
|
262
|
+
_id: 0,
|
|
263
|
+
};
|
|
264
|
+
// Spread group keys into top-level fields so callers see
|
|
265
|
+
// `{ country: ..., total: ... }` instead of `{ _id: { country: ... }, total: ... }`.
|
|
266
|
+
if (op.keys.length === 1) {
|
|
267
|
+
const k = op.keys[0];
|
|
268
|
+
if (k.kind === 'FieldAccess' && k.path.length === 1) {
|
|
269
|
+
projAfterGroup[k.path[0]] = '$_id';
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
for (const k of op.keys) {
|
|
273
|
+
if (k.kind === 'FieldAccess' && k.path.length === 1) {
|
|
274
|
+
projAfterGroup[k.path[0]] = `$_id.${k.path[0]}`;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
for (const key of Object.keys(groupStage)) {
|
|
279
|
+
if (key === '_id') continue;
|
|
280
|
+
if (distinctCountAliases.includes(key)) {
|
|
281
|
+
projAfterGroup[key] = { $size: `$${key}` };
|
|
282
|
+
} else {
|
|
283
|
+
projAfterGroup[key] = `$${key}`;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
postGroupProjection = projAfterGroup;
|
|
287
|
+
grouped = true;
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
case 'AggregateOp': {
|
|
291
|
+
if (grouped) {
|
|
292
|
+
return { ok: false, reason: 'multiple grouping ops in one pipeline' };
|
|
293
|
+
}
|
|
294
|
+
const fn = MONGO_AGGREGATE_FN[op.function];
|
|
295
|
+
if (!fn) {
|
|
296
|
+
return { ok: false, reason: `aggregate "${op.function}" has no mongo push-down` };
|
|
297
|
+
}
|
|
298
|
+
if (op.args && op.args.length > 0) {
|
|
299
|
+
return { ok: false, reason: `aggregate "${op.function}" with extra args not yet pushable` };
|
|
300
|
+
}
|
|
301
|
+
let argRef: string | null = null;
|
|
302
|
+
if (op.field !== undefined) {
|
|
303
|
+
if (op.field.kind !== 'FieldAccess' || op.field.path.length !== 1) {
|
|
304
|
+
return { ok: false, reason: `aggregate "${op.function}": unsupported argument expression` };
|
|
305
|
+
}
|
|
306
|
+
argRef = `$${op.field.path[0]}`;
|
|
307
|
+
}
|
|
308
|
+
const groupStage: Record<string, unknown> = { _id: null, [op.function]: fn(argRef) };
|
|
309
|
+
stages.push({ $group: groupStage });
|
|
310
|
+
const proj: Record<string, unknown> = { _id: 0, [op.function]: `$${op.function}` };
|
|
311
|
+
if (op.function === 'distinct_count') {
|
|
312
|
+
proj[op.function] = { $size: `$${op.function}` };
|
|
313
|
+
}
|
|
314
|
+
postGroupProjection = proj;
|
|
315
|
+
grouped = true;
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
default:
|
|
319
|
+
return { ok: false, reason: `${op.kind}: not yet pushable to mongo` };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (postGroupProjection) stages.push({ $project: postGroupProjection });
|
|
324
|
+
|
|
325
|
+
return { ok: true, compiled: { kind: 'aggregate', pipeline: stages } };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ─── Filter / expression compiler ───────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
function compileFilter(expr: Expression): Record<string, unknown> | null {
|
|
331
|
+
switch (expr.kind) {
|
|
332
|
+
case 'BinaryExpr': {
|
|
333
|
+
if (expr.operator === '&&') {
|
|
334
|
+
const left = compileFilter(expr.left);
|
|
335
|
+
const right = compileFilter(expr.right);
|
|
336
|
+
if (left === null || right === null) return null;
|
|
337
|
+
return { $and: [left, right] };
|
|
338
|
+
}
|
|
339
|
+
if (expr.operator === '||') {
|
|
340
|
+
const left = compileFilter(expr.left);
|
|
341
|
+
const right = compileFilter(expr.right);
|
|
342
|
+
if (left === null || right === null) return null;
|
|
343
|
+
return { $or: [left, right] };
|
|
344
|
+
}
|
|
345
|
+
// Comparison: must be FieldAccess <op> literal (or NULL).
|
|
346
|
+
const cmp = CMP_TO_MONGO[expr.operator];
|
|
347
|
+
if (!cmp) return null;
|
|
348
|
+
|
|
349
|
+
// x == null / x != null have special handling.
|
|
350
|
+
if (expr.right.kind === 'NullLiteral') {
|
|
351
|
+
if (expr.operator === '==') return field(expr.left, { $eq: null });
|
|
352
|
+
if (expr.operator === '!=') return field(expr.left, { $ne: null });
|
|
353
|
+
}
|
|
354
|
+
if (expr.left.kind === 'NullLiteral') {
|
|
355
|
+
if (expr.operator === '==') return field(expr.right, { $eq: null });
|
|
356
|
+
if (expr.operator === '!=') return field(expr.right, { $ne: null });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// FieldAccess <op> literal.
|
|
360
|
+
const lit = literalValue(expr.right);
|
|
361
|
+
if (expr.left.kind === 'FieldAccess' && lit.ok) {
|
|
362
|
+
return field(expr.left, { [cmp]: lit.value });
|
|
363
|
+
}
|
|
364
|
+
const litLeft = literalValue(expr.left);
|
|
365
|
+
if (expr.right.kind === 'FieldAccess' && litLeft.ok) {
|
|
366
|
+
// Reverse: pipequery `100 < amount` → mongo `amount > 100`.
|
|
367
|
+
const reversed = REVERSE_CMP[expr.operator] ?? expr.operator;
|
|
368
|
+
const reverseCmp = CMP_TO_MONGO[reversed];
|
|
369
|
+
if (!reverseCmp) return null;
|
|
370
|
+
return field(expr.right, { [reverseCmp]: litLeft.value });
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
case 'UnaryExpr': {
|
|
375
|
+
if (expr.operator === '!') {
|
|
376
|
+
const inner = compileFilter(expr.operand);
|
|
377
|
+
if (inner === null) return null;
|
|
378
|
+
return { $nor: [inner] };
|
|
379
|
+
}
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
default:
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const REVERSE_CMP: Record<string, string> = {
|
|
388
|
+
'>': '<',
|
|
389
|
+
'>=': '<=',
|
|
390
|
+
'<': '>',
|
|
391
|
+
'<=': '>=',
|
|
392
|
+
'==': '==',
|
|
393
|
+
'!=': '!=',
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
function field(expr: Expression, condition: Record<string, unknown>): Record<string, unknown> | null {
|
|
397
|
+
if (expr.kind !== 'FieldAccess' || expr.path.length !== 1) return null;
|
|
398
|
+
return { [expr.path[0]]: condition };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function literalValue(expr: Expression): { ok: true; value: unknown } | { ok: false } {
|
|
402
|
+
switch (expr.kind) {
|
|
403
|
+
case 'NumberLiteral':
|
|
404
|
+
case 'StringLiteral':
|
|
405
|
+
case 'BooleanLiteral':
|
|
406
|
+
return { ok: true, value: expr.value };
|
|
407
|
+
case 'NullLiteral':
|
|
408
|
+
return { ok: true, value: null };
|
|
409
|
+
default:
|
|
410
|
+
return { ok: false };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Compile rollup keys into the `_id` value for `$group`.
|
|
416
|
+
*
|
|
417
|
+
* Returns:
|
|
418
|
+
* - `{ ok: true, value: null }` for `rollup(sum(...))` (no keys)
|
|
419
|
+
* - `{ ok: true, value: '$field' }` for `rollup(field, ...)` (one key)
|
|
420
|
+
* - `{ ok: true, value: { ... } }` for `rollup(k1, k2, ...)` (multiple)
|
|
421
|
+
* - `{ ok: false }` for non-field-access keys (caller declines)
|
|
422
|
+
*
|
|
423
|
+
* `null` is a valid Mongo group _id — it means "group everything into one
|
|
424
|
+
* bucket" — so it can't be used as the failure sentinel.
|
|
425
|
+
*/
|
|
426
|
+
function compileGroupId(keys: Expression[]):
|
|
427
|
+
| { ok: true; value: null | string | Record<string, string> }
|
|
428
|
+
| { ok: false } {
|
|
429
|
+
if (keys.length === 0) return { ok: true, value: null };
|
|
430
|
+
if (keys.length === 1) {
|
|
431
|
+
const k = keys[0];
|
|
432
|
+
if (k.kind !== 'FieldAccess' || k.path.length !== 1) return { ok: false };
|
|
433
|
+
return { ok: true, value: `$${k.path[0]}` };
|
|
434
|
+
}
|
|
435
|
+
const obj: Record<string, string> = {};
|
|
436
|
+
for (const k of keys) {
|
|
437
|
+
if (k.kind !== 'FieldAccess' || k.path.length !== 1) return { ok: false };
|
|
438
|
+
obj[k.path[0]] = `$${k.path[0]}`;
|
|
439
|
+
}
|
|
440
|
+
return { ok: true, value: obj };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function mergeFilters(
|
|
444
|
+
base: Record<string, unknown> | undefined,
|
|
445
|
+
added: Record<string, unknown> | undefined,
|
|
446
|
+
): Record<string, unknown> {
|
|
447
|
+
if (!base || Object.keys(base).length === 0) return added ?? {};
|
|
448
|
+
if (!added || Object.keys(added).length === 0) return { ...base };
|
|
449
|
+
return { $and: [{ ...base }, { ...added }] };
|
|
450
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Pipe-AST → MySQL SQL compiler.
|
|
2
|
+
//
|
|
3
|
+
// Same operator + aggregate set as the Postgres compiler — both engines
|
|
4
|
+
// accept the operations we push down identically. The only differences
|
|
5
|
+
// are quoting (`backticks`) and parameter placeholders (`?`), both
|
|
6
|
+
// abstracted behind dialect.ts.
|
|
7
|
+
|
|
8
|
+
import type { Pipeline } from '../../../../../src/engine/types.js';
|
|
9
|
+
import { compileForDialect, type CompileResult } from './compile.js';
|
|
10
|
+
import { MYSQL_DIALECT } from './dialect.js';
|
|
11
|
+
|
|
12
|
+
export function compileMysqlPushdown(
|
|
13
|
+
pipeline: Pipeline,
|
|
14
|
+
baseQuery: string,
|
|
15
|
+
): CompileResult {
|
|
16
|
+
return compileForDialect(pipeline, baseQuery, MYSQL_DIALECT);
|
|
17
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Pipe-AST → Postgres SQL compiler.
|
|
2
|
+
//
|
|
3
|
+
// Thin shim over the dialect-parametric compileForDialect; everything
|
|
4
|
+
// non-dialect lives in compile.ts. Public name preserved so existing
|
|
5
|
+
// callers (PostgresSourceAdapter.runPushdown) don't change.
|
|
6
|
+
|
|
7
|
+
import type { Pipeline } from '../../../../../src/engine/types.js';
|
|
8
|
+
import { compileForDialect, type CompileResult } from './compile.js';
|
|
9
|
+
import { POSTGRES_DIALECT } from './dialect.js';
|
|
10
|
+
|
|
11
|
+
export type { CompileResult, CompiledPushdown } from './compile.js';
|
|
12
|
+
export { isPushableShape } from './compile.js';
|
|
13
|
+
|
|
14
|
+
export function compilePostgresPushdown(
|
|
15
|
+
pipeline: Pipeline,
|
|
16
|
+
baseQuery: string,
|
|
17
|
+
): CompileResult {
|
|
18
|
+
return compileForDialect(pipeline, baseQuery, POSTGRES_DIALECT);
|
|
19
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Pipe-AST → Snowflake SQL compiler.
|
|
2
|
+
//
|
|
3
|
+
// Thin shim over the dialect-parametric compileForDialect; same operator +
|
|
4
|
+
// aggregate set as Postgres / MySQL. Snowflake uses double-quoted
|
|
5
|
+
// identifiers (case-sensitive) and `?` positional placeholders — both
|
|
6
|
+
// captured in SNOWFLAKE_DIALECT.
|
|
7
|
+
|
|
8
|
+
import type { Pipeline } from '../../../../../src/engine/types.js';
|
|
9
|
+
import { compileForDialect, type CompileResult } from './compile.js';
|
|
10
|
+
import { SNOWFLAKE_DIALECT } from './dialect.js';
|
|
11
|
+
|
|
12
|
+
export function compileSnowflakePushdown(
|
|
13
|
+
pipeline: Pipeline,
|
|
14
|
+
baseQuery: string,
|
|
15
|
+
): CompileResult {
|
|
16
|
+
return compileForDialect(pipeline, baseQuery, SNOWFLAKE_DIALECT);
|
|
17
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import snowflake, { type Connection } from 'snowflake-sdk';
|
|
2
|
+
import type { SnowflakeSourceConfig } from '../../config/schema.js';
|
|
3
|
+
import { parseDuration } from '../../utils/parseDuration.js';
|
|
4
|
+
import { expandEnv } from '../../utils/expandEnv.js';
|
|
5
|
+
import { parseQuery } from '../../engine.js';
|
|
6
|
+
import { compileSnowflakePushdown } from './pushdown/snowflake.js';
|
|
7
|
+
import type { SourceAdapter, SourceStatus } from './types.js';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_INTERVAL_MS = 60_000;
|
|
10
|
+
const DEFAULT_MAX_ROWS = 10_000;
|
|
11
|
+
|
|
12
|
+
export class SnowflakeSourceAdapter implements SourceAdapter {
|
|
13
|
+
private conn: Connection | null = null;
|
|
14
|
+
private data: unknown[] = [];
|
|
15
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
16
|
+
private lastFetch: Date | null = null;
|
|
17
|
+
private error?: string;
|
|
18
|
+
private listeners = new Set<(data: unknown[]) => void>();
|
|
19
|
+
private readonly intervalMs: number;
|
|
20
|
+
private readonly maxRows: number;
|
|
21
|
+
|
|
22
|
+
constructor(private config: SnowflakeSourceConfig) {
|
|
23
|
+
this.intervalMs = config.interval ? parseDuration(config.interval) : DEFAULT_INTERVAL_MS;
|
|
24
|
+
this.maxRows = config.maxRows ?? DEFAULT_MAX_ROWS;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async start(): Promise<void> {
|
|
28
|
+
this.conn = snowflake.createConnection({
|
|
29
|
+
account: expandEnv(this.config.account, 'snowflake source account'),
|
|
30
|
+
username: expandEnv(this.config.username, 'snowflake source username'),
|
|
31
|
+
password: expandEnv(this.config.password, 'snowflake source password'),
|
|
32
|
+
...(this.config.database ? { database: expandEnv(this.config.database, 'snowflake source database') } : {}),
|
|
33
|
+
...(this.config.schema ? { schema: expandEnv(this.config.schema, 'snowflake source schema') } : {}),
|
|
34
|
+
...(this.config.warehouse ? { warehouse: expandEnv(this.config.warehouse, 'snowflake source warehouse') } : {}),
|
|
35
|
+
...(this.config.role ? { role: expandEnv(this.config.role, 'snowflake source role') } : {}),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
await new Promise<void>((resolve, reject) => {
|
|
39
|
+
this.conn!.connect((err) => (err ? reject(err) : resolve()));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
await this.fetch();
|
|
43
|
+
this.timer = setInterval(() => {
|
|
44
|
+
void this.fetch();
|
|
45
|
+
}, this.intervalMs);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
stop(): void {
|
|
49
|
+
if (this.timer) {
|
|
50
|
+
clearInterval(this.timer);
|
|
51
|
+
this.timer = null;
|
|
52
|
+
}
|
|
53
|
+
if (this.conn) {
|
|
54
|
+
this.conn.destroy(() => undefined);
|
|
55
|
+
this.conn = null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getData(): unknown[] {
|
|
60
|
+
return this.data;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
getStatus(): SourceStatus {
|
|
64
|
+
return {
|
|
65
|
+
healthy: !this.error,
|
|
66
|
+
rowCount: this.data.length,
|
|
67
|
+
lastFetch: this.lastFetch,
|
|
68
|
+
error: this.error,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
onUpdate(callback: (data: unknown[]) => void): () => void {
|
|
73
|
+
this.listeners.add(callback);
|
|
74
|
+
return () => this.listeners.delete(callback);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async refresh(): Promise<void> {
|
|
78
|
+
await this.fetch();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Adapter-native push-down. SourceManager.runQuery dispatches here when a
|
|
83
|
+
* pipeline targets a Snowflake source; declines fall back to in-process.
|
|
84
|
+
*/
|
|
85
|
+
async runPushdown(expression: string): Promise<
|
|
86
|
+
| { ok: true; rows: unknown[]; sql: string; params: unknown[] }
|
|
87
|
+
| { ok: false; reason: string }
|
|
88
|
+
> {
|
|
89
|
+
if (!this.conn) {
|
|
90
|
+
return { ok: false, reason: 'Snowflake connection not initialized — call start() first' };
|
|
91
|
+
}
|
|
92
|
+
let pipeline;
|
|
93
|
+
try {
|
|
94
|
+
pipeline = parseQuery(expression);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return { ok: false, reason: `parse error: ${err instanceof Error ? err.message : String(err)}` };
|
|
97
|
+
}
|
|
98
|
+
const compiled = compileSnowflakePushdown(pipeline, this.config.query);
|
|
99
|
+
if (!compiled.ok) {
|
|
100
|
+
return { ok: false, reason: compiled.reason };
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const rows = await this.executeSql(compiled.compiled.sql, compiled.compiled.params);
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
rows,
|
|
107
|
+
sql: compiled.compiled.sql,
|
|
108
|
+
params: compiled.compiled.params,
|
|
109
|
+
};
|
|
110
|
+
} catch (err) {
|
|
111
|
+
return { ok: false, reason: `snowflake error: ${err instanceof Error ? err.message : String(err)}` };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private async fetch(): Promise<void> {
|
|
116
|
+
if (!this.conn) {
|
|
117
|
+
this.error = 'Snowflake connection not initialized';
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const capQuery = `SELECT * FROM (${stripTrailingSemicolon(this.config.query)}) AS pq_src LIMIT ${this.maxRows + 1}`;
|
|
122
|
+
const rows = await this.executeSql(capQuery, []);
|
|
123
|
+
if (rows.length > this.maxRows) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Query returned more than maxRows=${this.maxRows}. ` +
|
|
126
|
+
`Add LIMIT/WHERE to bound the result set, or raise maxRows in the source config.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
this.data = rows;
|
|
130
|
+
this.lastFetch = new Date();
|
|
131
|
+
this.error = undefined;
|
|
132
|
+
this.notify();
|
|
133
|
+
} catch (err) {
|
|
134
|
+
this.error = err instanceof Error ? err.message : String(err);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private executeSql(sqlText: string, params: unknown[]): Promise<unknown[]> {
|
|
139
|
+
return new Promise((resolve, reject) => {
|
|
140
|
+
this.conn!.execute({
|
|
141
|
+
sqlText,
|
|
142
|
+
binds: params as snowflake.Binds,
|
|
143
|
+
complete: (err, _stmt, rows) => {
|
|
144
|
+
if (err) reject(err);
|
|
145
|
+
else resolve(rows ?? []);
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private notify(): void {
|
|
152
|
+
for (const cb of this.listeners) cb(this.data);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function stripTrailingSemicolon(sql: string): string {
|
|
157
|
+
return sql.trim().replace(/;+\s*$/, '');
|
|
158
|
+
}
|