@vaultgradient/pipequery-cli 0.6.1 → 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__/rest-source.test.ts +191 -0
- package/__tests__/snowflake-pushdown-compile.test.ts +74 -0
- package/__tests__/websocket-source.test.ts +187 -0
- package/dist/index.js +3570 -2044
- package/package.json +11 -2
- package/src/commands/telegram.ts +10 -0
- package/src/config/schema.ts +90 -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/kafka.ts +4 -16
- package/src/server/sources/manager.ts +71 -1
- package/src/server/sources/mongodb.ts +165 -0
- package/src/server/sources/mysql.ts +46 -14
- package/src/server/sources/postgres.ts +52 -20
- 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/rest.ts +33 -5
- package/src/server/sources/snowflake.ts +158 -0
- package/src/server/sources/types.ts +27 -0
- package/src/server/sources/websocket.ts +73 -9
- package/src/telegram/bot.ts +198 -12
- package/src/telegram/event-log.ts +234 -0
- package/src/utils/expandEnv.ts +23 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { RestSourceConfig } from '../../config/schema.js';
|
|
2
2
|
import { parseDuration } from '../../utils/parseDuration.js';
|
|
3
|
+
import { expandEnv } from '../../utils/expandEnv.js';
|
|
3
4
|
import type { SourceAdapter, SourceStatus } from './types.js';
|
|
4
5
|
|
|
5
6
|
function extractPath(obj: unknown, path: string): unknown[] {
|
|
@@ -57,15 +58,19 @@ export class RestSourceAdapter implements SourceAdapter {
|
|
|
57
58
|
|
|
58
59
|
private async fetch(): Promise<void> {
|
|
59
60
|
try {
|
|
60
|
-
let url = this.config.url;
|
|
61
|
+
let url = expandEnv(this.config.url, 'rest source URL');
|
|
61
62
|
if (this.config.params) {
|
|
62
|
-
const
|
|
63
|
+
const expanded: Record<string, string> = {};
|
|
64
|
+
for (const [k, v] of Object.entries(this.config.params)) {
|
|
65
|
+
expanded[k] = expandEnv(v, `rest source param "${k}"`);
|
|
66
|
+
}
|
|
67
|
+
const params = new URLSearchParams(expanded);
|
|
63
68
|
url += (url.includes('?') ? '&' : '?') + params.toString();
|
|
64
69
|
}
|
|
65
70
|
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
});
|
|
71
|
+
const headers = this.buildHeaders();
|
|
72
|
+
|
|
73
|
+
const res = await globalThis.fetch(url, { headers });
|
|
69
74
|
|
|
70
75
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
71
76
|
|
|
@@ -82,4 +87,27 @@ export class RestSourceAdapter implements SourceAdapter {
|
|
|
82
87
|
private notify(): void {
|
|
83
88
|
for (const cb of this.listeners) cb(this.data);
|
|
84
89
|
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build the final HTTP headers for one fetch.
|
|
93
|
+
*
|
|
94
|
+
* Two sources contribute, in order: any user-declared `headers` (with
|
|
95
|
+
* env-var interpolation applied per value), then the `auth` helper
|
|
96
|
+
* (currently just `bearer`). Auth wins on collision — if a user sets
|
|
97
|
+
* both `headers.Authorization` and `auth.kind === 'bearer'`, the bearer
|
|
98
|
+
* helper is what they actually meant.
|
|
99
|
+
*/
|
|
100
|
+
private buildHeaders(): Record<string, string> | undefined {
|
|
101
|
+
const headers: Record<string, string> = {};
|
|
102
|
+
if (this.config.headers) {
|
|
103
|
+
for (const [k, v] of Object.entries(this.config.headers)) {
|
|
104
|
+
headers[k] = expandEnv(v, `rest source header "${k}"`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (this.config.auth?.kind === 'bearer') {
|
|
108
|
+
const token = expandEnv(this.config.auth.token, 'rest source bearer token');
|
|
109
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
110
|
+
}
|
|
111
|
+
return Object.keys(headers).length > 0 ? headers : undefined;
|
|
112
|
+
}
|
|
85
113
|
}
|