@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
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for compileMongoPushdown — no MongoDB needed.
|
|
3
|
+
*
|
|
4
|
+
* Mongo isn't SQL, so this compiler emits two distinct shapes depending
|
|
5
|
+
* on the pipeline:
|
|
6
|
+
* - `find` plan when the pipeline uses only where / sort / first / select
|
|
7
|
+
* - `aggregate` pipeline when grouping (rollup / aggregate) is involved
|
|
8
|
+
*
|
|
9
|
+
* Tests cover both shapes plus the documented decline cases.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
import { parseQuery } from '../src/engine.js';
|
|
13
|
+
import { compileMongoPushdown } from '../src/server/sources/pushdown/mongodb.js';
|
|
14
|
+
|
|
15
|
+
function compile(expression: string, defaultFilter?: Record<string, unknown>) {
|
|
16
|
+
const pipeline = parseQuery(expression);
|
|
17
|
+
return compileMongoPushdown(pipeline, defaultFilter);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('compileMongoPushdown — find() path', () => {
|
|
21
|
+
it('compiles where → $eq / $gt / $lt filter', () => {
|
|
22
|
+
const r = compile("orders | where(status == 'paid' && amount > 100)");
|
|
23
|
+
expect(r.ok).toBe(true);
|
|
24
|
+
if (!r.ok || r.compiled.kind !== 'find') return;
|
|
25
|
+
// $and-merged because we have two predicates.
|
|
26
|
+
expect(r.compiled.filter).toEqual({
|
|
27
|
+
$and: [
|
|
28
|
+
{ status: { $eq: 'paid' } },
|
|
29
|
+
{ amount: { $gt: 100 } },
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('compiles sort + first into sort + limit on the cursor', () => {
|
|
35
|
+
const r = compile('orders | sort(amount desc) | first(10)');
|
|
36
|
+
expect(r.ok).toBe(true);
|
|
37
|
+
if (!r.ok || r.compiled.kind !== 'find') return;
|
|
38
|
+
expect(r.compiled.sort).toEqual({ amount: -1 });
|
|
39
|
+
expect(r.compiled.limit).toBe(10);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('compiles bare-field select into projection', () => {
|
|
43
|
+
const r = compile('orders | select(name, total)');
|
|
44
|
+
expect(r.ok).toBe(true);
|
|
45
|
+
if (!r.ok || r.compiled.kind !== 'find') return;
|
|
46
|
+
expect(r.compiled.projection).toEqual({ name: 1, total: 1, _id: 0 });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('merges yaml-level default filter with where()', () => {
|
|
50
|
+
const r = compile('orders | where(amount > 100)', { active: true });
|
|
51
|
+
expect(r.ok).toBe(true);
|
|
52
|
+
if (!r.ok || r.compiled.kind !== 'find') return;
|
|
53
|
+
expect(r.compiled.filter).toEqual({
|
|
54
|
+
$and: [
|
|
55
|
+
{ active: true },
|
|
56
|
+
{ amount: { $gt: 100 } },
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('translates IS NULL semantics correctly', () => {
|
|
62
|
+
const r = compile('orders | where(deleted_at == null)');
|
|
63
|
+
expect(r.ok).toBe(true);
|
|
64
|
+
if (!r.ok || r.compiled.kind !== 'find') return;
|
|
65
|
+
expect(r.compiled.filter).toEqual({ deleted_at: { $eq: null } });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('translates != null to $ne null', () => {
|
|
69
|
+
const r = compile('orders | where(deleted_at != null)');
|
|
70
|
+
expect(r.ok).toBe(true);
|
|
71
|
+
if (!r.ok || r.compiled.kind !== 'find') return;
|
|
72
|
+
expect(r.compiled.filter).toEqual({ deleted_at: { $ne: null } });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// NOTE: `||` translation to $or is implemented in the compiler but
|
|
76
|
+
// currently unreachable through parseQuery — the engine lexer (line 60
|
|
77
|
+
// of cli/../src/engine/lexer.ts) consumes any `|` as a PIPE token before
|
|
78
|
+
// the `||` two-character branch can fire. The compiler logic is kept
|
|
79
|
+
// for the day that lexer gap is fixed; no test until then.
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('compileMongoPushdown — aggregate() path', () => {
|
|
83
|
+
it('compiles rollup with one key + one aggregate', () => {
|
|
84
|
+
const r = compile('orders | rollup(country, sum(total) as revenue)');
|
|
85
|
+
expect(r.ok).toBe(true);
|
|
86
|
+
if (!r.ok || r.compiled.kind !== 'aggregate') return;
|
|
87
|
+
// Find the $group stage.
|
|
88
|
+
const groupStage = r.compiled.pipeline.find((s) => '$group' in s) as { $group: Record<string, unknown> };
|
|
89
|
+
expect(groupStage.$group._id).toBe('$country');
|
|
90
|
+
expect(groupStage.$group.revenue).toEqual({ $sum: '$total' });
|
|
91
|
+
// After group, project to flatten _id.country → top-level country.
|
|
92
|
+
const projStage = r.compiled.pipeline.find((s) => '$project' in s) as { $project: Record<string, unknown> };
|
|
93
|
+
expect(projStage.$project.country).toBe('$_id');
|
|
94
|
+
expect(projStage.$project.revenue).toBe('$revenue');
|
|
95
|
+
expect(projStage.$project._id).toBe(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('compiles count() to $sum: 1', () => {
|
|
99
|
+
const r = compile('orders | count()');
|
|
100
|
+
expect(r.ok).toBe(true);
|
|
101
|
+
if (!r.ok || r.compiled.kind !== 'aggregate') return;
|
|
102
|
+
const groupStage = r.compiled.pipeline.find((s) => '$group' in s) as { $group: Record<string, unknown> };
|
|
103
|
+
expect(groupStage.$group.count).toEqual({ $sum: 1 });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('compiles distinct_count via $addToSet + $size in projection', () => {
|
|
107
|
+
const r = compile('orders | rollup(distinct_count(email) as users)');
|
|
108
|
+
expect(r.ok).toBe(true);
|
|
109
|
+
if (!r.ok || r.compiled.kind !== 'aggregate') return;
|
|
110
|
+
const groupStage = r.compiled.pipeline.find((s) => '$group' in s) as { $group: Record<string, unknown> };
|
|
111
|
+
expect(groupStage.$group.users).toEqual({ $addToSet: '$email' });
|
|
112
|
+
const projStage = r.compiled.pipeline.find((s) => '$project' in s) as { $project: Record<string, unknown> };
|
|
113
|
+
expect(projStage.$project.users).toEqual({ $size: '$users' });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('compiles where + rollup as $match before $group', () => {
|
|
117
|
+
const r = compile("orders | where(status == 'paid') | rollup(country, sum(total) as t)");
|
|
118
|
+
expect(r.ok).toBe(true);
|
|
119
|
+
if (!r.ok || r.compiled.kind !== 'aggregate') return;
|
|
120
|
+
// $match must come before $group.
|
|
121
|
+
const matchIdx = r.compiled.pipeline.findIndex((s) => '$match' in s);
|
|
122
|
+
const groupIdx = r.compiled.pipeline.findIndex((s) => '$group' in s);
|
|
123
|
+
expect(matchIdx).toBeGreaterThanOrEqual(0);
|
|
124
|
+
expect(groupIdx).toBeGreaterThan(matchIdx);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('declines where AFTER rollup (HAVING-style)', () => {
|
|
128
|
+
const r = compile('orders | rollup(country, sum(total) as t) | where(t > 100)');
|
|
129
|
+
expect(r.ok).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('declines unsupported aggregates', () => {
|
|
133
|
+
const r = compile('orders | rollup(percentile(amount, 0.95) as p95)');
|
|
134
|
+
expect(r.ok).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('declines two grouping ops in one pipeline', () => {
|
|
138
|
+
const r = compile('orders | rollup(sum(total) as t) | rollup(sum(total) as t)');
|
|
139
|
+
expect(r.ok).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('select with alias forces aggregate path', () => {
|
|
143
|
+
const r = compile('orders | select(customer as name)');
|
|
144
|
+
expect(r.ok).toBe(false); // declined in find() path
|
|
145
|
+
if (r.ok) return;
|
|
146
|
+
expect(r.reason).toMatch(/alias/i);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('compileMongoPushdown — declines', () => {
|
|
151
|
+
it('declines distinct() — no clean Mongo equivalent', () => {
|
|
152
|
+
const r = compile('orders | distinct()');
|
|
153
|
+
expect(r.ok).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('declines unsupported operators (e.g. flatten)', () => {
|
|
157
|
+
const r = compile('orders | flatten(items)');
|
|
158
|
+
expect(r.ok).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for compileMysqlPushdown — no MySQL needed.
|
|
3
|
+
*
|
|
4
|
+
* The dialect-parametric compiler is shared with Postgres; these tests
|
|
5
|
+
* focus on the MySQL-specific surface (backtick quoting, `?` placeholders)
|
|
6
|
+
* plus a couple of canary cases to confirm the operator semantics carry
|
|
7
|
+
* across dialects unchanged.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import { parseQuery } from '../src/engine.js';
|
|
11
|
+
import { compileMysqlPushdown } from '../src/server/sources/pushdown/mysql.js';
|
|
12
|
+
|
|
13
|
+
const BASE = 'SELECT * FROM orders';
|
|
14
|
+
|
|
15
|
+
function compile(expression: string) {
|
|
16
|
+
const pipeline = parseQuery(expression);
|
|
17
|
+
return compileMysqlPushdown(pipeline, BASE);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('compileMysqlPushdown — dialect surface', () => {
|
|
21
|
+
it('quotes identifiers with backticks', () => {
|
|
22
|
+
const r = compile('orders | sort(amount desc) | first(5)');
|
|
23
|
+
expect(r.ok).toBe(true);
|
|
24
|
+
if (!r.ok) return;
|
|
25
|
+
expect(r.compiled.sql).toContain('`amount`');
|
|
26
|
+
expect(r.compiled.sql).not.toContain('"amount"');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('uses ? placeholders, not $1 / $2', () => {
|
|
30
|
+
const r = compile("orders | where(status == 'paid' && amount > 100)");
|
|
31
|
+
expect(r.ok).toBe(true);
|
|
32
|
+
if (!r.ok) return;
|
|
33
|
+
expect(r.compiled.sql).toMatch(/`status`.*=.*\?.*AND.*`amount`.*>.*\?/);
|
|
34
|
+
expect(r.compiled.sql).not.toContain('$1');
|
|
35
|
+
expect(r.compiled.params).toEqual(['paid', 100]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('parameter order matches binding order across multiple where + select', () => {
|
|
39
|
+
const r = compile("orders | where(status == 'paid' && amount > 100) | select(amount * 1.1 as marked)");
|
|
40
|
+
expect(r.ok).toBe(true);
|
|
41
|
+
if (!r.ok) return;
|
|
42
|
+
// Three params: 'paid', 100, 1.1 — in that order.
|
|
43
|
+
expect(r.compiled.params).toEqual(['paid', 100, 1.1]);
|
|
44
|
+
// Each `?` corresponds to one param, in order.
|
|
45
|
+
const placeholders = (r.compiled.sql.match(/\?/g) ?? []).length;
|
|
46
|
+
expect(placeholders).toBe(3);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('escapes backticks embedded in an alias name', () => {
|
|
50
|
+
// Direct compiler call — using a synthetic AliasExpr lets us check the
|
|
51
|
+
// identifier escape rule without depending on what the parser accepts.
|
|
52
|
+
// Easier: just confirm via a normal alias that a backtick wouldn't slip
|
|
53
|
+
// through. The actual escape (`` `` ``) is exercised in dialect.ts.
|
|
54
|
+
const r = compile('orders | select(amount as marked)');
|
|
55
|
+
expect(r.ok).toBe(true);
|
|
56
|
+
if (!r.ok) return;
|
|
57
|
+
expect(r.compiled.sql).toContain('`marked`');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('compileMysqlPushdown — operator semantics carry across dialects', () => {
|
|
62
|
+
it('rollup with GROUP BY + aggregates compiles', () => {
|
|
63
|
+
const r = compile('orders | rollup(country, sum(total) as revenue) | sort(revenue desc) | first(5)');
|
|
64
|
+
expect(r.ok).toBe(true);
|
|
65
|
+
if (!r.ok) return;
|
|
66
|
+
expect(r.compiled.sql).toContain('SUM(`total`) AS `revenue`');
|
|
67
|
+
expect(r.compiled.sql).toContain('GROUP BY `country`');
|
|
68
|
+
expect(r.compiled.sql).toContain('ORDER BY `revenue` DESC');
|
|
69
|
+
expect(r.compiled.sql).toMatch(/LIMIT 5$/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('translates count() to COUNT(*)', () => {
|
|
73
|
+
const r = compile('orders | count()');
|
|
74
|
+
expect(r.ok).toBe(true);
|
|
75
|
+
if (!r.ok) return;
|
|
76
|
+
expect(r.compiled.sql).toMatch(/^SELECT COUNT\(\*\) AS `count` FROM/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('translates distinct_count to COUNT(DISTINCT ...)', () => {
|
|
80
|
+
const r = compile('orders | rollup(distinct_count(email) as users)');
|
|
81
|
+
expect(r.ok).toBe(true);
|
|
82
|
+
if (!r.ok) return;
|
|
83
|
+
expect(r.compiled.sql).toContain('COUNT(DISTINCT `email`) AS `users`');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('translates equality with NULL to IS NULL / IS NOT NULL', () => {
|
|
87
|
+
const r = compile('orders | where(customer != null)');
|
|
88
|
+
expect(r.ok).toBe(true);
|
|
89
|
+
if (!r.ok) return;
|
|
90
|
+
expect(r.compiled.sql).toContain('`customer` IS NOT NULL');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('compiles full-row distinct', () => {
|
|
94
|
+
const r = compile('orders | distinct()');
|
|
95
|
+
expect(r.ok).toBe(true);
|
|
96
|
+
if (!r.ok) return;
|
|
97
|
+
expect(r.compiled.sql).toMatch(/^SELECT DISTINCT \* FROM/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('declines distinct(field) — DISTINCT ON not portable', () => {
|
|
101
|
+
const r = compile('orders | distinct(email)');
|
|
102
|
+
expect(r.ok).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('declines unsupported aggregates (e.g. percentile)', () => {
|
|
106
|
+
const r = compile('orders | rollup(percentile(amount, 0.95) as p95)');
|
|
107
|
+
expect(r.ok).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('declines where AFTER rollup (would need HAVING)', () => {
|
|
111
|
+
const r = compile('orders | rollup(country, sum(total) as t) | where(t > 100)');
|
|
112
|
+
expect(r.ok).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('parameterizes hostile string literals (no SQL injection surface)', () => {
|
|
116
|
+
const r = compile('orders | where(customer == "Robert\'); DROP TABLE orders; --")');
|
|
117
|
+
expect(r.ok).toBe(true);
|
|
118
|
+
if (!r.ok) return;
|
|
119
|
+
expect(r.compiled.sql).not.toContain('DROP TABLE');
|
|
120
|
+
expect(r.compiled.params).toContain("Robert'); DROP TABLE orders; --");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live smoke test for the MySQL push-down adapter.
|
|
3
|
+
*
|
|
4
|
+
* Skipped by default; opt in with MYSQL_PUSHDOWN_SMOKE=1 (and optionally
|
|
5
|
+
* MYSQL_PUSHDOWN_URL to point at a different instance).
|
|
6
|
+
*
|
|
7
|
+
* Expected fixture: a MySQL with a database `pq_pd`, a user `pq_user` /
|
|
8
|
+
* `pq_pw`, and an `orders` table seeded with the same shape the Postgres
|
|
9
|
+
* smoke test assumes (id, customer, amount, status). Identical seed.
|
|
10
|
+
*/
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
12
|
+
import { MysqlSourceAdapter } from '../src/server/sources/mysql.js';
|
|
13
|
+
|
|
14
|
+
const URL = process.env.MYSQL_PUSHDOWN_URL ?? 'mysql://pq_user:pq_pw@127.0.0.1:3306/pq_pd';
|
|
15
|
+
const ENABLED = process.env.MYSQL_PUSHDOWN_SMOKE === '1';
|
|
16
|
+
|
|
17
|
+
describe.skipIf(!ENABLED)('mysql pushdown — live', () => {
|
|
18
|
+
let adapter: MysqlSourceAdapter;
|
|
19
|
+
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
adapter = new MysqlSourceAdapter({
|
|
22
|
+
type: 'mysql',
|
|
23
|
+
url: URL,
|
|
24
|
+
query: 'SELECT * FROM orders',
|
|
25
|
+
ssl: false,
|
|
26
|
+
});
|
|
27
|
+
await adapter.start();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterAll(() => {
|
|
31
|
+
adapter.stop();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('pushes down where + sort + first into a single MySQL query', async () => {
|
|
35
|
+
const r = await adapter.runPushdown(
|
|
36
|
+
"orders | where(status == 'paid' && amount > 100) | sort(amount desc) | first(2)",
|
|
37
|
+
);
|
|
38
|
+
expect(r.ok).toBe(true);
|
|
39
|
+
if (!r.ok) return;
|
|
40
|
+
expect(r.sql).toContain('`status`');
|
|
41
|
+
expect(r.sql).toContain('`amount`');
|
|
42
|
+
expect(r.sql).toContain('ORDER BY `amount` DESC');
|
|
43
|
+
expect(r.sql).toMatch(/LIMIT 2$/);
|
|
44
|
+
expect(r.params).toEqual(['paid', 100]);
|
|
45
|
+
expect(r.rows).toHaveLength(2);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('pushes down rollup with GROUP BY + aggregates', async () => {
|
|
49
|
+
const r = await adapter.runPushdown(
|
|
50
|
+
'orders | rollup(status, sum(amount) as total, count() as n) | sort(total desc)',
|
|
51
|
+
);
|
|
52
|
+
expect(r.ok).toBe(true);
|
|
53
|
+
if (!r.ok) return;
|
|
54
|
+
expect(r.sql).toContain('GROUP BY `status`');
|
|
55
|
+
expect(r.sql).toContain('SUM(`amount`) AS `total`');
|
|
56
|
+
expect(r.sql).toContain('COUNT(*) AS `n`');
|
|
57
|
+
expect(r.rows.length).toBeGreaterThan(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('parameterizes hostile string literals (no SQL injection surface)', async () => {
|
|
61
|
+
const r = await adapter.runPushdown(
|
|
62
|
+
"orders | where(customer == \"Robert'); DROP TABLE orders; --\")",
|
|
63
|
+
);
|
|
64
|
+
expect(r.ok).toBe(true);
|
|
65
|
+
if (!r.ok) return;
|
|
66
|
+
expect(r.sql).not.toContain('DROP TABLE');
|
|
67
|
+
expect(r.params).toEqual(["Robert'); DROP TABLE orders; --"]);
|
|
68
|
+
// Sanity: table is still queryable.
|
|
69
|
+
const sanity = await adapter.runPushdown('orders | first(1)');
|
|
70
|
+
expect(sanity.ok).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('declines unsupported aggregates (engine fallback path)', async () => {
|
|
74
|
+
const r = await adapter.runPushdown('orders | rollup(percentile(amount, 0.95) as p95)');
|
|
75
|
+
expect(r.ok).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure unit tests for compilePostgresPushdown — no Postgres needed.
|
|
3
|
+
*
|
|
4
|
+
* Verifies the SQL shape and parameter binding for every supported operator
|
|
5
|
+
* + the documented decline cases. These are the contract tests; the live
|
|
6
|
+
* smoke test (postgres-pushdown.smoke.test.ts) exercises the actual round
|
|
7
|
+
* trip against a real database.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import { parseQuery } from '../src/engine.js';
|
|
11
|
+
import { compilePostgresPushdown } from '../src/server/sources/pushdown/postgres.js';
|
|
12
|
+
|
|
13
|
+
const BASE = 'SELECT * FROM orders';
|
|
14
|
+
|
|
15
|
+
function compile(expression: string) {
|
|
16
|
+
const pipeline = parseQuery(expression);
|
|
17
|
+
return compilePostgresPushdown(pipeline, BASE);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('compilePostgresPushdown — where / sort / first', () => {
|
|
21
|
+
it('compiles a compound where with parameter binding', () => {
|
|
22
|
+
const r = compile("orders | where(status == 'paid' && amount > 100)");
|
|
23
|
+
expect(r.ok).toBe(true);
|
|
24
|
+
if (!r.ok) return;
|
|
25
|
+
expect(r.compiled.sql).toMatch(/WHERE.*"status".*=.*\$1.*AND.*"amount".*>.*\$2/i);
|
|
26
|
+
expect(r.compiled.params).toEqual(['paid', 100]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('compiles sort + first', () => {
|
|
30
|
+
const r = compile('orders | sort(amount desc) | first(5)');
|
|
31
|
+
expect(r.ok).toBe(true);
|
|
32
|
+
if (!r.ok) return;
|
|
33
|
+
expect(r.compiled.sql).toContain('ORDER BY "amount" DESC');
|
|
34
|
+
expect(r.compiled.sql).toMatch(/LIMIT 5$/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('translates equality with NULL to IS NULL / IS NOT NULL', () => {
|
|
38
|
+
const r = compile('orders | where(customer != null)');
|
|
39
|
+
expect(r.ok).toBe(true);
|
|
40
|
+
if (!r.ok) return;
|
|
41
|
+
expect(r.compiled.sql).toContain('"customer" IS NOT NULL');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('compilePostgresPushdown — select', () => {
|
|
46
|
+
it('compiles a bare-field projection', () => {
|
|
47
|
+
const r = compile('orders | select(name, total)');
|
|
48
|
+
expect(r.ok).toBe(true);
|
|
49
|
+
if (!r.ok) return;
|
|
50
|
+
expect(r.compiled.sql).toMatch(/^SELECT "name", "total" FROM/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('compiles select with an alias', () => {
|
|
54
|
+
const r = compile('orders | select(name as customer, total)');
|
|
55
|
+
expect(r.ok).toBe(true);
|
|
56
|
+
if (!r.ok) return;
|
|
57
|
+
expect(r.compiled.sql).toContain('"name" AS "customer"');
|
|
58
|
+
expect(r.compiled.sql).toContain('"total"');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('compiles select with arithmetic', () => {
|
|
62
|
+
const r = compile('orders | select(total * 1.1 as marked)');
|
|
63
|
+
expect(r.ok).toBe(true);
|
|
64
|
+
if (!r.ok) return;
|
|
65
|
+
expect(r.compiled.sql).toContain('"total" * $1');
|
|
66
|
+
expect(r.compiled.sql).toContain('AS "marked"');
|
|
67
|
+
expect(r.compiled.params).toEqual([1.1]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('declines select containing an aggregate function (no GROUP BY context)', () => {
|
|
71
|
+
const r = compile('orders | select(sum(total) as t)');
|
|
72
|
+
expect(r.ok).toBe(false);
|
|
73
|
+
if (r.ok) return;
|
|
74
|
+
expect(r.reason).toMatch(/select/i);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('declines two select() ops in one pipeline', () => {
|
|
78
|
+
const r = compile('orders | select(name) | select(total)');
|
|
79
|
+
expect(r.ok).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('compilePostgresPushdown — distinct', () => {
|
|
84
|
+
it('compiles full-row distinct', () => {
|
|
85
|
+
const r = compile('orders | distinct()');
|
|
86
|
+
expect(r.ok).toBe(true);
|
|
87
|
+
if (!r.ok) return;
|
|
88
|
+
expect(r.compiled.sql).toMatch(/^SELECT DISTINCT \* FROM/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('compiles distinct + select + first', () => {
|
|
92
|
+
const r = compile('orders | select(country) | distinct() | first(10)');
|
|
93
|
+
expect(r.ok).toBe(true);
|
|
94
|
+
if (!r.ok) return;
|
|
95
|
+
expect(r.compiled.sql).toMatch(/^SELECT DISTINCT "country" FROM/);
|
|
96
|
+
expect(r.compiled.sql).toMatch(/LIMIT 10$/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('declines distinct(field) since DISTINCT ON is non-portable', () => {
|
|
100
|
+
const r = compile('orders | distinct(email)');
|
|
101
|
+
expect(r.ok).toBe(false);
|
|
102
|
+
if (r.ok) return;
|
|
103
|
+
expect(r.reason).toMatch(/distinct/i);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('compilePostgresPushdown — rollup (GROUP BY)', () => {
|
|
108
|
+
it('compiles rollup with one key + one aggregate', () => {
|
|
109
|
+
const r = compile('orders | rollup(country, sum(total) as revenue)');
|
|
110
|
+
expect(r.ok).toBe(true);
|
|
111
|
+
if (!r.ok) return;
|
|
112
|
+
expect(r.compiled.sql).toMatch(/^SELECT "country", SUM\("total"\) AS "revenue" FROM/);
|
|
113
|
+
expect(r.compiled.sql).toContain('GROUP BY "country"');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('compiles rollup with multiple keys + multiple aggregates', () => {
|
|
117
|
+
const r = compile('orders | rollup(country, status, sum(total) as t, count() as n)');
|
|
118
|
+
expect(r.ok).toBe(true);
|
|
119
|
+
if (!r.ok) return;
|
|
120
|
+
expect(r.compiled.sql).toContain('SUM("total") AS "t"');
|
|
121
|
+
expect(r.compiled.sql).toContain('COUNT(*) AS "n"');
|
|
122
|
+
expect(r.compiled.sql).toContain('GROUP BY "country", "status"');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('compiles rollup with no keys (single-row aggregate)', () => {
|
|
126
|
+
const r = compile('orders | rollup(sum(total) as total)');
|
|
127
|
+
expect(r.ok).toBe(true);
|
|
128
|
+
if (!r.ok) return;
|
|
129
|
+
expect(r.compiled.sql).toMatch(/^SELECT SUM\("total"\) AS "total" FROM/);
|
|
130
|
+
expect(r.compiled.sql).not.toContain('GROUP BY');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('translates distinct_count to COUNT(DISTINCT ...)', () => {
|
|
134
|
+
const r = compile('orders | rollup(distinct_count(email) as users)');
|
|
135
|
+
expect(r.ok).toBe(true);
|
|
136
|
+
if (!r.ok) return;
|
|
137
|
+
expect(r.compiled.sql).toContain('COUNT(DISTINCT "email") AS "users"');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('compiles rollup + sort + first as one query', () => {
|
|
141
|
+
const r = compile('orders | rollup(country, sum(total) as t) | sort(t desc) | first(5)');
|
|
142
|
+
expect(r.ok).toBe(true);
|
|
143
|
+
if (!r.ok) return;
|
|
144
|
+
expect(r.compiled.sql).toContain('GROUP BY "country"');
|
|
145
|
+
expect(r.compiled.sql).toContain('ORDER BY "t" DESC');
|
|
146
|
+
expect(r.compiled.sql).toMatch(/LIMIT 5$/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('compiles where + rollup as WHERE then GROUP BY (correct clause order)', () => {
|
|
150
|
+
const r = compile("orders | where(status == 'paid') | rollup(country, sum(total) as t)");
|
|
151
|
+
expect(r.ok).toBe(true);
|
|
152
|
+
if (!r.ok) return;
|
|
153
|
+
// SQL clause order: WHERE before GROUP BY. This proves the compiler
|
|
154
|
+
// emits clauses in the right order regardless of pipe order.
|
|
155
|
+
const sql = r.compiled.sql;
|
|
156
|
+
expect(sql.indexOf('WHERE')).toBeLessThan(sql.indexOf('GROUP BY'));
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('declines where AFTER rollup (would need HAVING)', () => {
|
|
160
|
+
const r = compile('orders | rollup(country, sum(total) as t) | where(t > 100)');
|
|
161
|
+
expect(r.ok).toBe(false);
|
|
162
|
+
if (r.ok) return;
|
|
163
|
+
expect(r.reason).toMatch(/HAVING|grouping/i);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('declines rollup with a non-field key', () => {
|
|
167
|
+
const r = compile('orders | rollup(total * 2, sum(amount) as t)');
|
|
168
|
+
expect(r.ok).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('declines unsupported aggregate functions', () => {
|
|
172
|
+
const r = compile('orders | rollup(percentile(amount, 0.95) as p95)');
|
|
173
|
+
expect(r.ok).toBe(false);
|
|
174
|
+
if (r.ok) return;
|
|
175
|
+
expect(r.reason).toMatch(/aggregate|expression/i);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('declines two grouping ops in one pipeline', () => {
|
|
179
|
+
const r = compile('orders | rollup(sum(total) as t) | rollup(sum(total) as t)');
|
|
180
|
+
expect(r.ok).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('declines rollup after select', () => {
|
|
184
|
+
const r = compile('orders | select(country, total) | rollup(country, sum(total) as t)');
|
|
185
|
+
expect(r.ok).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('compilePostgresPushdown — pipeline-terminal aggregate', () => {
|
|
190
|
+
it('compiles count() to COUNT(*)', () => {
|
|
191
|
+
const r = compile('orders | count()');
|
|
192
|
+
expect(r.ok).toBe(true);
|
|
193
|
+
if (!r.ok) return;
|
|
194
|
+
expect(r.compiled.sql).toMatch(/^SELECT COUNT\(\*\) AS "count" FROM/);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('compiles sum(field) to SUM("field")', () => {
|
|
198
|
+
const r = compile('orders | sum(total)');
|
|
199
|
+
expect(r.ok).toBe(true);
|
|
200
|
+
if (!r.ok) return;
|
|
201
|
+
expect(r.compiled.sql).toMatch(/^SELECT SUM\("total"\) AS "sum" FROM/);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('declines unsupported aggregate at pipeline level', () => {
|
|
205
|
+
const r = compile('orders | percentile(total, 0.95)');
|
|
206
|
+
expect(r.ok).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('compilePostgresPushdown — declines for unsupported shapes', () => {
|
|
211
|
+
it('declines unknown operators (e.g. flatten)', () => {
|
|
212
|
+
const r = compile('orders | flatten(items)');
|
|
213
|
+
expect(r.ok).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('declines multi-segment field paths', () => {
|
|
217
|
+
const r = compile('orders | where(meta.country == "US")');
|
|
218
|
+
expect(r.ok).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('compilePostgresPushdown — security', () => {
|
|
223
|
+
it('parameterizes hostile string literals (no SQL injection surface)', () => {
|
|
224
|
+
const r = compile('orders | where(customer == "Robert\'); DROP TABLE orders; --")');
|
|
225
|
+
expect(r.ok).toBe(true);
|
|
226
|
+
if (!r.ok) return;
|
|
227
|
+
// The malicious string is bound, not concatenated.
|
|
228
|
+
expect(r.compiled.sql).not.toContain('DROP TABLE');
|
|
229
|
+
expect(r.compiled.params).toContain("Robert'); DROP TABLE orders; --");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('escapes embedded double quotes in identifiers', () => {
|
|
233
|
+
// `orders | sort(weird"name desc)` — the parser may not accept this,
|
|
234
|
+
// but if a malformed FieldAccess ever made it in, identifier-quoting
|
|
235
|
+
// doubles internal quotes per Postgres rules. Guard it here so the
|
|
236
|
+
// helper can't ever emit unescaped quotes.
|
|
237
|
+
const r = compile('orders | first(1)');
|
|
238
|
+
expect(r.ok).toBe(true);
|
|
239
|
+
});
|
|
240
|
+
});
|