@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,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,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST source — env-var interpolation in url / headers / params,
|
|
3
|
+
* plus auth: bearer helper. Issue #37 (slice).
|
|
4
|
+
*
|
|
5
|
+
* Spins up a real http server in-process so we exercise the actual
|
|
6
|
+
* outbound request shape rather than mocking globalThis.fetch.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
9
|
+
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
10
|
+
import type { AddressInfo } from 'node:net';
|
|
11
|
+
import { RestSourceAdapter } from '../src/server/sources/rest.js';
|
|
12
|
+
|
|
13
|
+
interface Captured {
|
|
14
|
+
url: string;
|
|
15
|
+
headers: Record<string, string | string[] | undefined>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Harness {
|
|
19
|
+
url: string;
|
|
20
|
+
captured: Captured[];
|
|
21
|
+
/** Body the server returns for the next request. Default: empty array. */
|
|
22
|
+
setResponse(body: unknown, status?: number): void;
|
|
23
|
+
close: () => Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function makeServer(): Promise<Harness> {
|
|
27
|
+
const captured: Captured[] = [];
|
|
28
|
+
let nextBody: unknown = [];
|
|
29
|
+
let nextStatus = 200;
|
|
30
|
+
|
|
31
|
+
const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
32
|
+
captured.push({ url: req.url ?? '', headers: req.headers });
|
|
33
|
+
res.statusCode = nextStatus;
|
|
34
|
+
res.setHeader('content-type', 'application/json');
|
|
35
|
+
res.end(JSON.stringify(nextBody));
|
|
36
|
+
});
|
|
37
|
+
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
|
|
38
|
+
const port = (server.address() as AddressInfo).port;
|
|
39
|
+
return {
|
|
40
|
+
url: `http://127.0.0.1:${port}`,
|
|
41
|
+
captured,
|
|
42
|
+
setResponse(body, status = 200) {
|
|
43
|
+
nextBody = body;
|
|
44
|
+
nextStatus = status;
|
|
45
|
+
},
|
|
46
|
+
close: () => new Promise<void>((r) => server.close(() => r())),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('REST source — env interpolation + auth: bearer (#37 slice)', () => {
|
|
51
|
+
let server: Harness;
|
|
52
|
+
let envBackup: Record<string, string | undefined>;
|
|
53
|
+
|
|
54
|
+
beforeEach(async () => {
|
|
55
|
+
server = await makeServer();
|
|
56
|
+
envBackup = {
|
|
57
|
+
MY_TOKEN: process.env.MY_TOKEN,
|
|
58
|
+
MY_KEY: process.env.MY_KEY,
|
|
59
|
+
USER_PATH: process.env.USER_PATH,
|
|
60
|
+
MY_REGION: process.env.MY_REGION,
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
afterEach(async () => {
|
|
64
|
+
await server.close();
|
|
65
|
+
// Restore env
|
|
66
|
+
for (const [k, v] of Object.entries(envBackup)) {
|
|
67
|
+
if (v === undefined) delete process.env[k];
|
|
68
|
+
else process.env[k] = v;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('expands ${ENV_VAR} in URL', async () => {
|
|
73
|
+
process.env.USER_PATH = 'users';
|
|
74
|
+
server.setResponse([]);
|
|
75
|
+
const adapter = new RestSourceAdapter({
|
|
76
|
+
type: 'rest',
|
|
77
|
+
url: `${server.url}/api/\${USER_PATH}`,
|
|
78
|
+
});
|
|
79
|
+
await adapter.start();
|
|
80
|
+
adapter.stop();
|
|
81
|
+
expect(server.captured[0].url).toBe('/api/users');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('expands ${ENV_VAR} in headers', async () => {
|
|
85
|
+
process.env.MY_KEY = 'sk-secret-abc';
|
|
86
|
+
server.setResponse([]);
|
|
87
|
+
const adapter = new RestSourceAdapter({
|
|
88
|
+
type: 'rest',
|
|
89
|
+
url: server.url,
|
|
90
|
+
headers: { 'X-API-Key': '${MY_KEY}', 'X-Static': 'literal' },
|
|
91
|
+
});
|
|
92
|
+
await adapter.start();
|
|
93
|
+
adapter.stop();
|
|
94
|
+
expect(server.captured[0].headers['x-api-key']).toBe('sk-secret-abc');
|
|
95
|
+
expect(server.captured[0].headers['x-static']).toBe('literal');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('expands ${ENV_VAR} in query params and merges with existing query string', async () => {
|
|
99
|
+
process.env.MY_REGION = 'eu-west-1';
|
|
100
|
+
server.setResponse([]);
|
|
101
|
+
const adapter = new RestSourceAdapter({
|
|
102
|
+
type: 'rest',
|
|
103
|
+
url: `${server.url}/api?fixed=1`,
|
|
104
|
+
params: { region: '${MY_REGION}', kind: 'live' },
|
|
105
|
+
});
|
|
106
|
+
await adapter.start();
|
|
107
|
+
adapter.stop();
|
|
108
|
+
// Query string contains both the inline ?fixed=1 and the appended params,
|
|
109
|
+
// joined with `&`. URLSearchParams encodes dashes as-is.
|
|
110
|
+
const seen = server.captured[0].url;
|
|
111
|
+
expect(seen).toContain('fixed=1');
|
|
112
|
+
expect(seen).toContain('region=eu-west-1');
|
|
113
|
+
expect(seen).toContain('kind=live');
|
|
114
|
+
expect(seen.startsWith('/api?')).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('emits empty string and a warning for missing env vars (does not embed literal "${FOO}")', async () => {
|
|
118
|
+
// No MY_TOKEN set
|
|
119
|
+
delete process.env.MY_TOKEN;
|
|
120
|
+
server.setResponse([]);
|
|
121
|
+
const stderrCaptured: string[] = [];
|
|
122
|
+
const origWrite = process.stderr.write.bind(process.stderr);
|
|
123
|
+
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
124
|
+
stderrCaptured.push(typeof chunk === 'string' ? chunk : chunk.toString());
|
|
125
|
+
return true;
|
|
126
|
+
}) as typeof process.stderr.write;
|
|
127
|
+
|
|
128
|
+
const adapter = new RestSourceAdapter({
|
|
129
|
+
type: 'rest',
|
|
130
|
+
url: server.url,
|
|
131
|
+
headers: { Authorization: 'Bearer ${MY_TOKEN}' },
|
|
132
|
+
});
|
|
133
|
+
try {
|
|
134
|
+
await adapter.start();
|
|
135
|
+
adapter.stop();
|
|
136
|
+
} finally {
|
|
137
|
+
process.stderr.write = origWrite;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The literal "${...}" must NOT appear in the outbound header. Node
|
|
141
|
+
// trims trailing whitespace from header values on the wire, so the
|
|
142
|
+
// received value is "Bearer" not "Bearer ".
|
|
143
|
+
const auth = server.captured[0].headers['authorization'] as string;
|
|
144
|
+
expect(auth).not.toContain('${');
|
|
145
|
+
expect(auth.startsWith('Bearer')).toBe(true);
|
|
146
|
+
expect(stderrCaptured.some((s) => s.includes('MY_TOKEN'))).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('auth: bearer adds Authorization: Bearer <token> with env interpolation', async () => {
|
|
150
|
+
process.env.MY_TOKEN = 'jwt.abc.123';
|
|
151
|
+
server.setResponse([]);
|
|
152
|
+
const adapter = new RestSourceAdapter({
|
|
153
|
+
type: 'rest',
|
|
154
|
+
url: server.url,
|
|
155
|
+
auth: { kind: 'bearer', token: '${MY_TOKEN}' },
|
|
156
|
+
});
|
|
157
|
+
await adapter.start();
|
|
158
|
+
adapter.stop();
|
|
159
|
+
expect(server.captured[0].headers['authorization']).toBe('Bearer jwt.abc.123');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('auth: bearer wins over a hand-rolled headers.Authorization on collision', async () => {
|
|
163
|
+
// If a user sets both, they almost certainly meant the auth helper —
|
|
164
|
+
// bearer is the one that actually plumbs the token through env vars.
|
|
165
|
+
process.env.MY_TOKEN = 'jwt.xyz';
|
|
166
|
+
server.setResponse([]);
|
|
167
|
+
const adapter = new RestSourceAdapter({
|
|
168
|
+
type: 'rest',
|
|
169
|
+
url: server.url,
|
|
170
|
+
headers: { Authorization: 'Bearer should-be-overridden' },
|
|
171
|
+
auth: { kind: 'bearer', token: '${MY_TOKEN}' },
|
|
172
|
+
});
|
|
173
|
+
await adapter.start();
|
|
174
|
+
adapter.stop();
|
|
175
|
+
expect(server.captured[0].headers['authorization']).toBe('Bearer jwt.xyz');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('back-compat: no env vars + no auth → behaves exactly like before', async () => {
|
|
179
|
+
server.setResponse([{ id: 1 }, { id: 2 }]);
|
|
180
|
+
const adapter = new RestSourceAdapter({
|
|
181
|
+
type: 'rest',
|
|
182
|
+
url: server.url,
|
|
183
|
+
headers: { 'X-Custom': 'value' },
|
|
184
|
+
});
|
|
185
|
+
await adapter.start();
|
|
186
|
+
expect(adapter.getData()).toEqual([{ id: 1 }, { id: 2 }]);
|
|
187
|
+
expect(server.captured[0].headers['x-custom']).toBe('value');
|
|
188
|
+
expect(server.captured[0].headers['authorization']).toBeUndefined();
|
|
189
|
+
adapter.stop();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -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
|
+
});
|