@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.
Files changed (40) 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__/rest-source.test.ts +191 -0
  11. package/__tests__/snowflake-pushdown-compile.test.ts +74 -0
  12. package/__tests__/websocket-source.test.ts +187 -0
  13. package/dist/index.js +3570 -2044
  14. package/package.json +11 -2
  15. package/src/commands/telegram.ts +10 -0
  16. package/src/config/schema.ts +90 -1
  17. package/src/index.ts +2 -1
  18. package/src/mcp/provider.ts +4 -7
  19. package/src/server/routes/control.ts +1 -3
  20. package/src/server/routes/dynamic.ts +2 -4
  21. package/src/server/sources/clickhouse.ts +153 -0
  22. package/src/server/sources/kafka.ts +4 -16
  23. package/src/server/sources/manager.ts +71 -1
  24. package/src/server/sources/mongodb.ts +165 -0
  25. package/src/server/sources/mysql.ts +46 -14
  26. package/src/server/sources/postgres.ts +52 -20
  27. package/src/server/sources/pushdown/clickhouse.ts +17 -0
  28. package/src/server/sources/pushdown/compile.ts +328 -0
  29. package/src/server/sources/pushdown/dialect.ts +88 -0
  30. package/src/server/sources/pushdown/mongodb.ts +450 -0
  31. package/src/server/sources/pushdown/mysql.ts +17 -0
  32. package/src/server/sources/pushdown/postgres.ts +19 -0
  33. package/src/server/sources/pushdown/snowflake.ts +17 -0
  34. package/src/server/sources/rest.ts +33 -5
  35. package/src/server/sources/snowflake.ts +158 -0
  36. package/src/server/sources/types.ts +27 -0
  37. package/src/server/sources/websocket.ts +73 -9
  38. package/src/telegram/bot.ts +198 -12
  39. package/src/telegram/event-log.ts +234 -0
  40. 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
+ }