@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,165 @@
|
|
|
1
|
+
import { MongoClient, type Db, type Collection } from 'mongodb';
|
|
2
|
+
import type { MongoSourceConfig } from '../../config/schema.js';
|
|
3
|
+
import { parseDuration } from '../../utils/parseDuration.js';
|
|
4
|
+
import { expandEnv } from '../../utils/expandEnv.js';
|
|
5
|
+
import { parseQuery } from '../../engine.js';
|
|
6
|
+
import { compileMongoPushdown, type MongoPlan } from './pushdown/mongodb.js';
|
|
7
|
+
import type { SourceAdapter, SourceStatus } from './types.js';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_INTERVAL_MS = 30_000;
|
|
10
|
+
const DEFAULT_MAX_ROWS = 10_000;
|
|
11
|
+
|
|
12
|
+
export class MongoSourceAdapter implements SourceAdapter {
|
|
13
|
+
private client: MongoClient | null = null;
|
|
14
|
+
private db: Db | null = null;
|
|
15
|
+
private collection: Collection | null = null;
|
|
16
|
+
private data: unknown[] = [];
|
|
17
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
18
|
+
private lastFetch: Date | null = null;
|
|
19
|
+
private error?: string;
|
|
20
|
+
private listeners = new Set<(data: unknown[]) => void>();
|
|
21
|
+
private readonly intervalMs: number;
|
|
22
|
+
private readonly maxRows: number;
|
|
23
|
+
|
|
24
|
+
constructor(private config: MongoSourceConfig) {
|
|
25
|
+
this.intervalMs = config.interval ? parseDuration(config.interval) : DEFAULT_INTERVAL_MS;
|
|
26
|
+
this.maxRows = config.maxRows ?? DEFAULT_MAX_ROWS;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async start(): Promise<void> {
|
|
30
|
+
const uri = expandEnv(this.config.url, 'mongodb source URL');
|
|
31
|
+
this.client = new MongoClient(uri);
|
|
32
|
+
await this.client.connect();
|
|
33
|
+
this.db = this.client.db(expandEnv(this.config.database, 'mongodb source database'));
|
|
34
|
+
this.collection = this.db.collection(this.config.collection);
|
|
35
|
+
|
|
36
|
+
await this.fetch();
|
|
37
|
+
this.timer = setInterval(() => {
|
|
38
|
+
void this.fetch();
|
|
39
|
+
}, this.intervalMs);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
stop(): void {
|
|
43
|
+
if (this.timer) {
|
|
44
|
+
clearInterval(this.timer);
|
|
45
|
+
this.timer = null;
|
|
46
|
+
}
|
|
47
|
+
if (this.client) {
|
|
48
|
+
void this.client.close();
|
|
49
|
+
this.client = null;
|
|
50
|
+
this.db = null;
|
|
51
|
+
this.collection = null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
getData(): unknown[] {
|
|
56
|
+
return this.data;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getStatus(): SourceStatus {
|
|
60
|
+
return {
|
|
61
|
+
healthy: !this.error,
|
|
62
|
+
rowCount: this.data.length,
|
|
63
|
+
lastFetch: this.lastFetch,
|
|
64
|
+
error: this.error,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
onUpdate(callback: (data: unknown[]) => void): () => void {
|
|
69
|
+
this.listeners.add(callback);
|
|
70
|
+
return () => this.listeners.delete(callback);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async refresh(): Promise<void> {
|
|
74
|
+
await this.fetch();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Adapter-native push-down to MongoDB. The compiler emits either a
|
|
79
|
+
* `find()` plan (when the pipeline only uses where/sort/first/select)
|
|
80
|
+
* or an `aggregate()` pipeline (when grouping is involved).
|
|
81
|
+
*
|
|
82
|
+
* The success result's `sql` field carries a JSON serialization of the
|
|
83
|
+
* compiled plan (Mongo doesn't have SQL); `params` is empty since
|
|
84
|
+
* Mongo plans bind values inline as part of the BSON document.
|
|
85
|
+
*/
|
|
86
|
+
async runPushdown(expression: string): Promise<
|
|
87
|
+
| { ok: true; rows: unknown[]; sql: string; params: unknown[] }
|
|
88
|
+
| { ok: false; reason: string }
|
|
89
|
+
> {
|
|
90
|
+
if (!this.collection) {
|
|
91
|
+
return { ok: false, reason: 'Mongo collection not initialized — call start() first' };
|
|
92
|
+
}
|
|
93
|
+
let pipeline;
|
|
94
|
+
try {
|
|
95
|
+
pipeline = parseQuery(expression);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
return { ok: false, reason: `parse error: ${err instanceof Error ? err.message : String(err)}` };
|
|
98
|
+
}
|
|
99
|
+
const compiled = compileMongoPushdown(pipeline, this.config.filter);
|
|
100
|
+
if (!compiled.ok) {
|
|
101
|
+
return { ok: false, reason: compiled.reason };
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const rows = await this.runPlan(compiled.compiled);
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
rows,
|
|
108
|
+
sql: JSON.stringify(compiled.compiled),
|
|
109
|
+
params: [],
|
|
110
|
+
};
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return { ok: false, reason: `mongo error: ${err instanceof Error ? err.message : String(err)}` };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async runPlan(plan: MongoPlan): Promise<unknown[]> {
|
|
117
|
+
const coll = this.collection!;
|
|
118
|
+
if (plan.kind === 'find') {
|
|
119
|
+
let cursor = coll.find(plan.filter);
|
|
120
|
+
if (plan.projection) cursor = cursor.project(plan.projection);
|
|
121
|
+
if (plan.sort) cursor = cursor.sort(plan.sort);
|
|
122
|
+
if (plan.limit !== undefined) cursor = cursor.limit(plan.limit);
|
|
123
|
+
return cursor.toArray();
|
|
124
|
+
}
|
|
125
|
+
// aggregate
|
|
126
|
+
return coll.aggregate(plan.pipeline).toArray();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private async fetch(): Promise<void> {
|
|
130
|
+
if (!this.collection) {
|
|
131
|
+
this.error = 'Mongo collection not initialized';
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
// Apply the optional default filter (yaml-level) plus the maxRows cap.
|
|
136
|
+
const filter = this.config.filter ?? {};
|
|
137
|
+
const docs = await this.collection
|
|
138
|
+
.find(filter)
|
|
139
|
+
.limit(this.maxRows + 1)
|
|
140
|
+
.toArray();
|
|
141
|
+
if (docs.length > this.maxRows) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`Query returned more than maxRows=${this.maxRows}. ` +
|
|
144
|
+
`Tighten the filter, or raise maxRows in the source config.`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
// Strip Mongo's BSON _id by default — most pipequery users don't want it
|
|
148
|
+
// surfacing in result rows. The original is still queryable in source
|
|
149
|
+
// queries that explicitly project it.
|
|
150
|
+
this.data = docs.map((d) => {
|
|
151
|
+
const { _id, ...rest } = d as Record<string, unknown>;
|
|
152
|
+
return rest;
|
|
153
|
+
});
|
|
154
|
+
this.lastFetch = new Date();
|
|
155
|
+
this.error = undefined;
|
|
156
|
+
this.notify();
|
|
157
|
+
} catch (err) {
|
|
158
|
+
this.error = err instanceof Error ? err.message : String(err);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private notify(): void {
|
|
163
|
+
for (const cb of this.listeners) cb(this.data);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -1,24 +1,14 @@
|
|
|
1
1
|
import mysql from 'mysql2/promise';
|
|
2
2
|
import type { MysqlSourceConfig } from '../../config/schema.js';
|
|
3
3
|
import { parseDuration } from '../../utils/parseDuration.js';
|
|
4
|
+
import { expandEnv } from '../../utils/expandEnv.js';
|
|
5
|
+
import { parseQuery } from '../../engine.js';
|
|
6
|
+
import { compileMysqlPushdown } from './pushdown/mysql.js';
|
|
4
7
|
import type { SourceAdapter, SourceStatus } from './types.js';
|
|
5
8
|
|
|
6
9
|
const DEFAULT_INTERVAL_MS = 30_000;
|
|
7
10
|
const DEFAULT_MAX_ROWS = 10_000;
|
|
8
11
|
|
|
9
|
-
function expandEnv(input: string): string {
|
|
10
|
-
return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name: string) => {
|
|
11
|
-
const value = process.env[name];
|
|
12
|
-
if (value === undefined) {
|
|
13
|
-
process.stderr.write(
|
|
14
|
-
`[pipequery] env var "${name}" referenced in mysql source URL is not set\n`,
|
|
15
|
-
);
|
|
16
|
-
return '';
|
|
17
|
-
}
|
|
18
|
-
return value;
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
|
|
22
12
|
function resolveSsl(ssl: MysqlSourceConfig['ssl']): mysql.PoolOptions['ssl'] {
|
|
23
13
|
if (ssl === false) return undefined;
|
|
24
14
|
if (ssl === 'no-verify') return { rejectUnauthorized: false };
|
|
@@ -41,7 +31,7 @@ export class MysqlSourceAdapter implements SourceAdapter {
|
|
|
41
31
|
}
|
|
42
32
|
|
|
43
33
|
async start(): Promise<void> {
|
|
44
|
-
const uri = expandEnv(this.config.url);
|
|
34
|
+
const uri = expandEnv(this.config.url, 'mysql source URL');
|
|
45
35
|
this.pool = mysql.createPool({
|
|
46
36
|
uri,
|
|
47
37
|
ssl: resolveSsl(this.config.ssl),
|
|
@@ -92,6 +82,48 @@ export class MysqlSourceAdapter implements SourceAdapter {
|
|
|
92
82
|
await this.fetch();
|
|
93
83
|
}
|
|
94
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Adapter-native push-down. Compiles the pipe expression to MySQL SQL
|
|
87
|
+
* (see ./pushdown/mysql.ts) and runs it directly. SourceManager.runQuery
|
|
88
|
+
* dispatches here automatically when the pipeline shape is eligible —
|
|
89
|
+
* unsupported shapes return `{ ok: false, reason }` and the caller falls
|
|
90
|
+
* back to the in-process engine.
|
|
91
|
+
*
|
|
92
|
+
* Mirrors PostgresSourceAdapter.runPushdown — same shape, same result
|
|
93
|
+
* type. Both adapters route through the dialect-parametric compiler in
|
|
94
|
+
* compile.ts.
|
|
95
|
+
*/
|
|
96
|
+
async runPushdown(expression: string): Promise<
|
|
97
|
+
| { ok: true; rows: unknown[]; sql: string; params: unknown[] }
|
|
98
|
+
| { ok: false; reason: string }
|
|
99
|
+
> {
|
|
100
|
+
if (!this.pool) {
|
|
101
|
+
return { ok: false, reason: 'MySQL pool not initialized — call start() first' };
|
|
102
|
+
}
|
|
103
|
+
let pipeline;
|
|
104
|
+
try {
|
|
105
|
+
pipeline = parseQuery(expression);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
return { ok: false, reason: `parse error: ${err instanceof Error ? err.message : String(err)}` };
|
|
108
|
+
}
|
|
109
|
+
const compiled = compileMysqlPushdown(pipeline, this.config.query);
|
|
110
|
+
if (!compiled.ok) {
|
|
111
|
+
return { ok: false, reason: compiled.reason };
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const [rows] = await this.pool.query(compiled.compiled.sql, compiled.compiled.params);
|
|
115
|
+
const asArray = Array.isArray(rows) ? (rows as unknown[]) : [];
|
|
116
|
+
return {
|
|
117
|
+
ok: true,
|
|
118
|
+
rows: asArray,
|
|
119
|
+
sql: compiled.compiled.sql,
|
|
120
|
+
params: compiled.compiled.params,
|
|
121
|
+
};
|
|
122
|
+
} catch (err) {
|
|
123
|
+
return { ok: false, reason: `mysql error: ${err instanceof Error ? err.message : String(err)}` };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
95
127
|
private async fetch(): Promise<void> {
|
|
96
128
|
if (!this.pool) {
|
|
97
129
|
this.error = 'MySQL pool not initialized';
|
|
@@ -1,30 +1,14 @@
|
|
|
1
1
|
import pg from 'pg';
|
|
2
2
|
import type { PostgresSourceConfig } from '../../config/schema.js';
|
|
3
3
|
import { parseDuration } from '../../utils/parseDuration.js';
|
|
4
|
+
import { expandEnv } from '../../utils/expandEnv.js';
|
|
4
5
|
import type { SourceAdapter, SourceStatus } from './types.js';
|
|
6
|
+
import { parseQuery } from '../../engine.js';
|
|
7
|
+
import { compilePostgresPushdown } from './pushdown/postgres.js';
|
|
5
8
|
|
|
6
9
|
const DEFAULT_INTERVAL_MS = 30_000;
|
|
7
10
|
const DEFAULT_MAX_ROWS = 10_000;
|
|
8
11
|
|
|
9
|
-
/**
|
|
10
|
-
* Resolve ${ENV_VAR} placeholders in a string from process.env.
|
|
11
|
-
* Missing vars expand to empty string (with a stderr warning) so a
|
|
12
|
-
* mis-typed env doesn't silently embed literal "${FOO}" into a
|
|
13
|
-
* Postgres connection string.
|
|
14
|
-
*/
|
|
15
|
-
function expandEnv(input: string): string {
|
|
16
|
-
return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name: string) => {
|
|
17
|
-
const value = process.env[name];
|
|
18
|
-
if (value === undefined) {
|
|
19
|
-
process.stderr.write(
|
|
20
|
-
`[pipequery] env var "${name}" referenced in postgres source URL is not set\n`,
|
|
21
|
-
);
|
|
22
|
-
return '';
|
|
23
|
-
}
|
|
24
|
-
return value;
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
|
|
28
12
|
function resolveSsl(ssl: PostgresSourceConfig['ssl']): pg.ClientConfig['ssl'] {
|
|
29
13
|
if (ssl === false) return false;
|
|
30
14
|
if (ssl === 'no-verify') return { rejectUnauthorized: false };
|
|
@@ -48,7 +32,7 @@ export class PostgresSourceAdapter implements SourceAdapter {
|
|
|
48
32
|
}
|
|
49
33
|
|
|
50
34
|
async start(): Promise<void> {
|
|
51
|
-
const connectionString = expandEnv(this.config.url);
|
|
35
|
+
const connectionString = expandEnv(this.config.url, 'postgres source URL');
|
|
52
36
|
this.pool = new pg.Pool({
|
|
53
37
|
connectionString,
|
|
54
38
|
ssl: resolveSsl(this.config.ssl),
|
|
@@ -104,6 +88,54 @@ export class PostgresSourceAdapter implements SourceAdapter {
|
|
|
104
88
|
await this.fetch();
|
|
105
89
|
}
|
|
106
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Push-down execution prototype. Compiles a pipequery expression rooted at
|
|
93
|
+
* this source into a single Postgres query (where / sort / first only in
|
|
94
|
+
* v1) and runs it directly against the database — no in-memory
|
|
95
|
+
* materialization of the user's source query, no polling, no ring buffer.
|
|
96
|
+
*
|
|
97
|
+
* Returns:
|
|
98
|
+
* - `{ ok: true, rows, sql, params, fullyPushed: true }` on a clean push-down
|
|
99
|
+
* - `{ ok: false, reason }` when the AST contains operators we don't yet
|
|
100
|
+
* translate. Caller should fall back to the in-process engine on the
|
|
101
|
+
* ring-buffered data instead.
|
|
102
|
+
*
|
|
103
|
+
* This is an opt-in API; the default getData() / fetch() polling loop is
|
|
104
|
+
* unchanged so existing users see no behaviour change.
|
|
105
|
+
*/
|
|
106
|
+
async runPushdown(expression: string): Promise<
|
|
107
|
+
| { ok: true; rows: unknown[]; sql: string; params: unknown[]; fullyPushed: true }
|
|
108
|
+
| { ok: false; reason: string }
|
|
109
|
+
> {
|
|
110
|
+
if (!this.pool) {
|
|
111
|
+
return { ok: false, reason: 'Postgres pool not initialized — call start() first' };
|
|
112
|
+
}
|
|
113
|
+
let pipeline;
|
|
114
|
+
try {
|
|
115
|
+
pipeline = parseQuery(expression);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
return { ok: false, reason: `parse error: ${err instanceof Error ? err.message : String(err)}` };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const compiled = compilePostgresPushdown(pipeline, this.config.query);
|
|
121
|
+
if (!compiled.ok) {
|
|
122
|
+
return { ok: false, reason: compiled.reason };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const res = await this.pool.query(compiled.compiled.sql, compiled.compiled.params);
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
rows: res.rows,
|
|
130
|
+
sql: compiled.compiled.sql,
|
|
131
|
+
params: compiled.compiled.params,
|
|
132
|
+
fullyPushed: true,
|
|
133
|
+
};
|
|
134
|
+
} catch (err) {
|
|
135
|
+
return { ok: false, reason: `postgres error: ${err instanceof Error ? err.message : String(err)}` };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
107
139
|
private async fetch(): Promise<void> {
|
|
108
140
|
if (!this.pool) {
|
|
109
141
|
this.error = 'Postgres pool not initialized';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Pipe-AST → ClickHouse SQL compiler.
|
|
2
|
+
//
|
|
3
|
+
// Thin shim over the dialect-parametric compileForDialect; same operator +
|
|
4
|
+
// aggregate set as Postgres / MySQL / Snowflake. ClickHouse uses
|
|
5
|
+
// backtick-quoted identifiers and `?` positional placeholders — both
|
|
6
|
+
// captured in CLICKHOUSE_DIALECT.
|
|
7
|
+
|
|
8
|
+
import type { Pipeline } from '../../../../../src/engine/types.js';
|
|
9
|
+
import { compileForDialect, type CompileResult } from './compile.js';
|
|
10
|
+
import { CLICKHOUSE_DIALECT } from './dialect.js';
|
|
11
|
+
|
|
12
|
+
export function compileClickhousePushdown(
|
|
13
|
+
pipeline: Pipeline,
|
|
14
|
+
baseQuery: string,
|
|
15
|
+
): CompileResult {
|
|
16
|
+
return compileForDialect(pipeline, baseQuery, CLICKHOUSE_DIALECT);
|
|
17
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// Pipe-AST → SQL compiler (dialect-parametric).
|
|
2
|
+
//
|
|
3
|
+
// Translates a Pipeline AST whose source is a single SQL-backed source into
|
|
4
|
+
// a parameterized SQL query. Every emission goes through a SqlDialect, so
|
|
5
|
+
// adding a new engine reduces to writing a dialect (see dialect.ts).
|
|
6
|
+
//
|
|
7
|
+
// Supported operators:
|
|
8
|
+
// - WhereOp with BinaryExpr / UnaryExpr / FieldAccess / literals
|
|
9
|
+
// - SortOp with FieldAccess criteria
|
|
10
|
+
// - FirstOp → LIMIT N
|
|
11
|
+
// - SelectOp projection (FieldAccess / arithmetic / AliasExpr)
|
|
12
|
+
// - DistinctOp full-row dedup (no DISTINCT ON; portable across dialects)
|
|
13
|
+
// - RollupOp → GROUP BY + aggregates in projection
|
|
14
|
+
// - AggregateOp pipeline-terminal aggregate (e.g. `orders | sum(total)`)
|
|
15
|
+
//
|
|
16
|
+
// Supported aggregate functions: sum, avg, min, max, count, distinct_count.
|
|
17
|
+
// Anything else (median, stddev, percentile, vwap, sharpe, …) declines so
|
|
18
|
+
// the in-process engine handles them — those have no clean portable SQL
|
|
19
|
+
// translation across the dialects we care about.
|
|
20
|
+
//
|
|
21
|
+
// Operator-order rules:
|
|
22
|
+
// - WhereOp must precede grouping (RollupOp / AggregateOp). After
|
|
23
|
+
// grouping, "where" is HAVING — declined for now.
|
|
24
|
+
// - SortOp / FirstOp accepted at any position; the SQL clauses are always
|
|
25
|
+
// emitted at the end so they apply post-grouping when grouping exists.
|
|
26
|
+
// - SelectOp / DistinctOp must come before grouping; grouping owns its
|
|
27
|
+
// own projection.
|
|
28
|
+
// - At most one grouping op (RollupOp xor AggregateOp).
|
|
29
|
+
|
|
30
|
+
import type {
|
|
31
|
+
AggregateFnName,
|
|
32
|
+
Expression,
|
|
33
|
+
Operation,
|
|
34
|
+
Pipeline,
|
|
35
|
+
} from '../../../../../src/engine/types.js';
|
|
36
|
+
import type { SqlDialect } from './dialect.js';
|
|
37
|
+
|
|
38
|
+
export interface CompiledPushdown {
|
|
39
|
+
/** Parameterized SQL using the dialect's placeholder syntax. */
|
|
40
|
+
sql: string;
|
|
41
|
+
/** Values to bind, in order. */
|
|
42
|
+
params: unknown[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type CompileResult =
|
|
46
|
+
| { ok: true; compiled: CompiledPushdown }
|
|
47
|
+
| { ok: false; reason: string };
|
|
48
|
+
|
|
49
|
+
interface CompilerCtx {
|
|
50
|
+
/** Subquery wrapping the user-provided source query. */
|
|
51
|
+
baseSelect: string;
|
|
52
|
+
/** Running parameter array; mutated as we walk. */
|
|
53
|
+
params: unknown[];
|
|
54
|
+
/** Dialect for quoting + placeholder rendering. */
|
|
55
|
+
dialect: SqlDialect;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const BINARY_OP_SQL: Record<string, string | undefined> = {
|
|
59
|
+
'>': '>',
|
|
60
|
+
'>=': '>=',
|
|
61
|
+
'<': '<',
|
|
62
|
+
'<=': '<=',
|
|
63
|
+
'==': '=',
|
|
64
|
+
'!=': '<>',
|
|
65
|
+
'&&': 'AND',
|
|
66
|
+
'||': 'OR',
|
|
67
|
+
'+': '+',
|
|
68
|
+
'-': '-',
|
|
69
|
+
'*': '*',
|
|
70
|
+
'/': '/',
|
|
71
|
+
'%': '%',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Aggregate functions we know how to translate to portable SQL. Both
|
|
76
|
+
* Postgres and MySQL accept these identically — no per-dialect overrides
|
|
77
|
+
* needed yet. The function takes the rendered argument SQL (or `null` for
|
|
78
|
+
* the no-arg `count()` shape) and produces the call site.
|
|
79
|
+
*/
|
|
80
|
+
const SQL_AGGREGATE_FN: Partial<Record<AggregateFnName, (arg: string | null) => string>> = {
|
|
81
|
+
sum: (arg) => `SUM(${arg ?? '*'})`,
|
|
82
|
+
avg: (arg) => `AVG(${arg ?? '*'})`,
|
|
83
|
+
min: (arg) => `MIN(${arg ?? '*'})`,
|
|
84
|
+
max: (arg) => `MAX(${arg ?? '*'})`,
|
|
85
|
+
count: (arg) => `COUNT(${arg ?? '*'})`,
|
|
86
|
+
distinct_count: (arg) => `COUNT(DISTINCT ${arg ?? '*'})`,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export function compileForDialect(
|
|
90
|
+
pipeline: Pipeline,
|
|
91
|
+
baseQuery: string,
|
|
92
|
+
dialect: SqlDialect,
|
|
93
|
+
): CompileResult {
|
|
94
|
+
const ctx: CompilerCtx = {
|
|
95
|
+
baseSelect: stripTrailingSemicolon(baseQuery),
|
|
96
|
+
params: [],
|
|
97
|
+
dialect,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
let where: string | null = null;
|
|
101
|
+
let orderBy: string | null = null;
|
|
102
|
+
let limit: number | null = null;
|
|
103
|
+
let selectClause: string | null = null;
|
|
104
|
+
let distinctClause = false;
|
|
105
|
+
let groupByClause: string | null = null;
|
|
106
|
+
let grouped = false;
|
|
107
|
+
|
|
108
|
+
for (const op of pipeline.operations) {
|
|
109
|
+
switch (op.kind) {
|
|
110
|
+
case 'WhereOp': {
|
|
111
|
+
if (grouped) {
|
|
112
|
+
return { ok: false, reason: 'where after grouping (HAVING) is not yet pushable' };
|
|
113
|
+
}
|
|
114
|
+
const compiled = compileExpr(op.condition, ctx);
|
|
115
|
+
if (compiled === null) {
|
|
116
|
+
return { ok: false, reason: 'where(): unsupported expression node' };
|
|
117
|
+
}
|
|
118
|
+
where = where ? `(${where}) AND (${compiled})` : compiled;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
case 'SortOp': {
|
|
123
|
+
const parts: string[] = [];
|
|
124
|
+
for (const c of op.criteria) {
|
|
125
|
+
if (c.expression.kind !== 'FieldAccess' || c.expression.path.length !== 1) {
|
|
126
|
+
return { ok: false, reason: 'sort(): only single-field paths supported' };
|
|
127
|
+
}
|
|
128
|
+
const ident = ctx.dialect.quoteIdent(c.expression.path[0]);
|
|
129
|
+
parts.push(`${ident} ${c.direction === 'desc' ? 'DESC' : 'ASC'}`);
|
|
130
|
+
}
|
|
131
|
+
orderBy = parts.join(', ');
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
case 'FirstOp': {
|
|
136
|
+
limit = op.count;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
case 'SelectOp': {
|
|
141
|
+
if (grouped) {
|
|
142
|
+
return { ok: false, reason: 'select after rollup is not yet pushable' };
|
|
143
|
+
}
|
|
144
|
+
if (selectClause !== null) {
|
|
145
|
+
return { ok: false, reason: 'multiple select() ops in one pipeline' };
|
|
146
|
+
}
|
|
147
|
+
const parts: string[] = [];
|
|
148
|
+
for (const f of op.fields) {
|
|
149
|
+
const compiled = compileSelectField(f, ctx, false);
|
|
150
|
+
if (compiled === null) {
|
|
151
|
+
return { ok: false, reason: 'select(): unsupported field expression' };
|
|
152
|
+
}
|
|
153
|
+
parts.push(compiled);
|
|
154
|
+
}
|
|
155
|
+
selectClause = parts.join(', ');
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
case 'DistinctOp': {
|
|
160
|
+
if (op.fields !== undefined && op.fields.length > 0) {
|
|
161
|
+
return { ok: false, reason: 'distinct(field) is not yet pushable; full-row distinct works' };
|
|
162
|
+
}
|
|
163
|
+
if (grouped) {
|
|
164
|
+
return { ok: false, reason: 'distinct after grouping is not yet pushable' };
|
|
165
|
+
}
|
|
166
|
+
distinctClause = true;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
case 'RollupOp': {
|
|
171
|
+
if (grouped) {
|
|
172
|
+
return { ok: false, reason: 'multiple grouping ops (rollup/aggregate) in one pipeline' };
|
|
173
|
+
}
|
|
174
|
+
if (selectClause !== null) {
|
|
175
|
+
return { ok: false, reason: 'rollup after select is not yet pushable' };
|
|
176
|
+
}
|
|
177
|
+
const keyParts: string[] = [];
|
|
178
|
+
const groupByParts: string[] = [];
|
|
179
|
+
for (const k of op.keys) {
|
|
180
|
+
if (k.kind !== 'FieldAccess' || k.path.length !== 1) {
|
|
181
|
+
return { ok: false, reason: 'rollup(): keys must be single-field paths' };
|
|
182
|
+
}
|
|
183
|
+
const ident = ctx.dialect.quoteIdent(k.path[0]);
|
|
184
|
+
keyParts.push(ident);
|
|
185
|
+
groupByParts.push(ident);
|
|
186
|
+
}
|
|
187
|
+
const aggParts: string[] = [];
|
|
188
|
+
for (const a of op.aggregates) {
|
|
189
|
+
const compiled = compileSelectField(a, ctx, true);
|
|
190
|
+
if (compiled === null) {
|
|
191
|
+
return { ok: false, reason: 'rollup(): unsupported aggregate expression' };
|
|
192
|
+
}
|
|
193
|
+
aggParts.push(compiled);
|
|
194
|
+
}
|
|
195
|
+
selectClause = [...keyParts, ...aggParts].join(', ');
|
|
196
|
+
groupByClause = groupByParts.length > 0 ? groupByParts.join(', ') : null;
|
|
197
|
+
grouped = true;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
case 'AggregateOp': {
|
|
202
|
+
if (grouped) {
|
|
203
|
+
return { ok: false, reason: 'multiple grouping ops (rollup/aggregate) in one pipeline' };
|
|
204
|
+
}
|
|
205
|
+
if (selectClause !== null) {
|
|
206
|
+
return { ok: false, reason: 'aggregate after select is not yet pushable' };
|
|
207
|
+
}
|
|
208
|
+
const fn = SQL_AGGREGATE_FN[op.function];
|
|
209
|
+
if (!fn) {
|
|
210
|
+
return { ok: false, reason: `aggregate "${op.function}" has no SQL push-down` };
|
|
211
|
+
}
|
|
212
|
+
if (op.args && op.args.length > 0) {
|
|
213
|
+
return { ok: false, reason: `aggregate "${op.function}" with extra args is not yet pushable` };
|
|
214
|
+
}
|
|
215
|
+
const argSql = op.field === undefined ? null : compileExpr(op.field, ctx);
|
|
216
|
+
if (op.field !== undefined && argSql === null) {
|
|
217
|
+
return { ok: false, reason: `aggregate "${op.function}": unsupported argument expression` };
|
|
218
|
+
}
|
|
219
|
+
selectClause = `${fn(argSql)} AS ${ctx.dialect.quoteIdent(op.function)}`;
|
|
220
|
+
grouped = true;
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
default:
|
|
225
|
+
return { ok: false, reason: `${(op as Operation).kind}: not yet pushable` };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let sql = `SELECT ${distinctClause ? 'DISTINCT ' : ''}${selectClause ?? '*'} FROM (${ctx.baseSelect}) AS pq_src`;
|
|
230
|
+
if (where) sql += ` WHERE ${where}`;
|
|
231
|
+
if (groupByClause) sql += ` GROUP BY ${groupByClause}`;
|
|
232
|
+
if (orderBy) sql += ` ORDER BY ${orderBy}`;
|
|
233
|
+
if (limit !== null) sql += ` LIMIT ${limit}`;
|
|
234
|
+
|
|
235
|
+
return { ok: true, compiled: { sql, params: ctx.params } };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ─── Expression compilers ───────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
function compileSelectField(expr: Expression, ctx: CompilerCtx, aggregateContext: boolean): string | null {
|
|
241
|
+
if (expr.kind === 'AliasExpr') {
|
|
242
|
+
const inner = compileSelectField(expr.expression, ctx, aggregateContext);
|
|
243
|
+
if (inner === null) return null;
|
|
244
|
+
return `${inner} AS ${ctx.dialect.quoteIdent(expr.alias)}`;
|
|
245
|
+
}
|
|
246
|
+
if (expr.kind === 'FunctionCall') {
|
|
247
|
+
if (!aggregateContext) return null;
|
|
248
|
+
return compileAggregateCall(expr.name, expr.args, ctx);
|
|
249
|
+
}
|
|
250
|
+
return compileExpr(expr, ctx);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function compileAggregateCall(name: string, args: Expression[], ctx: CompilerCtx): string | null {
|
|
254
|
+
const fn = SQL_AGGREGATE_FN[name as AggregateFnName];
|
|
255
|
+
if (!fn) return null;
|
|
256
|
+
if (args.length === 0) return fn(null);
|
|
257
|
+
if (args.length > 1) return null;
|
|
258
|
+
const argSql = compileExpr(args[0], ctx);
|
|
259
|
+
if (argSql === null) return null;
|
|
260
|
+
return fn(argSql);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function compileExpr(expr: Expression, ctx: CompilerCtx): string | null {
|
|
264
|
+
switch (expr.kind) {
|
|
265
|
+
case 'NumberLiteral':
|
|
266
|
+
case 'StringLiteral':
|
|
267
|
+
case 'BooleanLiteral':
|
|
268
|
+
return ctx.dialect.bindLiteral(expr.value, ctx.params);
|
|
269
|
+
case 'NullLiteral':
|
|
270
|
+
return 'NULL';
|
|
271
|
+
case 'FieldAccess': {
|
|
272
|
+
if (expr.path.length !== 1) return null;
|
|
273
|
+
return ctx.dialect.quoteIdent(expr.path[0]);
|
|
274
|
+
}
|
|
275
|
+
case 'BinaryExpr': {
|
|
276
|
+
const opSql = BINARY_OP_SQL[expr.operator];
|
|
277
|
+
if (!opSql) return null;
|
|
278
|
+
const left = compileExpr(expr.left, ctx);
|
|
279
|
+
const right = compileExpr(expr.right, ctx);
|
|
280
|
+
if (left === null || right === null) return null;
|
|
281
|
+
|
|
282
|
+
// x = NULL never matches in either Postgres or MySQL; need
|
|
283
|
+
// IS NULL / IS NOT NULL.
|
|
284
|
+
if (expr.right.kind === 'NullLiteral') {
|
|
285
|
+
if (expr.operator === '==') return `${left} IS NULL`;
|
|
286
|
+
if (expr.operator === '!=') return `${left} IS NOT NULL`;
|
|
287
|
+
}
|
|
288
|
+
if (expr.left.kind === 'NullLiteral') {
|
|
289
|
+
if (expr.operator === '==') return `${right} IS NULL`;
|
|
290
|
+
if (expr.operator === '!=') return `${right} IS NOT NULL`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return `(${left} ${opSql} ${right})`;
|
|
294
|
+
}
|
|
295
|
+
case 'UnaryExpr': {
|
|
296
|
+
const operand = compileExpr(expr.operand, ctx);
|
|
297
|
+
if (operand === null) return null;
|
|
298
|
+
if (expr.operator === '!') return `(NOT ${operand})`;
|
|
299
|
+
if (expr.operator === '-') return `(-${operand})`;
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
case 'FunctionCall':
|
|
303
|
+
case 'AliasExpr':
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
function stripTrailingSemicolon(sql: string): string {
|
|
311
|
+
return sql.trim().replace(/;+\s*$/, '');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const PUSHABLE_OPS: ReadonlySet<Operation['kind']> = new Set([
|
|
315
|
+
'WhereOp',
|
|
316
|
+
'SortOp',
|
|
317
|
+
'FirstOp',
|
|
318
|
+
'SelectOp',
|
|
319
|
+
'DistinctOp',
|
|
320
|
+
'RollupOp',
|
|
321
|
+
'AggregateOp',
|
|
322
|
+
]);
|
|
323
|
+
|
|
324
|
+
/** Convenience helper: does the pipeline only contain operators we *might*
|
|
325
|
+
* push down? Doesn't validate expression nodes. */
|
|
326
|
+
export function isPushableShape(pipeline: Pipeline): boolean {
|
|
327
|
+
return pipeline.operations.every((op) => PUSHABLE_OPS.has(op.kind));
|
|
328
|
+
}
|