@query-farm/vgi 0.28.0 → 0.29.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 (121) hide show
  1. package/README.md +52 -8
  2. package/dist/arguments/argument-spec.d.ts +57 -2
  3. package/dist/arrow/impl-arrowjs/index.d.ts +1 -1
  4. package/dist/arrow/impl-arrowjs/iterate.d.ts +10 -1
  5. package/dist/arrow/impl-arrowjs/schema.d.ts +2 -0
  6. package/dist/arrow/impl-flechette/index.d.ts +1 -1
  7. package/dist/arrow/impl-flechette/iterate.d.ts +10 -1
  8. package/dist/arrow/impl-flechette/schema.d.ts +2 -0
  9. package/dist/arrow/index.d.ts +1 -1
  10. package/dist/arrow/schema-types.d.ts +3 -1
  11. package/dist/arrow/types.d.ts +39 -3
  12. package/dist/catalog/composite.d.ts +13 -13
  13. package/dist/catalog/descriptors.d.ts +7 -3
  14. package/dist/catalog/interface.d.ts +108 -38
  15. package/dist/catalog/read-only.d.ts +32 -15
  16. package/dist/client/client.d.ts +37 -30
  17. package/dist/client/types.d.ts +2 -0
  18. package/dist/client-entry.d.ts +1 -1
  19. package/dist/client-entry.js +791 -447
  20. package/dist/client-entry.js.map +18 -17
  21. package/dist/filter-pushdown/capabilities.d.ts +3 -0
  22. package/dist/filter-pushdown/deserialize.d.ts +14 -15
  23. package/dist/filter-pushdown/evaluate.d.ts +14 -44
  24. package/dist/filter-pushdown/index.d.ts +2 -2
  25. package/dist/filter-pushdown/types.d.ts +120 -68
  26. package/dist/functions/aggregate.d.ts +4 -0
  27. package/dist/functions/registry.d.ts +5 -5
  28. package/dist/functions/scalar.d.ts +6 -1
  29. package/dist/functions/table-buffering.d.ts +9 -0
  30. package/dist/functions/table-in-out.d.ts +13 -0
  31. package/dist/functions/table.d.ts +72 -2
  32. package/dist/functions/types.d.ts +60 -2
  33. package/dist/generated/vgi-client.d.ts +114 -26
  34. package/dist/generated/vgi-protocol-schemas.d.ts +34 -0
  35. package/dist/http/fetch.d.ts +16 -0
  36. package/dist/index.core.d.ts +4 -3
  37. package/dist/index.js +3396 -2064
  38. package/dist/index.js.map +55 -49
  39. package/dist/metadata/types.d.ts +15 -2
  40. package/dist/protocol/handlers/catalog/shared.d.ts +0 -8
  41. package/dist/protocol/handlers/function.d.ts +11 -0
  42. package/dist/protocol/handlers/shared.d.ts +3 -1
  43. package/dist/protocol/serializers/bind.d.ts +1 -2
  44. package/dist/protocol/serializers/cardinality.d.ts +1 -2
  45. package/dist/protocol/serializers/splits.d.ts +126 -0
  46. package/dist/protocol/types.d.ts +36 -3
  47. package/dist/request-auth.d.ts +28 -0
  48. package/dist/schema-path.d.ts +7 -0
  49. package/dist/serve-entry.d.ts +25 -0
  50. package/dist/serve-entry.js +1129 -569
  51. package/dist/serve-entry.js.map +36 -32
  52. package/dist/split-token.d.ts +88 -0
  53. package/dist/types.d.ts +8 -0
  54. package/dist/worker-cf-entry.js +2928 -1689
  55. package/dist/worker-cf-entry.js.map +51 -44
  56. package/dist/worker.d.ts +1 -1
  57. package/package.json +3 -3
  58. package/src/arguments/argument-spec.ts +93 -4
  59. package/src/arguments/arguments.ts +0 -1
  60. package/src/arrow/impl-arrowjs/index.ts +1 -1
  61. package/src/arrow/impl-arrowjs/iterate.ts +21 -0
  62. package/src/arrow/impl-arrowjs/schema.ts +4 -0
  63. package/src/arrow/impl-flechette/index.ts +1 -1
  64. package/src/arrow/impl-flechette/iterate.ts +24 -1
  65. package/src/arrow/impl-flechette/schema.ts +4 -0
  66. package/src/arrow/index.ts +1 -1
  67. package/src/arrow/schema-types.ts +4 -0
  68. package/src/arrow/types.ts +39 -3
  69. package/src/catalog/composite.ts +26 -26
  70. package/src/catalog/descriptors.ts +12 -3
  71. package/src/catalog/interface.ts +217 -72
  72. package/src/catalog/read-only.ts +141 -84
  73. package/src/client/client.ts +97 -73
  74. package/src/client/types.ts +2 -0
  75. package/src/client-entry.ts +2 -1
  76. package/src/filter-pushdown/capabilities.ts +13 -0
  77. package/src/filter-pushdown/collector.ts +22 -35
  78. package/src/filter-pushdown/deserialize.ts +657 -221
  79. package/src/filter-pushdown/evaluate.ts +410 -499
  80. package/src/filter-pushdown/index.ts +16 -2
  81. package/src/filter-pushdown/types.ts +88 -71
  82. package/src/functions/aggregate.ts +12 -1
  83. package/src/functions/copy-from.ts +2 -1
  84. package/src/functions/copy-to.ts +2 -1
  85. package/src/functions/registry.ts +24 -22
  86. package/src/functions/scalar.ts +37 -0
  87. package/src/functions/table-buffering.ts +48 -6
  88. package/src/functions/table-in-out.ts +194 -33
  89. package/src/functions/table.ts +187 -22
  90. package/src/functions/types.ts +64 -1
  91. package/src/generated/vgi-client.ts +122 -28
  92. package/src/generated/vgi-protocol-schemas.ts +455 -58
  93. package/src/http/fetch.ts +129 -2
  94. package/src/index.core.ts +4 -2
  95. package/src/metadata/resolve.ts +43 -1
  96. package/src/metadata/serialize.ts +43 -5
  97. package/src/metadata/types.ts +17 -1
  98. package/src/protocol/dispatch.ts +1 -1
  99. package/src/protocol/handlers/aggregate.ts +23 -12
  100. package/src/protocol/handlers/catalog/admin.ts +47 -54
  101. package/src/protocol/handlers/catalog/index_methods.ts +6 -13
  102. package/src/protocol/handlers/catalog/macro.ts +12 -20
  103. package/src/protocol/handlers/catalog/shared.ts +12 -57
  104. package/src/protocol/handlers/catalog/table.ts +49 -112
  105. package/src/protocol/handlers/catalog/view.ts +18 -22
  106. package/src/protocol/handlers/function.ts +311 -18
  107. package/src/protocol/handlers/shared.ts +12 -3
  108. package/src/protocol/handlers/table-buffering.ts +11 -8
  109. package/src/protocol/serializers/bind.ts +69 -67
  110. package/src/protocol/serializers/cardinality.ts +7 -7
  111. package/src/protocol/serializers/init.ts +52 -10
  112. package/src/protocol/serializers/splits.ts +221 -0
  113. package/src/protocol/state-serializer.ts +55 -1
  114. package/src/protocol/types.ts +36 -3
  115. package/src/request-auth.ts +73 -0
  116. package/src/schema-path.ts +25 -0
  117. package/src/serve-entry.ts +69 -2
  118. package/src/split-token.ts +297 -0
  119. package/src/types.ts +9 -0
  120. package/src/worker-cf-entry.ts +2 -1
  121. package/src/worker.ts +79 -14
@@ -1,591 +1,502 @@
1
1
  // Copyright 2025, 2026 Query Farm LLC - https://query.farm
2
- // Filter pushdown evaluation: row-by-row predicate evaluation against
3
- // Arrow RecordBatches, plus the PushdownFilters class that owns a parsed
4
- // filter set and applies it to batches.
5
2
 
6
3
  import {
7
4
  type VgiBatch,
8
- type VgiSchema,
9
5
  type VgiDataType,
10
- schema,
6
+ isBool,
7
+ isFloat,
8
+ isInt,
9
+ isUtf8,
11
10
  readCanonicalValue,
12
11
  } from "../arrow/index.js";
13
- import { batchFromColumns, filterBatch } from "../util/arrow/index.js";
14
- import { codecFor } from "../arrow/codec/registry.js";
12
+ import { filterBatch } from "../util/arrow/index.js";
13
+ import { applyFilterDelta, type DeserializeFilterOptions, FilterV2Error } from "./deserialize.js";
14
+ import { filterFunctionIdentityKey } from "./capabilities.js";
15
15
  import {
16
16
  ComparisonOp,
17
- type AndFilter,
18
- type ConstantFilter,
19
- type ExpressionFilter,
20
- type ExprNode,
21
- type Filter,
22
- type InFilter,
23
- type IsNotNullFilter,
24
- type IsNullFilter,
25
- type OrFilter,
26
- type StructFilter,
17
+ type EvaluationContext,
18
+ type FilterExpression,
19
+ type FilterPredicate,
20
+ type FunctionIdentity,
27
21
  } from "./types.js";
28
22
 
29
- // ============================================================================
30
- // Comparison helpers
31
- // ============================================================================
23
+ type SqlBoolean = boolean | null;
32
24
 
33
- function compare(a: any, b: any, op: ComparisonOp): boolean {
34
- switch (op) {
35
- case ComparisonOp.EQ: return a === b;
36
- case ComparisonOp.NE: return a !== b;
37
- case ComparisonOp.GT: return a > b;
38
- case ComparisonOp.GE: return a >= b;
39
- case ComparisonOp.LT: return a < b;
40
- case ComparisonOp.LE: return a <= b;
41
- }
25
+ function readCell(batch: VgiBatch, index: number, row: number): unknown {
26
+ const column = batch.getChildAt(index);
27
+ return column ? readCanonicalValue(batch.schema.fields[index].type, column, row) : null;
42
28
  }
43
29
 
44
- /**
45
- * Read a column cell at `index` in CANONICAL form (the same representation the
46
- * filter literals are deserialized into — see deserialize.ts `getValue`). Going
47
- * through the per-backend canonical reader makes this work on BOTH Arrow
48
- * backends (arrow-js's `col.get` and flechette's `col.at` are not interchange-
49
- * able) and guarantees temporal/decimal cells and literals compare like-for-
50
- * like (e.g. timestamp -> bigint on both sides). `colIndex` resolves the field
51
- * type from the batch schema.
52
- */
53
- function readCell(batch: VgiBatch, colIndex: number, index: number): any {
54
- const col = batch.getChildAt(colIndex);
55
- if (!col) return null;
56
- const type = batch.schema.fields[colIndex].type as unknown as VgiDataType;
57
- return readCanonicalValue(type, col, index);
30
+ function deepEqual(left: unknown, right: unknown): boolean {
31
+ if (typeof left === "number" && typeof right === "number") {
32
+ return left === right || (Number.isNaN(left) && Number.isNaN(right));
33
+ }
34
+ if (left instanceof Uint8Array && right instanceof Uint8Array) {
35
+ return left.length === right.length && left.every((value, index) => value === right[index]);
36
+ }
37
+ if (Array.isArray(left) && Array.isArray(right)) {
38
+ return left.length === right.length && left.every((value, index) => deepEqual(value, right[index]));
39
+ }
40
+ if (left && right && typeof left === "object" && typeof right === "object") {
41
+ const entries = Object.entries(left as Record<string, unknown>);
42
+ const rightObject = right as Record<string, unknown>;
43
+ return entries.length === Object.keys(rightObject).length &&
44
+ entries.every(([key, value]) => key in rightObject && deepEqual(value, rightObject[key]));
45
+ }
46
+ return left === right;
58
47
  }
59
48
 
60
- // ============================================================================
61
- // Filter evaluation (row-by-row)
62
- // ============================================================================
63
-
64
- function evaluateFilter(
65
- filter: Filter,
66
- batch: VgiBatch,
67
- mask: Uint8Array,
68
- ): void {
69
- switch (filter.type) {
70
- case "constant":
71
- evaluateConstant(filter, batch, mask);
72
- break;
73
- case "is_null":
74
- evaluateIsNull(filter, batch, mask);
75
- break;
76
- case "is_not_null":
77
- evaluateIsNotNull(filter, batch, mask);
78
- break;
79
- case "in":
80
- evaluateIn(filter, batch, mask);
81
- break;
82
- case "and":
83
- evaluateAnd(filter, batch, mask);
84
- break;
85
- case "or":
86
- evaluateOr(filter, batch, mask);
87
- break;
88
- case "struct":
89
- evaluateStruct(filter, batch, mask);
90
- break;
91
- case "expression":
92
- evaluateExpression(filter, batch, mask);
93
- break;
49
+ function orderedCompare(left: unknown, right: unknown): number {
50
+ if (typeof left === "number" && typeof right === "number") {
51
+ const leftNan = Number.isNaN(left);
52
+ const rightNan = Number.isNaN(right);
53
+ if (leftNan || rightNan) return leftNan === rightNan ? 0 : leftNan ? 1 : -1;
94
54
  }
55
+ if (deepEqual(left, right)) return 0;
56
+ return (left as any) < (right as any) ? -1 : 1;
95
57
  }
96
58
 
97
- // Recursively evaluate an expression AST against a row of the batch, returning
98
- // the scalar value it produces. column_ref reads from the batch using the
99
- // caller-supplied remap (DuckDB rewrites column refs inside an ExpressionFilter
100
- // so index=0 means "this filter's column"); constant returns its pre-resolved
101
- // JS value; function dispatches on function_name; comparison/conjunction
102
- // combine children.
103
- function evaluateExpr(
104
- node: ExprNode,
105
- batch: VgiBatch,
106
- rowIndex: number,
107
- colRefToBatchIndex: (index: number) => number,
108
- ): any {
109
- switch (node.expr_type) {
110
- case "column_ref":
111
- return readCell(batch, colRefToBatchIndex(node.index), rowIndex);
112
- case "constant":
113
- return node.value;
114
- case "function":
115
- return evalFunction(
116
- node.function_name,
117
- node.children.map((c) => evaluateExpr(c, batch, rowIndex, colRefToBatchIndex)),
118
- );
119
- case "comparison": {
120
- const left = evaluateExpr(node.left, batch, rowIndex, colRefToBatchIndex);
121
- const right = evaluateExpr(node.right, batch, rowIndex, colRefToBatchIndex);
122
- if (left == null || right == null) return null;
123
- return compare(left, right, node.op);
124
- }
125
- case "conjunction": {
126
- if (node.conjunction_type === "and") {
127
- for (const c of node.children) {
128
- const v = evaluateExpr(c, batch, rowIndex, colRefToBatchIndex);
129
- if (v !== true) return v === false ? false : null;
130
- }
131
- return true;
132
- }
133
- let sawNull = false;
134
- for (const c of node.children) {
135
- const v = evaluateExpr(c, batch, rowIndex, colRefToBatchIndex);
136
- if (v === true) return true;
137
- if (v == null) sawNull = true;
138
- }
139
- return sawNull ? null : false;
140
- }
59
+ function compare(left: unknown, right: unknown, op: ComparisonOp): SqlBoolean {
60
+ if (op === ComparisonOp.DISTINCT_FROM) {
61
+ return left === null || right === null ? left !== right : !deepEqual(left, right);
62
+ }
63
+ if (op === ComparisonOp.NOT_DISTINCT_FROM) {
64
+ return left === null || right === null ? left === right : deepEqual(left, right);
65
+ }
66
+ if (left === null || right === null) return null;
67
+ const order = orderedCompare(left, right);
68
+ switch (op) {
69
+ case ComparisonOp.EQ: return order === 0;
70
+ case ComparisonOp.NE: return order !== 0;
71
+ case ComparisonOp.LT: return order < 0;
72
+ case ComparisonOp.LE: return order <= 0;
73
+ case ComparisonOp.GT: return order > 0;
74
+ case ComparisonOp.GE: return order >= 0;
75
+ default: throw new FilterV2Error(`unknown comparison operator ${op}`);
141
76
  }
142
77
  }
143
78
 
144
- // Dispatch DuckDB scalar-function names we support as pushdown filters.
145
- // Return null for any missing argument (SQL three-valued logic).
146
- function evalFunction(name: string, args: any[]): any {
147
- if (args.some((a) => a == null)) return null;
148
- switch (name) {
149
- case "list_contains":
150
- case "array_contains": {
151
- // args[0] is an Arrow ListVector row (iterable), args[1] is the needle.
152
- const [list, needle] = args;
153
- if (!list || typeof list[Symbol.iterator] !== "function") return false;
154
- for (const v of list) {
155
- if (v === needle) return true;
156
- }
157
- return false;
158
- }
159
- case "starts_with":
160
- case "prefix": {
161
- const [hay, needle] = args;
162
- return typeof hay === "string" && typeof needle === "string"
163
- ? hay.startsWith(needle) : false;
164
- }
165
- case "contains": {
166
- const [hay, needle] = args;
167
- return typeof hay === "string" && typeof needle === "string"
168
- ? hay.includes(needle) : false;
79
+ function andKleene(values: SqlBoolean[]): SqlBoolean {
80
+ if (values.some((value) => value === false)) return false;
81
+ return values.some((value) => value === null) ? null : true;
82
+ }
83
+
84
+ function orKleene(values: SqlBoolean[]): SqlBoolean {
85
+ if (values.some((value) => value === true)) return true;
86
+ return values.some((value) => value === null) ? null : false;
87
+ }
88
+
89
+ function integerBounds(type: VgiDataType): [bigint, bigint] | null {
90
+ if (!isInt(type)) return null;
91
+ const width = BigInt((type as any).bitWidth ?? 32);
92
+ const signed = (type as any).isSigned ?? (type as any).signed ?? true;
93
+ return signed
94
+ ? [-(1n << (width - 1n)), (1n << (width - 1n)) - 1n]
95
+ : [0n, (1n << width) - 1n];
96
+ }
97
+
98
+ function castValue(value: unknown, type: VgiDataType): unknown {
99
+ if (value === null) return null;
100
+ if (isUtf8(type)) return String(value);
101
+ if (isBool(type)) {
102
+ if (typeof value === "boolean") return value;
103
+ if (value === 0 || value === 0n || value === "false") return false;
104
+ if (value === 1 || value === 1n || value === "true") return true;
105
+ throw new FilterV2Error(`cannot cast ${String(value)} to BOOLEAN`);
106
+ }
107
+ if (isFloat(type)) {
108
+ const result = Number(value);
109
+ if (Number.isNaN(result) && String(value).toLowerCase() !== "nan") {
110
+ throw new FilterV2Error(`cannot cast ${String(value)} to floating point`);
169
111
  }
170
- case "&&":
171
- case "st_intersects_extent": {
172
- // Bounding-box intersection between two WKB geometries. Parses each
173
- // geometry's XY bbox and checks for overlap in both dimensions.
174
- const [a, b] = args;
175
- const ba = wkbBBox(a);
176
- const bb = wkbBBox(b);
177
- if (!ba || !bb) return false;
178
- return !(ba.maxX < bb.minX || ba.minX > bb.maxX ||
179
- ba.maxY < bb.minY || ba.minY > bb.maxY);
112
+ return result;
113
+ }
114
+ const bounds = integerBounds(type);
115
+ if (bounds) {
116
+ let result: bigint;
117
+ try {
118
+ result = typeof value === "bigint" ? value : BigInt(typeof value === "number" ? Math.trunc(value) : String(value));
119
+ } catch {
120
+ throw new FilterV2Error(`cannot cast ${String(value)} to integer`);
180
121
  }
181
- default:
182
- // Unsupported functions shouldn't make it past the C++-side
183
- // `ExpressionTreeIsSupported` gate, but be defensive: evaluate to null
184
- // so the row is filtered out conservatively rather than silently
185
- // producing wrong results.
186
- return null;
122
+ if (result < bounds[0] || result > bounds[1]) throw new FilterV2Error("integer cast overflow");
123
+ return (type as any).bitWidth === 64 ? result : Number(result);
187
124
  }
125
+ throw new FilterV2Error(`no vgi.duckdb.standard.v1 cast evaluator for Arrow type ${type.typeId}`);
188
126
  }
189
127
 
190
- // Parse the XY bounding box of a WKB geometry. Supports Point and Polygon for
191
- // ST_MakeEnvelope output; extend as new geometry kinds are needed.
192
- function wkbBBox(bytes: any): { minX: number; minY: number; maxX: number; maxY: number } | null {
193
- if (!(bytes instanceof Uint8Array)) return null;
194
- if (bytes.length < 5) return null;
195
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
196
- const le = view.getUint8(0) === 1;
197
- const type = view.getUint32(1, le);
198
- // Point (type=1): 21 bytes total (5 header + 8+8 coords)
199
- if (type === 1 && bytes.length >= 21) {
200
- const x = view.getFloat64(5, le);
201
- const y = view.getFloat64(13, le);
202
- return { minX: x, minY: y, maxX: x, maxY: y };
128
+ function numericBinary(
129
+ op: "add" | "subtract" | "multiply" | "divide" | "modulo",
130
+ left: unknown,
131
+ right: unknown,
132
+ context: EvaluationContext,
133
+ ): unknown {
134
+ if (left === null || right === null) return null;
135
+ if ((typeof left !== "number" && typeof left !== "bigint") ||
136
+ (typeof right !== "number" && typeof right !== "bigint")) {
137
+ throw new FilterV2Error("arithmetic operands must be numeric");
203
138
  }
204
- // Polygon (type=3): used by ST_MakeEnvelope. One ring, 4 points, close.
205
- // Header 5 + num_rings 4 + num_points 4 + 4*16 coord bytes = 81 bytes.
206
- if (type === 3 && bytes.length >= 81) {
207
- const numRings = view.getUint32(5, le);
208
- if (numRings < 1) return null;
209
- const numPoints = view.getUint32(9, le);
210
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
211
- let off = 13;
212
- for (let i = 0; i < numPoints && off + 16 <= bytes.length; i++, off += 16) {
213
- const x = view.getFloat64(off, le);
214
- const y = view.getFloat64(off + 8, le);
215
- if (x < minX) minX = x;
216
- if (x > maxX) maxX = x;
217
- if (y < minY) minY = y;
218
- if (y > maxY) maxY = y;
139
+ if (typeof left === "bigint" && typeof right === "bigint") {
140
+ if ((op === "divide" || op === "modulo") && right === 0n) throw new FilterV2Error("division by zero");
141
+ switch (op) {
142
+ case "add": return left + right;
143
+ case "subtract": return left - right;
144
+ case "multiply": return left * right;
145
+ case "divide": return context.integerDivision ? left / right : Number(left) / Number(right);
146
+ case "modulo": return left % right;
219
147
  }
220
- if (minX === Infinity) return null;
221
- return { minX, minY, maxX, maxY };
222
148
  }
223
- return null;
149
+ const a = Number(left);
150
+ const b = Number(right);
151
+ if ((op === "divide" || op === "modulo") && b === 0 && context.ieeeFloatingPointOps === false) {
152
+ throw new FilterV2Error("division by zero");
153
+ }
154
+ switch (op) {
155
+ case "add": return a + b;
156
+ case "subtract": return a - b;
157
+ case "multiply": return a * b;
158
+ case "divide": return a / b;
159
+ case "modulo": return a % b;
160
+ }
224
161
  }
225
162
 
226
- function evaluateExpression(
227
- filter: ExpressionFilter,
228
- batch: VgiBatch,
229
- mask: Uint8Array,
230
- ): void {
231
- const n = batch.numRows;
232
- // DuckDB's FilterSerializer rewrites bound references inside an
233
- // ExpressionFilter so that column_ref index 0 refers to the filter's own
234
- // column (see ReplaceWithBoundReference in DuckDB's optimizer). Higher
235
- // indices on the same filter aren't produced — the filter is always
236
- // scoped to a single column in the outer batch — so we map every
237
- // column_ref back to `filter.columnIndex` by name, resolving through the
238
- // current batch in case projection reordered columns.
239
- const colName = filter.columnName;
240
- let targetIdx = batch.schema.fields.findIndex((f) => f.name === colName);
241
- if (targetIdx < 0) targetIdx = filter.columnIndex;
242
- const colRef = (_nodeIndex: number) => targetIdx;
243
- for (let i = 0; i < n; i++) {
244
- if (!mask[i]) continue;
245
- const v = evaluateExpr(filter.expr, batch, i, colRef);
246
- mask[i] = v === true ? 1 : 0;
247
- }
163
+ interface Bounds {
164
+ minX: number;
165
+ minY: number;
166
+ maxX: number;
167
+ maxY: number;
248
168
  }
249
169
 
250
- function evaluateConstant(
251
- filter: ConstantFilter,
252
- batch: VgiBatch,
253
- mask: Uint8Array,
254
- ): void {
255
- const n = batch.numRows;
256
- for (let i = 0; i < n; i++) {
257
- if (!mask[i]) continue;
258
- const cell = readCell(batch, filter.columnIndex, i);
259
- if (cell === null) {
260
- mask[i] = 0;
261
- } else {
262
- mask[i] = compare(cell, filter.value, filter.op) ? 1 : 0;
263
- }
170
+ class WkbBoundsReader {
171
+ private readonly view: DataView;
172
+ private offset = 0;
173
+
174
+ constructor(bytes: Uint8Array) {
175
+ this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
264
176
  }
265
- }
266
177
 
267
- function evaluateIsNull(
268
- filter: IsNullFilter,
269
- batch: VgiBatch,
270
- mask: Uint8Array,
271
- ): void {
272
- const n = batch.numRows;
273
- for (let i = 0; i < n; i++) {
274
- if (!mask[i]) continue;
275
- mask[i] = (readCell(batch, filter.columnIndex, i) !== null) ? 0 : 1;
178
+ read(): Bounds | null {
179
+ const bounds = this.geometry();
180
+ if (this.offset !== this.view.byteLength) throw new FilterV2Error("WKB has trailing bytes");
181
+ return bounds;
276
182
  }
277
- }
278
183
 
279
- function evaluateIsNotNull(
280
- filter: IsNotNullFilter,
281
- batch: VgiBatch,
282
- mask: Uint8Array,
283
- ): void {
284
- const n = batch.numRows;
285
- for (let i = 0; i < n; i++) {
286
- if (!mask[i]) continue;
287
- mask[i] = (readCell(batch, filter.columnIndex, i) !== null) ? 1 : 0;
184
+ private require(length: number): void {
185
+ if (length < 0 || this.offset + length > this.view.byteLength) throw new FilterV2Error("truncated WKB geometry");
288
186
  }
289
- }
290
187
 
291
- function evaluateIn(
292
- filter: InFilter,
293
- batch: VgiBatch,
294
- mask: Uint8Array,
295
- ): void {
296
- const n = batch.numRows;
297
- for (let i = 0; i < n; i++) {
298
- if (!mask[i]) continue;
299
- const cell = readCell(batch, filter.columnIndex, i);
300
- if (cell === null) {
301
- mask[i] = 0;
302
- } else {
303
- mask[i] = filter.values.has(cell) ? 1 : 0;
304
- }
188
+ private byte(): number {
189
+ this.require(1);
190
+ return this.view.getUint8(this.offset++);
305
191
  }
306
- }
307
192
 
308
- function evaluateAnd(
309
- filter: AndFilter,
310
- batch: VgiBatch,
311
- mask: Uint8Array,
312
- ): void {
313
- // AND: start with all-pass, then narrow with each child
314
- const n = batch.numRows;
315
- const childMask = new Uint8Array(n);
316
-
317
- for (const child of filter.children) {
318
- childMask.fill(1);
319
- evaluateFilter(child, batch, childMask);
320
- for (let i = 0; i < n; i++) {
321
- mask[i] &= childMask[i];
322
- }
193
+ private uint32(littleEndian: boolean): number {
194
+ this.require(4);
195
+ const value = this.view.getUint32(this.offset, littleEndian);
196
+ this.offset += 4;
197
+ return value;
198
+ }
199
+
200
+ private float64(littleEndian: boolean): number {
201
+ this.require(8);
202
+ const value = this.view.getFloat64(this.offset, littleEndian);
203
+ this.offset += 8;
204
+ return value;
205
+ }
206
+
207
+ private point(littleEndian: boolean, dimensions: number): Bounds | null {
208
+ const x = this.float64(littleEndian);
209
+ const y = this.float64(littleEndian);
210
+ for (let dimension = 2; dimension < dimensions; dimension++) this.float64(littleEndian);
211
+ if (Number.isNaN(x) && Number.isNaN(y)) return null;
212
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new FilterV2Error("WKB coordinate must be finite");
213
+ return { minX: x, minY: y, maxX: x, maxY: y };
323
214
  }
324
- }
325
215
 
326
- function evaluateOr(
327
- filter: OrFilter,
328
- batch: VgiBatch,
329
- mask: Uint8Array,
330
- ): void {
331
- // OR: start with all-fail, then widen with each child
332
- const n = batch.numRows;
333
- const result = new Uint8Array(n); // all zeros
334
- const childMask = new Uint8Array(n);
335
-
336
- for (const child of filter.children) {
337
- childMask.fill(1);
338
- evaluateFilter(child, batch, childMask);
339
- for (let i = 0; i < n; i++) {
340
- result[i] |= childMask[i];
216
+ private count(littleEndian: boolean): number {
217
+ const count = this.uint32(littleEndian);
218
+ if (count > Math.floor((this.view.byteLength - this.offset) / 4)) {
219
+ throw new FilterV2Error("WKB element count exceeds payload size");
341
220
  }
221
+ return count;
342
222
  }
343
223
 
344
- // Combine with incoming mask
345
- for (let i = 0; i < n; i++) {
346
- mask[i] &= result[i];
224
+ private merge(left: Bounds | null, right: Bounds | null): Bounds | null {
225
+ if (!left) return right;
226
+ if (!right) return left;
227
+ return {
228
+ minX: Math.min(left.minX, right.minX),
229
+ minY: Math.min(left.minY, right.minY),
230
+ maxX: Math.max(left.maxX, right.maxX),
231
+ maxY: Math.max(left.maxY, right.maxY),
232
+ };
347
233
  }
348
- }
349
234
 
350
- function evaluateStruct(
351
- filter: StructFilter,
352
- batch: VgiBatch,
353
- mask: Uint8Array,
354
- ): void {
355
- const structType = batch.schema.fields[filter.columnIndex].type as unknown as VgiDataType;
356
- const childField = (structType as any).children?.[filter.childIndex];
357
- if (!childField) {
358
- // Child field not found — fail all rows
359
- mask.fill(0);
360
- return;
361
- }
362
- const childType = childField.type as VgiDataType;
363
-
364
- // Read the struct column in canonical form (backend-agnostic), pull out the
365
- // target child's canonical value per row, and rebuild a single-column child
366
- // batch via batchFromColumns (which re-runs canonical writes). Filter literals
367
- // are compared in canonical form too (see deserialize.ts), so the child column
368
- // cells and literals stay in matching representations across both backends.
369
- const childCanonical = Array.from({ length: batch.numRows }, (_, i) => {
370
- const structVal = readCell(batch, filter.columnIndex, i) as Record<string, unknown> | null;
371
- return structVal == null ? null : (structVal[childField.name] ?? null);
372
- });
373
- // batchFromColumns expects RICH values; map canonical -> rich for the child.
374
- const childCodec = codecFor(childType);
375
- const childRich = childCanonical.map((v) => childCodec.canonicalToRich(v));
376
-
377
- const childSchema = schema([
378
- (structType as any).children[filter.childIndex],
379
- ]);
380
- const childBatch = batchFromColumns(
381
- { [filter.childName]: childRich },
382
- childSchema,
383
- );
384
-
385
- // Remap child filter's columnIndex to 0 for the wrapper batch
386
- const remapped = { ...filter.childFilter, columnIndex: 0 };
387
- evaluateFilter(remapped, childBatch, mask);
235
+ private points(littleEndian: boolean, dimensions: number): Bounds | null {
236
+ let bounds: Bounds | null = null;
237
+ const count = this.count(littleEndian);
238
+ for (let index = 0; index < count; index++) bounds = this.merge(bounds, this.point(littleEndian, dimensions));
239
+ return bounds;
240
+ }
241
+
242
+ private geometry(): Bounds | null {
243
+ const byteOrder = this.byte();
244
+ if (byteOrder !== 0 && byteOrder !== 1) throw new FilterV2Error("invalid WKB byte order");
245
+ const littleEndian = byteOrder === 1;
246
+ const encodedType = this.uint32(littleEndian);
247
+ const hasZ = (encodedType & 0x80000000) !== 0;
248
+ const hasM = (encodedType & 0x40000000) !== 0;
249
+ const hasSrid = (encodedType & 0x20000000) !== 0;
250
+ let type = encodedType & 0x0fffffff;
251
+ let dimensions = 2 + Number(hasZ) + Number(hasM);
252
+ if (type >= 3000) {
253
+ type -= 3000;
254
+ dimensions = 4;
255
+ } else if (type >= 2000) {
256
+ type -= 2000;
257
+ dimensions = 3;
258
+ } else if (type >= 1000) {
259
+ type -= 1000;
260
+ dimensions = 3;
261
+ }
262
+ if (hasSrid) this.uint32(littleEndian);
263
+
264
+ if (type === 1) return this.point(littleEndian, dimensions);
265
+ if (type === 2) return this.points(littleEndian, dimensions);
266
+ if (type === 3) {
267
+ let bounds: Bounds | null = null;
268
+ const rings = this.count(littleEndian);
269
+ for (let ring = 0; ring < rings; ring++) bounds = this.merge(bounds, this.points(littleEndian, dimensions));
270
+ return bounds;
271
+ }
272
+ if (type >= 4 && type <= 7) {
273
+ let bounds: Bounds | null = null;
274
+ const geometries = this.count(littleEndian);
275
+ for (let index = 0; index < geometries; index++) bounds = this.merge(bounds, this.geometry());
276
+ return bounds;
277
+ }
278
+ throw new FilterV2Error(`unsupported WKB geometry type ${type}`);
279
+ }
388
280
  }
389
281
 
390
- // ============================================================================
391
- // SQL formatting
392
- // ============================================================================
393
-
394
- const OP_SYMBOLS: Record<ComparisonOp, string> = {
395
- [ComparisonOp.EQ]: "=",
396
- [ComparisonOp.NE]: "!=",
397
- [ComparisonOp.GT]: ">",
398
- [ComparisonOp.GE]: ">=",
399
- [ComparisonOp.LT]: "<",
400
- [ComparisonOp.LE]: "<=",
401
- };
402
-
403
- function formatValue(v: any): string {
404
- if (typeof v === "string") return `'${v}'`;
405
- if (typeof v === "bigint") return String(v);
406
- return String(v);
282
+ function wkbBBox(bytes: unknown): Bounds | null {
283
+ if (!(bytes instanceof Uint8Array)) throw new FilterV2Error("spatial extent arguments must be WKB binary values");
284
+ return new WkbBoundsReader(bytes).read();
407
285
  }
408
286
 
409
- function filterToSql(f: Filter): string {
410
- const col = f.columnName;
287
+ function evaluateCall(expression: Extract<FilterExpression, { node: "call" }>, args: unknown[]): unknown {
288
+ if (args.some((value) => value === null)) return null;
289
+ if (typeof expression.function === "string") {
290
+ const [left, right] = args;
291
+ switch (expression.function) {
292
+ case "starts_with": return String(left).startsWith(String(right));
293
+ case "ends_with": return String(left).endsWith(String(right));
294
+ case "contains": return String(left).includes(String(right));
295
+ case "list_contains": return Array.isArray(left) && left.some((value) => value !== null && deepEqual(value, right));
296
+ }
297
+ }
298
+ if (filterFunctionIdentityKey(expression.function as FunctionIdentity) === "duckdb.spatial/intersects_extent@1") {
299
+ const left = wkbBBox(args[0]);
300
+ const right = wkbBBox(args[1]);
301
+ return !!left && !!right && !(left.maxX < right.minX || left.minX > right.maxX ||
302
+ left.maxY < right.minY || left.minY > right.maxY);
303
+ }
304
+ throw new FilterV2Error("no evaluator for extension filter function");
305
+ }
411
306
 
412
- switch (f.type) {
413
- case "constant":
414
- return `${col} ${OP_SYMBOLS[f.op]} ${formatValue(f.value)}`;
415
- case "is_null":
416
- return `${col} IS NULL`;
417
- case "is_not_null":
418
- return `${col} IS NOT NULL`;
419
- case "in": {
420
- // Match Python's repr: summarize large lists as `N values`, otherwise
421
- // render each element. Threshold matches vgi-python's InFilter.__repr__.
422
- const size = f.values.size;
423
- if (size > 20) {
424
- return `${col} IN (${size} values)`;
307
+ function evaluateExpression(expression: FilterExpression, batch: VgiBatch, row: number, context: EvaluationContext): unknown {
308
+ switch (expression.node) {
309
+ case "column_ref": {
310
+ const projectedIndex = batch.schema.fields.findIndex((field) => field.name === expression.columnName);
311
+ if (projectedIndex < 0) {
312
+ throw new FilterV2Error(`filter column ${expression.columnName} is unavailable in emitted batch`);
425
313
  }
426
- const vals = [...f.values].map(formatValue).join(", ");
427
- return `${col} IN (${vals})`;
314
+ return readCell(batch, projectedIndex, row);
315
+ }
316
+ case "field_ref": {
317
+ const parent = evaluateExpression(expression.expression, batch, row, context);
318
+ if (parent === null) return null;
319
+ if (Array.isArray(parent)) return parent[expression.fieldIndex] ?? null;
320
+ return (parent as Record<string, unknown>)[expression.fieldName] ?? null;
321
+ }
322
+ case "literal": return expression.value;
323
+ case "comparison": return compare(evaluateExpression(expression.left, batch, row, context),
324
+ evaluateExpression(expression.right, batch, row, context), expression.op);
325
+ case "and": return andKleene(expression.children.map((child) => evaluateExpression(child, batch, row, context) as SqlBoolean));
326
+ case "or": return orKleene(expression.children.map((child) => evaluateExpression(child, batch, row, context) as SqlBoolean));
327
+ case "not": {
328
+ const value = evaluateExpression(expression.expression, batch, row, context) as SqlBoolean;
329
+ return value === null ? null : !value;
428
330
  }
429
- case "and": {
430
- const parts = f.children.map(filterToSql);
431
- return `(${parts.join(" AND ")})`;
331
+ case "is_null": {
332
+ const result = evaluateExpression(expression.expression, batch, row, context) === null;
333
+ return expression.negated ? !result : result;
432
334
  }
433
- case "or": {
434
- const parts = f.children.map(filterToSql);
435
- return `(${parts.join(" OR ")})`;
335
+ case "in": {
336
+ const input = evaluateExpression(expression.expression, batch, row, context);
337
+ if (input === null) return null;
338
+ const matched = expression.set.values.some((value) => value !== null && deepEqual(input, value));
339
+ let result: SqlBoolean = matched ? true : expression.set.values.some((value) => value === null) ? null : false;
340
+ if (expression.negated && result !== null) result = !result;
341
+ return result;
436
342
  }
437
- case "struct": {
438
- const nested = `${f.columnName}.${f.childName}`;
439
- const remapped = { ...f.childFilter, columnName: nested };
440
- return filterToSql(remapped);
343
+ case "cast": return castValue(evaluateExpression(expression.expression, batch, row, context), expression.field.type);
344
+ case "arithmetic": return numericBinary(expression.op, evaluateExpression(expression.left, batch, row, context),
345
+ evaluateExpression(expression.right, batch, row, context), context);
346
+ case "negate": {
347
+ const value = evaluateExpression(expression.expression, batch, row, context);
348
+ return value === null ? null : typeof value === "bigint" ? -value : -Number(value);
441
349
  }
442
- case "expression":
443
- // Expression filters don't have a natural SQL rendering here — the
444
- // tree came from DuckDB's bound-expression serializer. Use a generic
445
- // placeholder so debug output stays readable.
446
- return `${f.columnName} MATCHES (expression)`;
350
+ case "call": return evaluateCall(expression, expression.arguments.map((argument) => evaluateExpression(argument, batch, row, context)));
351
+ case "runtime_filter": throw new FilterV2Error("runtime-filter artifact evaluator is unavailable");
352
+ }
353
+ }
354
+
355
+ function expressionColumns(expression: FilterExpression, output = new Map<number, string>()): Map<number, string> {
356
+ switch (expression.node) {
357
+ case "column_ref": output.set(expression.columnIndex, expression.columnName); break;
358
+ case "field_ref": case "not": case "is_null": case "cast": case "negate":
359
+ expressionColumns(expression.expression, output); break;
360
+ case "comparison": case "arithmetic":
361
+ expressionColumns(expression.left, output); expressionColumns(expression.right, output); break;
362
+ case "and": case "or": expression.children.forEach((child) => expressionColumns(child, output)); break;
363
+ case "in": expressionColumns(expression.expression, output); break;
364
+ case "call": expression.arguments.forEach((argument) => expressionColumns(argument, output)); break;
365
+ case "runtime_filter": expressionColumns(expression.input, output); break;
447
366
  }
367
+ return output;
448
368
  }
449
369
 
450
- // ============================================================================
451
- // PushdownFilters
452
- // ============================================================================
370
+ function discreteValues(expression: FilterExpression, columnName: string): unknown[] | null {
371
+ if (expression.node === "comparison" && expression.op === ComparisonOp.EQ) {
372
+ if (expression.left.node === "column_ref" && expression.left.columnName === columnName && expression.right.node === "literal") return [expression.right.value];
373
+ if (expression.right.node === "column_ref" && expression.right.columnName === columnName && expression.left.node === "literal") return [expression.left.value];
374
+ }
375
+ if (expression.node === "in" && expression.expression.node === "column_ref" &&
376
+ expression.expression.columnName === columnName && !expression.negated) return expression.set.values;
377
+ if (expression.node === "and") {
378
+ for (const child of expression.children) {
379
+ const values = discreteValues(child, columnName);
380
+ if (values) return values;
381
+ }
382
+ }
383
+ if (expression.node === "or") {
384
+ const branches = expression.children.map((child) => discreteValues(child, columnName));
385
+ if (branches.some((values) => values === null)) return null;
386
+ const result: unknown[] = [];
387
+ for (const values of branches as unknown[][]) {
388
+ for (const value of values) if (!result.some((known) => deepEqual(known, value))) result.push(value);
389
+ }
390
+ return result;
391
+ }
392
+ return null;
393
+ }
453
394
 
454
395
  export class PushdownFilters {
396
+ readonly version = "2";
397
+
455
398
  constructor(
456
- readonly filters: Filter[],
457
- readonly version: string,
399
+ readonly predicates: FilterPredicate[],
400
+ readonly evaluationContext: EvaluationContext,
401
+ readonly options: DeserializeFilterOptions,
402
+ readonly revisions = new Map<string, number>(),
403
+ readonly requiredIds = new Set<string>(),
458
404
  ) {}
459
405
 
460
- /** Convert filters to a human-readable SQL-like string. */
461
- toSql(): string {
462
- if (this.filters.length === 0) return "";
463
- return this.filters.map(filterToSql).join(" AND ");
406
+ get filters(): FilterExpression[] {
407
+ return this.predicates.map((predicate) => predicate.expression);
408
+ }
409
+
410
+ applyDelta(batch: VgiBatch): PushdownFilters {
411
+ return applyFilterDelta(this, batch);
464
412
  }
465
413
 
466
- /** Evaluate filters against a batch, returning a Uint8Array mask (0=fail, 1=pass). */
467
414
  evaluate(batch: VgiBatch): Uint8Array {
468
- const n = batch.numRows;
469
- const mask = new Uint8Array(n);
415
+ const mask = new Uint8Array(batch.numRows);
470
416
  mask.fill(1);
471
-
472
- for (const filter of this.filters) {
473
- evaluateFilter(filter, batch, mask);
417
+ for (const predicate of this.predicates) {
418
+ if (predicate.expression.node === "runtime_filter" && !predicate.expression.supported) continue;
419
+ let values: unknown[];
420
+ try {
421
+ values = Array.from({ length: batch.numRows }, (_, row) => evaluateExpression(
422
+ predicate.expression, batch, row, this.evaluationContext));
423
+ } catch (error) {
424
+ if (predicate.mode === "advisory") continue;
425
+ throw error;
426
+ }
427
+ for (let row = 0; row < batch.numRows; row++) mask[row] &= values[row] === true ? 1 : 0;
474
428
  }
475
-
476
429
  return mask;
477
430
  }
478
431
 
479
- /** Apply filters to a batch, returning only passing rows. */
480
432
  apply(batch: VgiBatch): VgiBatch {
481
- if (batch.numRows === 0 || this.filters.length === 0) return batch;
482
-
483
- const mask = this.evaluate(batch);
484
- // filterBatch rebuilds via the codec/canonical path (backend-agnostic,
485
- // lossless), including its own all-pass / all-fail fast paths.
486
- return filterBatch(batch, mask);
433
+ if (!batch.numRows || !this.predicates.length) return batch;
434
+ return filterBatch(batch, this.evaluate(batch));
487
435
  }
488
436
 
489
- /**
490
- * The set of column names referenced by the pushed-down filters, sorted
491
- * ascending. Descends one level into AndFilter children (DuckDB pushes
492
- * `col IN (...) AND col>=min` as a single AndFilter), consistent with
493
- * `getColumnValues` / `hasFilterForColumn`. Mirrors the cross-language
494
- * `filtered_columns` accessor (vgi-python / vgi-go / vgi-rust).
495
- */
496
437
  filteredColumns(): string[] {
497
- const cols = new Set<string>();
498
- for (const f of this.filters) {
499
- if (f.type === "and") {
500
- for (const c of f.children) cols.add(c.columnName);
501
- } else {
502
- cols.add(f.columnName);
503
- }
438
+ const columns = new Set<string>();
439
+ for (const predicate of this.predicates) {
440
+ for (const name of expressionColumns(predicate.expression).values()) columns.add(name);
504
441
  }
505
- return Array.from(cols).sort();
442
+ return [...columns].sort();
506
443
  }
507
444
 
508
- /**
509
- * Whether any pushed-down filter references `columnName`. Mirrors the
510
- * cross-language `has_filter_for_column` accessor.
511
- */
512
445
  hasFilterForColumn(columnName: string): boolean {
513
- return this.collectColumnFilters(columnName).length > 0;
514
- }
515
-
516
- /**
517
- * Discrete values a column could take, from equality (`=`) or `IN` filters.
518
- * Useful for partition pruning (resolve the key set up front, fetch only
519
- * those keys). Returns null when the predicate is not enumerable — no
520
- * filter, a bare range, or an OR with a non-discrete branch.
521
- *
522
- * Mirrors vgi-python's `PushdownFilters.get_column_values`: descends one
523
- * level into an AndFilter (DuckDB pushes `col IN (...) AND col>=min AND
524
- * col<=max` as a single AndFilter from a semi-join), and unions OR branches
525
- * only when every branch pins the column to discrete values.
526
- */
527
- getColumnValues(columnName: string): any[] | null {
528
- for (const f of this.collectColumnFilters(columnName)) {
529
- if (f.type === "constant" && f.op === ComparisonOp.EQ) {
530
- return [f.value];
531
- } else if (f.type === "in") {
532
- return Array.from(f.values);
533
- } else if (f.type === "or") {
534
- const union = this.orDiscreteValues(f, columnName);
535
- if (union !== null) return union;
536
- }
446
+ return this.filteredColumns().includes(columnName);
447
+ }
448
+
449
+ getColumnValues(columnName: string): unknown[] | null {
450
+ for (const predicate of this.predicates) {
451
+ const values = discreteValues(predicate.expression, columnName);
452
+ if (values) return values;
537
453
  }
538
454
  return null;
539
455
  }
540
456
 
541
- /**
542
- * Union of discrete values for `columnName` across all OR branches, iff every
543
- * child constrains it to discrete values (`=` or `IN`); otherwise null (the
544
- * column could take any value through a non-discrete branch). Returning one
545
- * branch's values would be an unsafe subset.
546
- */
547
- private orDiscreteValues(orFilter: OrFilter, columnName: string): any[] | null {
548
- const values: any[] = [];
549
- for (const child of orFilter.children) {
550
- if (child.columnName !== columnName) return null;
551
- if (child.type === "constant" && child.op === ComparisonOp.EQ) {
552
- values.push(child.value);
553
- } else if (child.type === "in") {
554
- for (const v of child.values) values.push(v);
555
- } else {
556
- return null;
557
- }
457
+ toSql(): string {
458
+ return this.predicates.map((predicate) => expressionToSql(predicate.expression)).join(" AND ");
459
+ }
460
+ }
461
+
462
+ function quote(value: unknown): string {
463
+ if (value === null) return "NULL";
464
+ if (typeof value === "string") return `'${value.replaceAll("'", "''")}'`;
465
+ if (value instanceof Uint8Array) return `X'${[...value].map((byte) => byte.toString(16).padStart(2, "0")).join("")}'`;
466
+ if (Array.isArray(value)) return `[${value.map(quote).join(", ")}]`;
467
+ return String(value);
468
+ }
469
+
470
+ export function expressionToSql(expression: FilterExpression): string {
471
+ switch (expression.node) {
472
+ case "column_ref": return expression.columnName;
473
+ case "field_ref": return `${expressionToSql(expression.expression)}.${expression.fieldName}`;
474
+ case "literal": return quote(expression.value);
475
+ case "comparison": {
476
+ const symbols: Record<ComparisonOp, string> = {
477
+ [ComparisonOp.EQ]: "=", [ComparisonOp.NE]: "!=", [ComparisonOp.LT]: "<", [ComparisonOp.LE]: "<=",
478
+ [ComparisonOp.GT]: ">", [ComparisonOp.GE]: ">=", [ComparisonOp.DISTINCT_FROM]: "IS DISTINCT FROM",
479
+ [ComparisonOp.NOT_DISTINCT_FROM]: "IS NOT DISTINCT FROM",
480
+ };
481
+ return `${expressionToSql(expression.left)} ${symbols[expression.op]} ${expressionToSql(expression.right)}`;
558
482
  }
559
- if (values.length === 0) return null;
560
- // Deduplicate, preserving first-seen order for stable output.
561
- const seen = new Set<any>();
562
- const deduped: any[] = [];
563
- for (const v of values) {
564
- if (!seen.has(v)) {
565
- seen.add(v);
566
- deduped.push(v);
567
- }
483
+ case "and": case "or": return `(${expression.children.map(expressionToSql).join(` ${expression.node.toUpperCase()} `)})`;
484
+ case "not": return `(NOT ${expressionToSql(expression.expression)})`;
485
+ case "is_null": return `${expressionToSql(expression.expression)} IS ${expression.negated ? "NOT " : ""}NULL`;
486
+ case "in": return `${expressionToSql(expression.expression)} ${expression.negated ? "NOT " : ""}IN (${expression.set.values.map(quote).join(", ")})`;
487
+ case "cast": return `CAST(${expressionToSql(expression.expression)} AS type_${expression.typeRef})`;
488
+ case "arithmetic": {
489
+ const symbols = { add: "+", subtract: "-", multiply: "*", divide: "/", modulo: "%" } as const;
490
+ return `(${expressionToSql(expression.left)} ${symbols[expression.op]} ${expressionToSql(expression.right)})`;
568
491
  }
569
- return deduped;
570
- }
571
-
572
- /**
573
- * Collect filters for a column from the top level and direct AND children
574
- * (one level of descent, consistent with the Python accessor). Deeply nested
575
- * AND/AND is not traversed.
576
- */
577
- private collectColumnFilters(columnName: string): Filter[] {
578
- const result: Filter[] = [];
579
- for (const f of this.filters) {
580
- if (f.columnName !== columnName) continue;
581
- if (f.type === "and") {
582
- for (const c of f.children) {
583
- if (c.columnName === columnName) result.push(c);
584
- }
585
- } else {
586
- result.push(f);
587
- }
492
+ case "negate": return `(-${expressionToSql(expression.expression)})`;
493
+ case "call": {
494
+ const name = typeof expression.function === "string"
495
+ ? expression.function
496
+ : filterFunctionIdentityKey(expression.function);
497
+ return `${name}(${expression.arguments.map(expressionToSql).join(", ")})`;
588
498
  }
589
- return result;
499
+ case "runtime_filter":
500
+ return `${filterFunctionIdentityKey(expression.algorithm)}(${expressionToSql(expression.input)})`;
590
501
  }
591
502
  }