@vaultgradient/pipequery-cli 0.7.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +98 -13
  2. package/__tests__/clickhouse-pushdown-compile.test.ts +121 -0
  3. package/__tests__/event-log.test.ts +267 -0
  4. package/__tests__/mongo-pushdown-compile.test.ts +160 -0
  5. package/__tests__/mysql-pushdown-compile.test.ts +122 -0
  6. package/__tests__/mysql-pushdown.smoke.test.ts +77 -0
  7. package/__tests__/postgres-pushdown-compile.test.ts +240 -0
  8. package/__tests__/postgres-pushdown.smoke.test.ts +158 -0
  9. package/__tests__/pushdown-routing.test.ts +144 -0
  10. package/__tests__/snowflake-pushdown-compile.test.ts +74 -0
  11. package/dist/index.js +3642 -2172
  12. package/package.json +12 -2
  13. package/src/commands/telegram.ts +10 -0
  14. package/src/config/schema.ts +68 -1
  15. package/src/index.ts +2 -1
  16. package/src/mcp/provider.ts +4 -7
  17. package/src/server/routes/control.ts +1 -3
  18. package/src/server/routes/dynamic.ts +2 -4
  19. package/src/server/sources/clickhouse.ts +153 -0
  20. package/src/server/sources/manager.ts +71 -1
  21. package/src/server/sources/mongodb.ts +165 -0
  22. package/src/server/sources/mysql.ts +44 -0
  23. package/src/server/sources/postgres.ts +50 -0
  24. package/src/server/sources/pushdown/clickhouse.ts +17 -0
  25. package/src/server/sources/pushdown/compile.ts +328 -0
  26. package/src/server/sources/pushdown/dialect.ts +88 -0
  27. package/src/server/sources/pushdown/mongodb.ts +450 -0
  28. package/src/server/sources/pushdown/mysql.ts +17 -0
  29. package/src/server/sources/pushdown/postgres.ts +19 -0
  30. package/src/server/sources/pushdown/snowflake.ts +17 -0
  31. package/src/server/sources/snowflake.ts +158 -0
  32. package/src/server/sources/types.ts +27 -0
  33. package/src/telegram/bot.ts +198 -12
  34. package/src/telegram/event-log.ts +234 -0
  35. package/tsup.config.ts +1 -1
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Live smoke test for the Postgres push-down prototype.
3
+ *
4
+ * Requires a local Postgres on 127.0.0.1:5432 with a `pq_pd` database, a
5
+ * `pq_user` role (password `pq_pw`), and an `orders` table seeded with the
6
+ * fixtures the test below expects. The seeding lives outside the test on
7
+ * purpose — push-down is a "does the round trip work end-to-end" thing,
8
+ * not a unit-test concern.
9
+ *
10
+ * Skipped by default so CI (and contributors without a local Postgres)
11
+ * stay green. To run:
12
+ *
13
+ * POSTGRES_PUSHDOWN_SMOKE=1 npm test
14
+ *
15
+ * Or point at a different instance / credentials via POSTGRES_PUSHDOWN_URL.
16
+ */
17
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
18
+ import { PostgresSourceAdapter } from '../src/server/sources/postgres.js';
19
+
20
+ const URL = process.env.POSTGRES_PUSHDOWN_URL ?? 'postgres://pq_user:pq_pw@127.0.0.1:5432/pq_pd';
21
+ const ENABLED = process.env.POSTGRES_PUSHDOWN_SMOKE === '1';
22
+
23
+ describe.skipIf(!ENABLED)('postgres pushdown — live', () => {
24
+ let adapter: PostgresSourceAdapter;
25
+
26
+ beforeAll(async () => {
27
+ adapter = new PostgresSourceAdapter({
28
+ type: 'postgres',
29
+ url: URL,
30
+ query: 'SELECT * FROM orders',
31
+ ssl: false,
32
+ });
33
+ await adapter.start();
34
+ });
35
+
36
+ afterAll(() => {
37
+ adapter.stop();
38
+ });
39
+
40
+ it('pushes down where + sort + first into one Postgres query', async () => {
41
+ const r = await adapter.runPushdown(
42
+ "orders | where(status == 'paid' && amount > 100) | sort(amount desc) | first(2)",
43
+ );
44
+ expect(r.ok).toBe(true);
45
+ if (!r.ok) return;
46
+
47
+ // Verify the SQL shape — these are the assertions that prove "push-down,
48
+ // not in-memory filter": the WHERE / ORDER BY / LIMIT clauses must be
49
+ // present in the SQL we sent to Postgres.
50
+ expect(r.sql).toMatch(/SELECT \* FROM \(SELECT \* FROM orders\) AS pq_src/);
51
+ expect(r.sql).toMatch(/WHERE.*"status".*=.*\$1.*AND.*"amount".*>.*\$2/i);
52
+ expect(r.sql).toMatch(/ORDER BY "amount" DESC/);
53
+ expect(r.sql).toMatch(/LIMIT 2$/);
54
+ expect(r.params).toEqual(['paid', 100]);
55
+
56
+ // Result: two highest-amount paid orders. Frank ($1500) and Carol ($899.99).
57
+ expect(r.rows).toHaveLength(2);
58
+ expect((r.rows[0] as { customer: string }).customer).toBe('Frank');
59
+ expect((r.rows[1] as { customer: string }).customer).toBe('Carol');
60
+ });
61
+
62
+ it('declines push-down when the pipeline contains an unsupported op', async () => {
63
+ const r = await adapter.runPushdown('orders | groupBy(status) | rollup(sum(amount) as total)');
64
+ expect(r.ok).toBe(false);
65
+ if (r.ok) return;
66
+ expect(r.reason).toMatch(/GroupByOp|not yet pushable/);
67
+ });
68
+
69
+ it('handles NULL semantics correctly', async () => {
70
+ const r = await adapter.runPushdown("orders | where(customer != null)");
71
+ expect(r.ok).toBe(true);
72
+ if (!r.ok) return;
73
+ expect(r.sql).toMatch(/"customer" IS NOT NULL/);
74
+ });
75
+
76
+ it('parameterizes string literals (no SQL injection surface)', async () => {
77
+ const r = await adapter.runPushdown(
78
+ "orders | where(customer == \"Robert'); DROP TABLE orders; --\")",
79
+ );
80
+ expect(r.ok).toBe(true);
81
+ if (!r.ok) return;
82
+ // The malicious string is bound as $1, not concatenated. SQL stays clean.
83
+ expect(r.sql).not.toContain('DROP TABLE');
84
+ expect(r.params).toEqual(["Robert'); DROP TABLE orders; --"]);
85
+ expect(r.rows).toHaveLength(0);
86
+
87
+ // Confirm the table is still intact afterwards.
88
+ const sanity = await adapter.runPushdown('orders | first(1)');
89
+ expect(sanity.ok).toBe(true);
90
+ if (sanity.ok) expect(sanity.rows.length).toBe(1);
91
+ });
92
+
93
+ it('pushes down rollup with GROUP BY + aggregates', async () => {
94
+ const r = await adapter.runPushdown(
95
+ "orders | rollup(status, sum(amount) as total, count() as n) | sort(total desc)",
96
+ );
97
+ expect(r.ok).toBe(true);
98
+ if (!r.ok) return;
99
+ expect(r.sql).toContain('GROUP BY "status"');
100
+ expect(r.sql).toContain('SUM("amount") AS "total"');
101
+ expect(r.sql).toContain('COUNT(*) AS "n"');
102
+ expect(r.sql).toContain('ORDER BY "total" DESC');
103
+ // Each row carries the aggregate columns we asked for.
104
+ expect(r.rows.length).toBeGreaterThan(0);
105
+ expect(r.rows[0]).toHaveProperty('status');
106
+ expect(r.rows[0]).toHaveProperty('total');
107
+ expect(r.rows[0]).toHaveProperty('n');
108
+ });
109
+
110
+ it('pushes down a single-row aggregate (no GROUP BY)', async () => {
111
+ const r = await adapter.runPushdown('orders | rollup(sum(amount) as total)');
112
+ expect(r.ok).toBe(true);
113
+ if (!r.ok) return;
114
+ expect(r.sql).not.toContain('GROUP BY');
115
+ expect(r.rows).toHaveLength(1);
116
+ expect((r.rows[0] as { total: unknown }).total).not.toBeNull();
117
+ });
118
+
119
+ it('pushes down pipeline-terminal count() to COUNT(*)', async () => {
120
+ const r = await adapter.runPushdown('orders | count()');
121
+ expect(r.ok).toBe(true);
122
+ if (!r.ok) return;
123
+ expect(r.sql).toMatch(/^SELECT COUNT\(\*\) AS "count" FROM/);
124
+ expect(r.rows).toHaveLength(1);
125
+ });
126
+
127
+ it('pushes down select with alias', async () => {
128
+ const r = await adapter.runPushdown('orders | select(customer as name, amount) | first(3)');
129
+ expect(r.ok).toBe(true);
130
+ if (!r.ok) return;
131
+ expect(r.sql).toContain('"customer" AS "name"');
132
+ expect(r.rows.length).toBeLessThanOrEqual(3);
133
+ // Result rows have the alias, not the original column name.
134
+ if (r.rows.length > 0) {
135
+ expect(r.rows[0]).toHaveProperty('name');
136
+ expect(r.rows[0]).not.toHaveProperty('customer');
137
+ }
138
+ });
139
+
140
+ it('pushes down full-row distinct', async () => {
141
+ const r = await adapter.runPushdown('orders | select(status) | distinct()');
142
+ expect(r.ok).toBe(true);
143
+ if (!r.ok) return;
144
+ expect(r.sql).toMatch(/^SELECT DISTINCT "status" FROM/);
145
+ });
146
+
147
+ it('declines distinct(field) — DISTINCT ON not portable', async () => {
148
+ const r = await adapter.runPushdown('orders | distinct(status)');
149
+ expect(r.ok).toBe(false);
150
+ if (r.ok) return;
151
+ expect(r.reason).toMatch(/distinct/i);
152
+ });
153
+
154
+ it('still declines unsupported aggregates (e.g. percentile)', async () => {
155
+ const r = await adapter.runPushdown('orders | rollup(percentile(amount, 0.95) as p95)');
156
+ expect(r.ok).toBe(false);
157
+ });
158
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Auto-routing tests for SourceManager.runQuery — the "try push-down,
3
+ * fall back to in-process" decision tree introduced for issue #29 follow-up.
4
+ *
5
+ * No real Postgres needed: we register sources whose adapters expose a
6
+ * fake `runPushdown` so the routing logic is testable in isolation.
7
+ *
8
+ * `addSource()` only constructs adapters from `SourceConfig.type`, so we
9
+ * use a `static` source as the carrier and reach in to attach a
10
+ * `runPushdown` after the fact. That lets us exercise the manager's
11
+ * dispatch surface without inventing a new public extensibility hook.
12
+ */
13
+ import { describe, expect, it } from 'vitest';
14
+ import { SourceManager } from '../src/server/sources/manager.js';
15
+ import type { PushdownResult, SourceAdapter } from '../src/server/sources/types.js';
16
+
17
+ interface SourceWithAdapter {
18
+ adapter: SourceAdapter;
19
+ }
20
+
21
+ /**
22
+ * Attach a fake `runPushdown` to an already-registered source's adapter.
23
+ * Returns a tracker so tests can assert call shapes.
24
+ */
25
+ function attachFakePushdown(
26
+ sm: SourceManager,
27
+ name: string,
28
+ impl: (expression: string) => Promise<PushdownResult>,
29
+ ): { calls: string[] } {
30
+ const calls: string[] = [];
31
+ // Reach into the private map. This is test-only; production callers go
32
+ // through the typed methods. Keeps the test from inventing a public
33
+ // extensibility hook that production code wouldn't use.
34
+ const sources = (sm as unknown as { sources: Map<string, SourceWithAdapter> }).sources;
35
+ const entry = sources.get(name);
36
+ if (!entry) throw new Error(`source "${name}" not registered`);
37
+ entry.adapter.runPushdown = async (expression: string) => {
38
+ calls.push(expression);
39
+ return impl(expression);
40
+ };
41
+ return { calls };
42
+ }
43
+
44
+ describe('SourceManager.runQuery — push-down auto-routing', () => {
45
+ it('uses push-down when the adapter accepts the expression', async () => {
46
+ const sm = new SourceManager(process.cwd());
47
+ await sm.addSource('orders', {
48
+ type: 'static',
49
+ data: [{ id: 1, total: 10 }],
50
+ });
51
+ const tracker = attachFakePushdown(sm, 'orders', async () => ({
52
+ ok: true,
53
+ rows: [{ id: 99, pushed: true }],
54
+ sql: 'SELECT * FROM orders WHERE total > 0',
55
+ }));
56
+
57
+ const rows = await sm.runQuery('orders | where(total > 0)');
58
+
59
+ expect(rows).toEqual([{ id: 99, pushed: true }]);
60
+ expect(tracker.calls).toEqual(['orders | where(total > 0)']);
61
+ await sm.dispose();
62
+ });
63
+
64
+ it('falls back to in-process when the adapter declines', async () => {
65
+ const sm = new SourceManager(process.cwd());
66
+ await sm.addSource('orders', {
67
+ type: 'static',
68
+ data: [{ total: 1 }, { total: 2 }, { total: 3 }],
69
+ });
70
+ const tracker = attachFakePushdown(sm, 'orders', async () => ({
71
+ ok: false,
72
+ reason: 'simulated decline',
73
+ }));
74
+
75
+ const rows = (await sm.runQuery('orders | rollup(sum(total) as t)')) as Array<{ t: number }>;
76
+
77
+ expect(tracker.calls).toHaveLength(1); // adapter was asked
78
+ expect(rows[0].t).toBe(6); // but in-process actually computed the result
79
+ await sm.dispose();
80
+ });
81
+
82
+ it('falls back to in-process when the adapter throws', async () => {
83
+ const sm = new SourceManager(process.cwd());
84
+ await sm.addSource('orders', {
85
+ type: 'static',
86
+ data: [{ total: 5 }, { total: 7 }],
87
+ });
88
+ const tracker = attachFakePushdown(sm, 'orders', async () => {
89
+ throw new Error('connection lost');
90
+ });
91
+
92
+ // Push-down threw, so we fall back to in-process; the in-process result
93
+ // proves the fallback fired and computed correctly.
94
+ const rows = (await sm.runQuery('orders | rollup(sum(total) as t)')) as Array<{ t: number }>;
95
+ expect(tracker.calls).toHaveLength(1);
96
+ expect(rows[0].t).toBe(12);
97
+ await sm.dispose();
98
+ });
99
+
100
+ it('skips push-down entirely when the adapter has no runPushdown', async () => {
101
+ const sm = new SourceManager(process.cwd());
102
+ // No fake attached → entry.adapter.runPushdown is undefined.
103
+ await sm.addSource('orders', {
104
+ type: 'static',
105
+ data: [{ total: 1 }, { total: 2 }],
106
+ });
107
+
108
+ const rows = (await sm.runQuery('orders | rollup(sum(total) as t)')) as Array<{ t: number }>;
109
+ expect(rows[0].t).toBe(3);
110
+ await sm.dispose();
111
+ });
112
+
113
+ it('skips push-down when the named source does not exist (in-process raises)', async () => {
114
+ const sm = new SourceManager(process.cwd());
115
+ // No source registered. tryPushdown returns null; runQuery falls
116
+ // through to in-process, which throws the canonical "source not found"
117
+ // engine error. We verify the fall-through happened by observing that
118
+ // the error is the engine's, not a push-down-specific one.
119
+ await expect(sm.runQuery('ghost | first(1)')).rejects.toThrow();
120
+ await sm.dispose();
121
+ });
122
+
123
+ it('non-pipe expressions still parse-fail through the in-process path', async () => {
124
+ const sm = new SourceManager(process.cwd());
125
+ await sm.addSource('orders', { type: 'static', data: [] });
126
+ // tryPushdown swallows the parse error and returns null; in-process
127
+ // re-parses and surfaces the engine's error message — same as before
128
+ // push-down existed.
129
+ await expect(sm.runQuery('this is not a pipe expression')).rejects.toThrow();
130
+ await sm.dispose();
131
+ });
132
+
133
+ it('tryPushdown returns null when push-down is not even attempted', async () => {
134
+ const sm = new SourceManager(process.cwd());
135
+ await sm.addSource('orders', { type: 'static', data: [{ id: 1 }] });
136
+ // No runPushdown attached → null (signals "not attempted").
137
+ expect(await sm.tryPushdown('orders | first(1)')).toBeNull();
138
+ // Source not found → null.
139
+ expect(await sm.tryPushdown('ghost | first(1)')).toBeNull();
140
+ // Bad parse → null.
141
+ expect(await sm.tryPushdown('totally not pipequery')).toBeNull();
142
+ await sm.dispose();
143
+ });
144
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Unit tests for compileSnowflakePushdown — no Snowflake account needed.
3
+ *
4
+ * Snowflake reuses the dialect-parametric compiler with `"x"` quoting (like
5
+ * Postgres) and `?` placeholders (like MySQL). These tests focus on the
6
+ * Snowflake-specific surface plus a couple of canary cases proving the
7
+ * operator semantics carry across dialects unchanged.
8
+ */
9
+ import { describe, expect, it } from 'vitest';
10
+ import { parseQuery } from '../src/engine.js';
11
+ import { compileSnowflakePushdown } from '../src/server/sources/pushdown/snowflake.js';
12
+
13
+ const BASE = 'SELECT * FROM ORDERS';
14
+
15
+ function compile(expression: string) {
16
+ const pipeline = parseQuery(expression);
17
+ return compileSnowflakePushdown(pipeline, BASE);
18
+ }
19
+
20
+ describe('compileSnowflakePushdown — dialect surface', () => {
21
+ it('quotes identifiers with double quotes', () => {
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 ? positional placeholders', () => {
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
+
39
+ describe('compileSnowflakePushdown — operator parity with other dialects', () => {
40
+ it('rollup with GROUP BY + aggregates', () => {
41
+ const r = compile('orders | rollup(country, sum(total) as revenue) | sort(revenue desc) | first(5)');
42
+ expect(r.ok).toBe(true);
43
+ if (!r.ok) return;
44
+ expect(r.compiled.sql).toContain('SUM("total") AS "revenue"');
45
+ expect(r.compiled.sql).toContain('GROUP BY "country"');
46
+ expect(r.compiled.sql).toContain('ORDER BY "revenue" DESC');
47
+ expect(r.compiled.sql).toMatch(/LIMIT 5$/);
48
+ });
49
+
50
+ it('translates count() and distinct_count', () => {
51
+ const r1 = compile('orders | count()');
52
+ expect(r1.ok).toBe(true);
53
+ if (r1.ok) expect(r1.compiled.sql).toContain('COUNT(*) AS "count"');
54
+
55
+ const r2 = compile('orders | rollup(distinct_count(email) as users)');
56
+ expect(r2.ok).toBe(true);
57
+ if (r2.ok) expect(r2.compiled.sql).toContain('COUNT(DISTINCT "email") AS "users"');
58
+ });
59
+
60
+ it('NULL semantics → IS NULL / IS NOT NULL', () => {
61
+ const r = compile('orders | where(customer != null)');
62
+ expect(r.ok).toBe(true);
63
+ if (!r.ok) return;
64
+ expect(r.compiled.sql).toContain('"customer" IS NOT NULL');
65
+ });
66
+
67
+ it('parameterizes hostile string literals', () => {
68
+ const r = compile('orders | where(customer == "Robert\'); DROP TABLE orders; --")');
69
+ expect(r.ok).toBe(true);
70
+ if (!r.ok) return;
71
+ expect(r.compiled.sql).not.toContain('DROP TABLE');
72
+ expect(r.compiled.params).toContain("Robert'); DROP TABLE orders; --");
73
+ });
74
+ });