@query-farm/vgi 0.29.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.
- package/README.md +26 -1
- package/dist/arguments/argument-spec.d.ts +4 -2
- package/dist/catalog/composite.d.ts +13 -13
- package/dist/catalog/descriptors.d.ts +7 -3
- package/dist/catalog/interface.d.ts +67 -40
- package/dist/catalog/read-only.d.ts +32 -15
- package/dist/client/client.d.ts +36 -29
- package/dist/client/types.d.ts +2 -0
- package/dist/client-entry.d.ts +1 -1
- package/dist/client-entry.js +441 -279
- package/dist/client-entry.js.map +12 -11
- package/dist/filter-pushdown/capabilities.d.ts +3 -0
- package/dist/filter-pushdown/deserialize.d.ts +14 -15
- package/dist/filter-pushdown/evaluate.d.ts +14 -44
- package/dist/filter-pushdown/index.d.ts +2 -2
- package/dist/filter-pushdown/types.d.ts +120 -68
- package/dist/functions/aggregate.d.ts +4 -0
- package/dist/functions/registry.d.ts +5 -5
- package/dist/functions/scalar.d.ts +6 -1
- package/dist/functions/table-buffering.d.ts +9 -0
- package/dist/functions/table-in-out.d.ts +13 -0
- package/dist/functions/table.d.ts +9 -2
- package/dist/functions/types.d.ts +22 -2
- package/dist/generated/vgi-client.d.ts +56 -27
- package/dist/generated/vgi-protocol-schemas.d.ts +15 -0
- package/dist/http/fetch.d.ts +7 -1
- package/dist/index.core.d.ts +2 -1
- package/dist/index.js +2317 -1354
- package/dist/index.js.map +43 -40
- package/dist/metadata/types.d.ts +15 -2
- package/dist/protocol/handlers/shared.d.ts +3 -1
- package/dist/protocol/serializers/splits.d.ts +1 -1
- package/dist/protocol/types.d.ts +9 -3
- package/dist/schema-path.d.ts +7 -0
- package/dist/serve-entry.d.ts +22 -1
- package/dist/serve-entry.js +412 -238
- package/dist/serve-entry.js.map +27 -26
- package/dist/split-token.d.ts +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/worker-cf-entry.js +2030 -1201
- package/dist/worker-cf-entry.js.map +40 -36
- package/dist/worker.d.ts +1 -1
- package/package.json +3 -3
- package/src/arguments/argument-spec.ts +12 -4
- package/src/catalog/composite.ts +26 -26
- package/src/catalog/descriptors.ts +12 -3
- package/src/catalog/interface.ts +98 -48
- package/src/catalog/read-only.ts +124 -67
- package/src/client/client.ts +92 -72
- package/src/client/types.ts +2 -0
- package/src/client-entry.ts +2 -1
- package/src/filter-pushdown/capabilities.ts +13 -0
- package/src/filter-pushdown/collector.ts +22 -35
- package/src/filter-pushdown/deserialize.ts +657 -221
- package/src/filter-pushdown/evaluate.ts +410 -499
- package/src/filter-pushdown/index.ts +16 -2
- package/src/filter-pushdown/types.ts +88 -71
- package/src/functions/aggregate.ts +9 -1
- package/src/functions/registry.ts +24 -22
- package/src/functions/scalar.ts +26 -0
- package/src/functions/table-buffering.ts +46 -7
- package/src/functions/table-in-out.ts +187 -30
- package/src/functions/table.ts +58 -20
- package/src/functions/types.ts +24 -1
- package/src/generated/vgi-client.ts +61 -29
- package/src/generated/vgi-protocol-schemas.ts +225 -54
- package/src/http/fetch.ts +12 -0
- package/src/index.core.ts +2 -0
- package/src/metadata/resolve.ts +43 -1
- package/src/metadata/serialize.ts +43 -5
- package/src/metadata/types.ts +17 -1
- package/src/protocol/dispatch.ts +1 -1
- package/src/protocol/handlers/aggregate.ts +13 -7
- package/src/protocol/handlers/catalog/admin.ts +6 -6
- package/src/protocol/handlers/catalog/index_methods.ts +2 -2
- package/src/protocol/handlers/catalog/macro.ts +4 -4
- package/src/protocol/handlers/catalog/shared.ts +2 -0
- package/src/protocol/handlers/catalog/table.ts +16 -16
- package/src/protocol/handlers/catalog/view.ts +5 -5
- package/src/protocol/handlers/function.ts +2 -2
- package/src/protocol/handlers/shared.ts +12 -3
- package/src/protocol/handlers/table-buffering.ts +4 -4
- package/src/protocol/serializers/bind.ts +15 -6
- package/src/protocol/serializers/splits.ts +4 -2
- package/src/protocol/types.ts +9 -3
- package/src/schema-path.ts +25 -0
- package/src/serve-entry.ts +65 -3
- package/src/split-token.ts +2 -2
- package/src/types.ts +9 -0
- package/src/worker.ts +49 -4
|
@@ -1,253 +1,689 @@
|
|
|
1
1
|
// Copyright 2025, 2026 Query Farm LLC - https://query.farm
|
|
2
|
-
// Deserialize pushdown filters from the wire format (Arrow RecordBatch with
|
|
3
|
-
// JSON specs in column 0 and value-ref scalars in columns 1+).
|
|
4
2
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
3
|
+
import {
|
|
4
|
+
type VgiBatch,
|
|
5
|
+
type VgiDataType,
|
|
6
|
+
type VgiField,
|
|
7
|
+
type VgiSchema,
|
|
8
|
+
isBool,
|
|
9
|
+
isDate,
|
|
10
|
+
isList,
|
|
11
|
+
isStruct,
|
|
12
|
+
isTime,
|
|
13
|
+
isTimestamp,
|
|
14
|
+
isUtf8,
|
|
15
|
+
readCanonicalValue,
|
|
16
|
+
typeSignature,
|
|
17
|
+
} from "../arrow/index.js";
|
|
7
18
|
import { PushdownFilters } from "./evaluate.js";
|
|
19
|
+
import { filterFunctionIdentityKey, supportsExtensionFilterFunction } from "./capabilities.js";
|
|
20
|
+
import {
|
|
21
|
+
ComparisonOp,
|
|
22
|
+
DUCKDB_SESSION_CONTEXT,
|
|
23
|
+
DUCKDB_STANDARD_V1,
|
|
24
|
+
FILTER_ENCODING,
|
|
25
|
+
FILTER_VERSION,
|
|
26
|
+
NO_EVALUATION_CONTEXT,
|
|
27
|
+
type EvaluationContext,
|
|
28
|
+
type FilterCapabilities,
|
|
29
|
+
type FilterExpression,
|
|
30
|
+
type FilterSet,
|
|
31
|
+
type FilterPredicate,
|
|
32
|
+
type FunctionIdentity,
|
|
33
|
+
type PredicateMode,
|
|
34
|
+
type PredicateSource,
|
|
35
|
+
type StandardFilterFunction,
|
|
36
|
+
} from "./types.js";
|
|
8
37
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
38
|
+
const MAX_JSON_BYTES = 1 << 20;
|
|
39
|
+
const MAX_DEPTH = 64;
|
|
40
|
+
const MAX_NODES = 10_000;
|
|
41
|
+
const MAX_PREDICATES = 1_024;
|
|
42
|
+
const MAX_PREDICATE_IDS = 4_096;
|
|
43
|
+
const MAX_ARGUMENTS = 256;
|
|
44
|
+
const MAX_ID_BYTES = 128;
|
|
45
|
+
const MAX_FINGERPRINT_BYTES = 256;
|
|
46
|
+
const PAYLOAD_NAME = /^(?:value|type|artifact)_(?:0|[1-9][0-9]*)$/;
|
|
47
|
+
const IDENTITY_NAMESPACE = /^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9_]*)*$/;
|
|
48
|
+
const IDENTITY_NAME = /^[a-z][a-z0-9_]*$/;
|
|
49
|
+
const MODES = new Set<PredicateMode>(["required", "advisory"]);
|
|
50
|
+
const SOURCES = new Set<PredicateSource>(["query", "join", "top_n", "split_refinement", "other"]);
|
|
51
|
+
const STANDARD_FUNCTIONS = new Set<StandardFilterFunction>([
|
|
52
|
+
"starts_with", "ends_with", "contains", "list_contains",
|
|
53
|
+
]);
|
|
54
|
+
const KNOWN_RUNTIME_ALGORITHMS = new Set([
|
|
55
|
+
"duckdb.runtime_filter/bloom@1",
|
|
56
|
+
"duckdb.runtime_filter/prefix_range@1",
|
|
57
|
+
]);
|
|
58
|
+
const KNOWN_ARROW_EXTENSIONS = new Set([
|
|
59
|
+
"arrow.bool8", "arrow.json", "arrow.uuid",
|
|
60
|
+
"geoarrow.linestring", "geoarrow.multilinestring", "geoarrow.multipoint",
|
|
61
|
+
"geoarrow.multipolygon", "geoarrow.point", "geoarrow.polygon", "geoarrow.wkb",
|
|
62
|
+
]);
|
|
16
63
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
op: spec.op as ComparisonOp,
|
|
24
|
-
value: getValue(spec.value_ref),
|
|
25
|
-
};
|
|
64
|
+
export class FilterV2Error extends Error {
|
|
65
|
+
constructor(message: string) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = "FilterV2Error";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
26
70
|
|
|
27
|
-
|
|
28
|
-
|
|
71
|
+
export interface DeserializeFilterOptions extends FilterCapabilities {
|
|
72
|
+
/** The authoritative, unprojected bind output schema. */
|
|
73
|
+
outputSchema: VgiSchema;
|
|
74
|
+
joinKeyBatches?: VgiBatch[];
|
|
75
|
+
}
|
|
29
76
|
|
|
30
|
-
|
|
31
|
-
|
|
77
|
+
function utf8Length(value: string): number {
|
|
78
|
+
return new TextEncoder().encode(value).byteLength;
|
|
79
|
+
}
|
|
32
80
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
81
|
+
function expectObject(value: unknown, where: string): Record<string, unknown> {
|
|
82
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
83
|
+
throw new FilterV2Error(`${where} must be a JSON object`);
|
|
84
|
+
}
|
|
85
|
+
return value as Record<string, unknown>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function expectArray(value: unknown, where: string): unknown[] {
|
|
89
|
+
if (!Array.isArray(value)) throw new FilterV2Error(`${where} must be a JSON array`);
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function expectString(value: unknown, where: string, allowEmpty = false): string {
|
|
94
|
+
if (typeof value !== "string" || (!allowEmpty && value.length === 0)) {
|
|
95
|
+
throw new FilterV2Error(`${where} must be a${allowEmpty ? "" : " nonempty"} string`);
|
|
96
|
+
}
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function expectBoolean(value: unknown, where: string): boolean {
|
|
101
|
+
if (typeof value !== "boolean") throw new FilterV2Error(`${where} must be a Boolean`);
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function expectUint(value: unknown, where: string, positive = false): number {
|
|
106
|
+
if (!Number.isSafeInteger(value) || (value as number) < (positive ? 1 : 0)) {
|
|
107
|
+
throw new FilterV2Error(`${where} must be a safely representable unsigned integer`);
|
|
108
|
+
}
|
|
109
|
+
return value as number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function expectKeys(
|
|
113
|
+
object: Record<string, unknown>,
|
|
114
|
+
required: readonly string[],
|
|
115
|
+
optional: readonly string[],
|
|
116
|
+
where: string,
|
|
117
|
+
): void {
|
|
118
|
+
const allowed = new Set([...required, ...optional]);
|
|
119
|
+
const missing = required.filter((key) => !(key in object));
|
|
120
|
+
const unknown = Object.keys(object).filter((key) => !allowed.has(key));
|
|
121
|
+
if (missing.length) throw new FilterV2Error(`${where} is missing ${missing.join(", ")}`);
|
|
122
|
+
if (unknown.length) throw new FilterV2Error(`${where} has unknown properties ${unknown.join(", ")}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function validateArrowExtensions(field: VgiField): void {
|
|
126
|
+
const names = [field.metadata?.get("ARROW:extension:name"), (field.type as any).extensionName,
|
|
127
|
+
(field.type as any).extension_name].filter((value): value is string => typeof value === "string");
|
|
128
|
+
for (const name of names) {
|
|
129
|
+
if (!KNOWN_ARROW_EXTENSIONS.has(name)) throw new FilterV2Error(`unknown Arrow extension type ${name}`);
|
|
130
|
+
}
|
|
131
|
+
const children = (field.type as any).children as VgiField[] | undefined;
|
|
132
|
+
children?.forEach(validateArrowExtensions);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// JSON.parse silently accepts duplicate object names. VGI v2 does not, so this
|
|
136
|
+
// deliberately small parser detects them while retaining native JS values.
|
|
137
|
+
class StrictJsonParser {
|
|
138
|
+
private offset = 0;
|
|
139
|
+
|
|
140
|
+
constructor(private readonly text: string) {}
|
|
141
|
+
|
|
142
|
+
parse(): unknown {
|
|
143
|
+
const result = this.value();
|
|
144
|
+
this.space();
|
|
145
|
+
if (this.offset !== this.text.length) throw this.error("trailing JSON data");
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private error(message: string): FilterV2Error {
|
|
150
|
+
return new FilterV2Error(`invalid filter JSON at byte ${this.offset}: ${message}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private space(): void {
|
|
154
|
+
while (/\s/.test(this.text[this.offset] ?? "")) this.offset++;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private value(): unknown {
|
|
158
|
+
this.space();
|
|
159
|
+
const ch = this.text[this.offset];
|
|
160
|
+
if (ch === "{") return this.object();
|
|
161
|
+
if (ch === "[") return this.array();
|
|
162
|
+
if (ch === '"') return this.string();
|
|
163
|
+
for (const [word, value] of [["true", true], ["false", false], ["null", null]] as const) {
|
|
164
|
+
if (this.text.startsWith(word, this.offset)) {
|
|
165
|
+
this.offset += word.length;
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const match = this.text.slice(this.offset).match(/^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/);
|
|
170
|
+
if (match) {
|
|
171
|
+
this.offset += match[0].length;
|
|
172
|
+
const number = Number(match[0]);
|
|
173
|
+
if (!Number.isFinite(number)) throw this.error("non-finite number");
|
|
174
|
+
return number;
|
|
175
|
+
}
|
|
176
|
+
throw this.error("expected a JSON value");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private string(): string {
|
|
180
|
+
const start = this.offset++;
|
|
181
|
+
let escaped = false;
|
|
182
|
+
while (this.offset < this.text.length) {
|
|
183
|
+
const ch = this.text[this.offset++];
|
|
184
|
+
if (!escaped && ch === '"') {
|
|
185
|
+
try {
|
|
186
|
+
return JSON.parse(this.text.slice(start, this.offset));
|
|
187
|
+
} catch {
|
|
188
|
+
throw this.error("invalid string escape");
|
|
44
189
|
}
|
|
45
190
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
191
|
+
if (!escaped && ch.charCodeAt(0) < 0x20) throw this.error("control character in string");
|
|
192
|
+
if (!escaped && ch === "\\") escaped = true;
|
|
193
|
+
else escaped = false;
|
|
194
|
+
}
|
|
195
|
+
throw this.error("unterminated string");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private object(): Record<string, unknown> {
|
|
199
|
+
this.offset++;
|
|
200
|
+
const result: Record<string, unknown> = {};
|
|
201
|
+
const names = new Set<string>();
|
|
202
|
+
this.space();
|
|
203
|
+
if (this.text[this.offset] === "}") {
|
|
204
|
+
this.offset++;
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
while (true) {
|
|
208
|
+
this.space();
|
|
209
|
+
if (this.text[this.offset] !== '"') throw this.error("expected object key");
|
|
210
|
+
const key = this.string();
|
|
211
|
+
if (names.has(key)) throw this.error(`duplicate object key ${JSON.stringify(key)}`);
|
|
212
|
+
names.add(key);
|
|
213
|
+
this.space();
|
|
214
|
+
if (this.text[this.offset++] !== ":") throw this.error("expected ':'");
|
|
215
|
+
result[key] = this.value();
|
|
216
|
+
this.space();
|
|
217
|
+
const separator = this.text[this.offset++];
|
|
218
|
+
if (separator === "}") return result;
|
|
219
|
+
if (separator !== ",") throw this.error("expected ',' or '}'");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private array(): unknown[] {
|
|
224
|
+
this.offset++;
|
|
225
|
+
const result: unknown[] = [];
|
|
226
|
+
this.space();
|
|
227
|
+
if (this.text[this.offset] === "]") {
|
|
228
|
+
this.offset++;
|
|
229
|
+
return result;
|
|
230
|
+
}
|
|
231
|
+
while (true) {
|
|
232
|
+
result.push(this.value());
|
|
233
|
+
this.space();
|
|
234
|
+
const separator = this.text[this.offset++];
|
|
235
|
+
if (separator === "]") return result;
|
|
236
|
+
if (separator !== ",") throw this.error("expected ',' or ']'");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function metadataValue(schema: VgiSchema, key: string): string {
|
|
242
|
+
const value = schema.metadata?.get(key);
|
|
243
|
+
if (value === undefined) throw new FilterV2Error(`missing schema metadata ${key}`);
|
|
244
|
+
return value;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function parseContext(schema: VgiSchema): EvaluationContext {
|
|
248
|
+
if (metadataValue(schema, "vgi_filter_encoding") !== FILTER_ENCODING) {
|
|
249
|
+
throw new FilterV2Error("unsupported filter encoding");
|
|
250
|
+
}
|
|
251
|
+
if (metadataValue(schema, "vgi_filter_version") !== FILTER_VERSION) {
|
|
252
|
+
throw new FilterV2Error("unsupported filter version");
|
|
253
|
+
}
|
|
254
|
+
const profile = metadataValue(schema, "vgi_evaluation_context");
|
|
255
|
+
const contextKeys = [
|
|
256
|
+
"vgi_time_zone", "vgi_calendar", "vgi_default_collation",
|
|
257
|
+
"vgi_ieee_floating_point_ops", "vgi_integer_division",
|
|
258
|
+
"vgi_context_provider_fingerprint",
|
|
259
|
+
];
|
|
260
|
+
if (profile === NO_EVALUATION_CONTEXT) {
|
|
261
|
+
if (contextKeys.some((key) => schema.metadata.has(key))) {
|
|
262
|
+
throw new FilterV2Error("vgi.none.v1 forbids DuckDB session context metadata");
|
|
263
|
+
}
|
|
264
|
+
return { profile };
|
|
265
|
+
}
|
|
266
|
+
if (profile !== DUCKDB_SESSION_CONTEXT) throw new FilterV2Error(`unknown evaluation context ${profile}`);
|
|
267
|
+
const required = contextKeys.slice(0, 5);
|
|
268
|
+
for (const key of required) metadataValue(schema, key);
|
|
269
|
+
const bool = (key: string): boolean => {
|
|
270
|
+
const value = metadataValue(schema, key);
|
|
271
|
+
if (value !== "true" && value !== "false") throw new FilterV2Error(`${key} must be true or false`);
|
|
272
|
+
return value === "true";
|
|
273
|
+
};
|
|
274
|
+
const fingerprint = schema.metadata.get("vgi_context_provider_fingerprint");
|
|
275
|
+
if (fingerprint !== undefined && (!fingerprint || utf8Length(fingerprint) > MAX_FINGERPRINT_BYTES)) {
|
|
276
|
+
throw new FilterV2Error("invalid context provider fingerprint");
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
profile,
|
|
280
|
+
timeZone: metadataValue(schema, "vgi_time_zone"),
|
|
281
|
+
calendar: metadataValue(schema, "vgi_calendar"),
|
|
282
|
+
defaultCollation: metadataValue(schema, "vgi_default_collation"),
|
|
283
|
+
ieeeFloatingPointOps: bool("vgi_ieee_floating_point_ops"),
|
|
284
|
+
integerDivision: bool("vgi_integer_division"),
|
|
285
|
+
providerFingerprint: fingerprint,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
class FilterParser {
|
|
290
|
+
private nodes = 0;
|
|
291
|
+
readonly context: EvaluationContext;
|
|
292
|
+
readonly document: Record<string, unknown>;
|
|
293
|
+
|
|
294
|
+
constructor(
|
|
295
|
+
readonly batch: VgiBatch,
|
|
296
|
+
readonly options: DeserializeFilterOptions,
|
|
297
|
+
) {
|
|
298
|
+
if (batch.numRows !== 1) throw new FilterV2Error("filter batch must contain exactly one row");
|
|
299
|
+
const first = batch.schema.fields[0];
|
|
300
|
+
if (!first || first.name !== "filter_spec" || first.nullable || !isUtf8(first.type)) {
|
|
301
|
+
throw new FilterV2Error("first field must be filter_spec: utf8 not null");
|
|
302
|
+
}
|
|
303
|
+
const names = batch.schema.fields.map((field) => field.name);
|
|
304
|
+
if (new Set(names).size !== names.length) throw new FilterV2Error("filter payload names must be unique");
|
|
305
|
+
for (let i = 1; i < names.length; i++) {
|
|
306
|
+
if (!PAYLOAD_NAME.test(names[i])) throw new FilterV2Error(`noncanonical payload name ${names[i]}`);
|
|
307
|
+
if (names[i].startsWith("type_") && this.valueAt(i) !== null) {
|
|
308
|
+
throw new FilterV2Error(`${names[i]} must contain NULL`);
|
|
68
309
|
}
|
|
69
|
-
return { type: "or", columnName, columnIndex, children: orChildren };
|
|
70
310
|
}
|
|
311
|
+
this.context = parseContext(batch.schema);
|
|
312
|
+
this.validateContextCapability();
|
|
313
|
+
const raw = this.valueAt(0);
|
|
314
|
+
if (typeof raw !== "string" || utf8Length(raw) > MAX_JSON_BYTES) {
|
|
315
|
+
throw new FilterV2Error("filter_spec must be UTF-8 JSON no larger than 1 MiB");
|
|
316
|
+
}
|
|
317
|
+
this.document = expectObject(new StrictJsonParser(raw).parse(), "filter document");
|
|
318
|
+
}
|
|
71
319
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
320
|
+
private valueAt(index: number): unknown {
|
|
321
|
+
const column = this.batch.getChildAt(index);
|
|
322
|
+
if (!column) throw new FilterV2Error(`missing payload column ${index}`);
|
|
323
|
+
return readCanonicalValue(this.batch.schema.fields[index].type, column, 0);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private validateContextCapability(): void {
|
|
327
|
+
if (this.context.profile === NO_EVALUATION_CONTEXT) return;
|
|
328
|
+
const matching = (this.options.evaluationContexts ?? [])
|
|
329
|
+
.filter((value) => value.profile === this.context.profile);
|
|
330
|
+
if (!matching.length) throw new FilterV2Error(`evaluation context ${this.context.profile} was not advertised`);
|
|
331
|
+
if (this.context.providerFingerprint !== undefined &&
|
|
332
|
+
!matching.some((value) => value.providerFingerprint === this.context.providerFingerprint)) {
|
|
333
|
+
throw new FilterV2Error("evaluation context fingerprint was not advertised");
|
|
83
334
|
}
|
|
335
|
+
}
|
|
84
336
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
337
|
+
private header(kind: "snapshot" | "delta", member: "predicates" | "updates"): unknown[] {
|
|
338
|
+
expectKeys(this.document, ["encoding", "semantics", "kind", member], [], "filter document");
|
|
339
|
+
if (this.document.encoding !== FILTER_ENCODING) throw new FilterV2Error("document encoding must be vgi.filters.v2");
|
|
340
|
+
if (this.document.semantics !== DUCKDB_STANDARD_V1) throw new FilterV2Error("unsupported filter semantics");
|
|
341
|
+
if (this.document.kind !== kind) throw new FilterV2Error(`expected a ${kind} document`);
|
|
342
|
+
return expectArray(this.document[member], member);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
snapshot(): PushdownFilters {
|
|
346
|
+
const entries = this.header("snapshot", "predicates");
|
|
347
|
+
if (entries.length > MAX_PREDICATES) throw new FilterV2Error("snapshot predicate limit exceeded");
|
|
348
|
+
const predicates = entries.map((value, index) => this.predicate(expectObject(value, `predicates[${index}]`), `predicates[${index}]`));
|
|
349
|
+
const ids = new Set<string>();
|
|
350
|
+
for (const predicate of predicates) {
|
|
351
|
+
if (predicate.revision !== 0) throw new FilterV2Error("snapshot revisions must be zero");
|
|
352
|
+
if (ids.has(predicate.id)) throw new FilterV2Error(`duplicate predicate id ${predicate.id}`);
|
|
353
|
+
ids.add(predicate.id);
|
|
97
354
|
}
|
|
355
|
+
return new PushdownFilters(predicates, this.context, this.options, new Map(predicates.map((p) => [p.id, 0])),
|
|
356
|
+
new Set(predicates.filter((p) => p.mode === "required").map((p) => p.id)));
|
|
357
|
+
}
|
|
98
358
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
// may reference constants by `value_ref` — resolve those now so the
|
|
103
|
-
// evaluator sees plain JS values.
|
|
104
|
-
const expr = parseExprNode(spec.expr, getValue);
|
|
105
|
-
if (!expr) return null;
|
|
106
|
-
return { type: "expression", columnName, columnIndex, expr };
|
|
359
|
+
delta(prior: PushdownFilters): PushdownFilters {
|
|
360
|
+
if (JSON.stringify(this.context) !== JSON.stringify(prior.evaluationContext)) {
|
|
361
|
+
throw new FilterV2Error("evaluation context changed within one scan");
|
|
107
362
|
}
|
|
363
|
+
const entries = this.header("delta", "updates");
|
|
364
|
+
const live = new Map(prior.predicates.map((predicate) => [predicate.id, predicate]));
|
|
365
|
+
const revisions = new Map(prior.revisions);
|
|
366
|
+
const parsed: Array<{ id: string; revision: number; predicate?: FilterPredicate }> = [];
|
|
367
|
+
const seen = new Set<string>();
|
|
368
|
+
for (let index = 0; index < entries.length; index++) {
|
|
369
|
+
const where = `updates[${index}]`;
|
|
370
|
+
const object = expectObject(entries[index], where);
|
|
371
|
+
const operation = expectString(object.operation, `${where}.operation`);
|
|
372
|
+
const common = operation === "upsert"
|
|
373
|
+
? ["operation", "id", "revision", "mode", "source", "expression"]
|
|
374
|
+
: ["operation", "id", "revision"];
|
|
375
|
+
expectKeys(object, common, [], where);
|
|
376
|
+
if (operation !== "upsert" && operation !== "remove") throw new FilterV2Error(`${where} has invalid operation`);
|
|
377
|
+
const id = this.predicateId(object.id, where);
|
|
378
|
+
const revision = expectUint(object.revision, `${where}.revision`);
|
|
379
|
+
if (seen.has(id)) throw new FilterV2Error(`duplicate delta predicate id ${id}`);
|
|
380
|
+
seen.add(id);
|
|
381
|
+
if (prior.requiredIds.has(id)) throw new FilterV2Error(`delta targets required predicate ${id}`);
|
|
382
|
+
if (operation === "upsert") {
|
|
383
|
+
const mode = expectString(object.mode, `${where}.mode`) as PredicateMode;
|
|
384
|
+
const source = expectString(object.source, `${where}.source`) as PredicateSource;
|
|
385
|
+
if (!MODES.has(mode) || !SOURCES.has(source)) {
|
|
386
|
+
throw new FilterV2Error(`${where} has unknown mode or source`);
|
|
387
|
+
}
|
|
388
|
+
expectObject(object.expression, `${where}.expression`);
|
|
389
|
+
if (mode !== "advisory") throw new FilterV2Error("delta upserts must be advisory");
|
|
390
|
+
}
|
|
391
|
+
if (revision <= (revisions.get(id) ?? -1)) continue;
|
|
392
|
+
parsed.push({ id, revision, predicate: operation === "upsert" ? this.predicate(object, where, true) : undefined });
|
|
393
|
+
}
|
|
394
|
+
if (new Set([...revisions.keys(), ...parsed.map((entry) => entry.id)]).size > MAX_PREDICATE_IDS) {
|
|
395
|
+
throw new FilterV2Error("delta predicate-id limit exceeded");
|
|
396
|
+
}
|
|
397
|
+
for (const update of parsed) {
|
|
398
|
+
revisions.set(update.id, update.revision);
|
|
399
|
+
if (update.predicate) live.set(update.id, update.predicate);
|
|
400
|
+
else live.delete(update.id);
|
|
401
|
+
}
|
|
402
|
+
return new PushdownFilters([...live.values()], this.context, prior.options, revisions, new Set(prior.requiredIds));
|
|
403
|
+
}
|
|
108
404
|
|
|
109
|
-
|
|
110
|
-
|
|
405
|
+
private predicateId(value: unknown, where: string): string {
|
|
406
|
+
const id = expectString(value, `${where}.id`);
|
|
407
|
+
if (utf8Length(id) > MAX_ID_BYTES) throw new FilterV2Error(`${where}.id is too long`);
|
|
408
|
+
return id;
|
|
111
409
|
}
|
|
112
|
-
}
|
|
113
410
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
411
|
+
private predicate(object: Record<string, unknown>, where: string, update = false): FilterPredicate {
|
|
412
|
+
expectKeys(object, [...(update ? ["operation"] : []), "id", "revision", "mode", "source", "expression"], [], where);
|
|
413
|
+
const mode = expectString(object.mode, `${where}.mode`) as PredicateMode;
|
|
414
|
+
const source = expectString(object.source, `${where}.source`) as PredicateSource;
|
|
415
|
+
if (!MODES.has(mode) || !SOURCES.has(source)) throw new FilterV2Error(`${where} has unknown mode or source`);
|
|
416
|
+
const expression = this.expression(expectObject(object.expression, `${where}.expression`), 1, true);
|
|
417
|
+
if (this.context.profile === NO_EVALUATION_CONTEXT && requiresSessionContext(expression)) {
|
|
418
|
+
throw new FilterV2Error("context-dependent expression requires vgi.duckdb.session.v1");
|
|
419
|
+
}
|
|
420
|
+
if (expression.node === "runtime_filter" && mode !== "advisory") {
|
|
421
|
+
throw new FilterV2Error("runtime_filter predicates must be advisory");
|
|
422
|
+
}
|
|
423
|
+
if (expression.node !== "runtime_filter" && !isBooleanExpression(expression)) {
|
|
424
|
+
throw new FilterV2Error("predicate root must resolve to BOOLEAN");
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
id: this.predicateId(object.id, where),
|
|
428
|
+
revision: expectUint(object.revision, `${where}.revision`),
|
|
429
|
+
mode,
|
|
430
|
+
source,
|
|
431
|
+
expression,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private payload(prefix: "value" | "type" | "artifact", rawRef: unknown): { ref: number; field: VgiField; value: unknown } {
|
|
436
|
+
const ref = expectUint(rawRef, `${prefix}_ref`);
|
|
437
|
+
const name = `${prefix}_${ref}`;
|
|
438
|
+
const indexes = this.batch.schema.fields.flatMap((field, index) => field.name === name ? [index] : []);
|
|
439
|
+
if (indexes.length !== 1) throw new FilterV2Error(`missing or duplicate payload ${name}`);
|
|
440
|
+
return { ref, field: this.batch.schema.fields[indexes[0]], value: this.valueAt(indexes[0]) };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private identity(raw: unknown, where: string): FunctionIdentity {
|
|
444
|
+
const object = expectObject(raw, where);
|
|
445
|
+
expectKeys(object, ["namespace", "name", "version"], [], where);
|
|
446
|
+
const identity = {
|
|
447
|
+
namespace: expectString(object.namespace, `${where}.namespace`),
|
|
448
|
+
name: expectString(object.name, `${where}.name`),
|
|
449
|
+
version: expectUint(object.version, `${where}.version`, true),
|
|
450
|
+
};
|
|
451
|
+
if (!IDENTITY_NAMESPACE.test(identity.namespace) || !IDENTITY_NAME.test(identity.name)) {
|
|
452
|
+
throw new FilterV2Error(`${where} has a noncanonical identity`);
|
|
453
|
+
}
|
|
454
|
+
return identity;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private expression(object: Record<string, unknown>, depth: number, root = false): FilterExpression {
|
|
458
|
+
if (depth > MAX_DEPTH) throw new FilterV2Error("expression depth limit exceeded");
|
|
459
|
+
if (++this.nodes > MAX_NODES) throw new FilterV2Error("expression node limit exceeded");
|
|
460
|
+
const node = object.node;
|
|
461
|
+
const child = (value: unknown) => this.expression(expectObject(value, "child expression"), depth + 1);
|
|
462
|
+
if (node === "column_ref") {
|
|
463
|
+
expectKeys(object, ["node", "column_index", "column_name"], [], "column_ref");
|
|
464
|
+
const columnIndex = expectUint(object.column_index, "column_ref.column_index");
|
|
465
|
+
const columnName = expectString(object.column_name, "column_ref.column_name");
|
|
466
|
+
const field = this.options.outputSchema.fields[columnIndex];
|
|
467
|
+
if (!field || field.name !== columnName) {
|
|
468
|
+
throw new FilterV2Error(`column_ref ${columnName} does not match index ${columnIndex}`);
|
|
133
469
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
470
|
+
validateArrowExtensions(field);
|
|
471
|
+
return { node, columnIndex, columnName, dataType: field.type };
|
|
472
|
+
}
|
|
473
|
+
if (node === "field_ref") {
|
|
474
|
+
expectKeys(object, ["node", "expression", "field_index", "field_name"], [], "field_ref");
|
|
475
|
+
const expression = child(object.expression);
|
|
476
|
+
const parent = expressionType(expression);
|
|
477
|
+
if (!parent || !isStruct(parent)) throw new FilterV2Error("field_ref input must be STRUCT");
|
|
478
|
+
const fieldIndex = expectUint(object.field_index, "field_ref.field_index");
|
|
479
|
+
const fieldName = expectString(object.field_name, "field_ref.field_name");
|
|
480
|
+
const field = (parent as any).children?.[fieldIndex] as VgiField | undefined;
|
|
481
|
+
if (!field || field.name !== fieldName) throw new FilterV2Error("field_ref name/index mismatch");
|
|
482
|
+
return { node, expression, fieldIndex, fieldName, dataType: field.type };
|
|
483
|
+
}
|
|
484
|
+
if (node === "literal") {
|
|
485
|
+
expectKeys(object, ["node", "value_ref"], [], "literal");
|
|
486
|
+
const payload = this.payload("value", object.value_ref);
|
|
487
|
+
validateArrowExtensions(payload.field);
|
|
488
|
+
return { node, valueRef: payload.ref, field: payload.field, value: payload.value };
|
|
489
|
+
}
|
|
490
|
+
if (node === "comparison") {
|
|
491
|
+
expectKeys(object, ["node", "op", "left", "right"], [], "comparison");
|
|
492
|
+
const op = expectString(object.op, "comparison.op") as ComparisonOp;
|
|
493
|
+
if (!Object.values(ComparisonOp).includes(op)) throw new FilterV2Error(`unknown comparison operator ${op}`);
|
|
494
|
+
return { node, op, left: child(object.left), right: child(object.right) };
|
|
495
|
+
}
|
|
496
|
+
if (node === "and" || node === "or") {
|
|
497
|
+
expectKeys(object, ["node", "children"], [], node);
|
|
498
|
+
const children = expectArray(object.children, `${node}.children`).map(child);
|
|
499
|
+
if (children.length < 2 || children.some((value) => !isBooleanExpression(value))) {
|
|
500
|
+
throw new FilterV2Error(`${node} requires at least two Boolean children`);
|
|
159
501
|
}
|
|
160
|
-
return {
|
|
502
|
+
return { node, children };
|
|
161
503
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
if (!
|
|
166
|
-
return {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
};
|
|
504
|
+
if (node === "not") {
|
|
505
|
+
expectKeys(object, ["node", "expression"], [], "not");
|
|
506
|
+
const expression = child(object.expression);
|
|
507
|
+
if (!isBooleanExpression(expression)) throw new FilterV2Error("not input must be Boolean");
|
|
508
|
+
return { node, expression };
|
|
509
|
+
}
|
|
510
|
+
if (node === "is_null") {
|
|
511
|
+
expectKeys(object, ["node", "expression", "negated"], [], "is_null");
|
|
512
|
+
return { node, expression: child(object.expression), negated: expectBoolean(object.negated, "is_null.negated") };
|
|
172
513
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
514
|
+
if (node === "in") {
|
|
515
|
+
expectKeys(object, ["node", "expression", "set", "negated"], [], "in");
|
|
516
|
+
const setObject = expectObject(object.set, "in.set");
|
|
517
|
+
const kind = setObject.kind;
|
|
518
|
+
let parsedSet: FilterSet;
|
|
519
|
+
if (kind === "literal") {
|
|
520
|
+
expectKeys(setObject, ["kind", "value_ref"], [], "in.set");
|
|
521
|
+
const payload = this.payload("value", setObject.value_ref);
|
|
522
|
+
validateArrowExtensions(payload.field);
|
|
523
|
+
if (!isList(payload.field.type) || !Array.isArray(payload.value)) {
|
|
524
|
+
throw new FilterV2Error("literal IN payload must be a non-NULL list");
|
|
525
|
+
}
|
|
526
|
+
parsedSet = { kind, valueRef: payload.ref, field: payload.field, values: payload.value };
|
|
527
|
+
} else if (kind === "external") {
|
|
528
|
+
expectKeys(setObject, ["kind", "batch_index", "column_index", "column_name"], [], "in.set");
|
|
529
|
+
const batchIndex = expectUint(setObject.batch_index, "in.set.batch_index");
|
|
530
|
+
const columnIndex = expectUint(setObject.column_index, "in.set.column_index");
|
|
531
|
+
const columnName = expectString(setObject.column_name, "in.set.column_name");
|
|
532
|
+
const batch = this.options.joinKeyBatches?.[batchIndex];
|
|
533
|
+
const field = batch?.schema.fields[columnIndex];
|
|
534
|
+
const column = batch?.getChildAt(columnIndex);
|
|
535
|
+
if (!batch || !field || !column || field.name !== columnName) {
|
|
536
|
+
throw new FilterV2Error("external IN reference is unavailable or mismatched");
|
|
537
|
+
}
|
|
538
|
+
validateArrowExtensions(field);
|
|
539
|
+
const values = Array.from({ length: batch.numRows }, (_, index) => readCanonicalValue(field.type, column, index));
|
|
540
|
+
parsedSet = { kind, batchIndex, columnIndex, columnName, values, batch };
|
|
541
|
+
} else {
|
|
542
|
+
throw new FilterV2Error("unknown IN set kind");
|
|
543
|
+
}
|
|
544
|
+
return { node, expression: child(object.expression), set: parsedSet, negated: expectBoolean(object.negated, "in.negated") };
|
|
545
|
+
}
|
|
546
|
+
if (node === "cast") {
|
|
547
|
+
expectKeys(object, ["node", "expression", "type_ref"], [], "cast");
|
|
548
|
+
const payload = this.payload("type", object.type_ref);
|
|
549
|
+
validateArrowExtensions(payload.field);
|
|
550
|
+
if (payload.value !== null) throw new FilterV2Error("cast type payload must be NULL");
|
|
551
|
+
return { node, expression: child(object.expression), typeRef: payload.ref, field: payload.field };
|
|
552
|
+
}
|
|
553
|
+
if (node === "arithmetic") {
|
|
554
|
+
expectKeys(object, ["node", "op", "left", "right"], [], "arithmetic");
|
|
555
|
+
const op = expectString(object.op, "arithmetic.op");
|
|
556
|
+
if (!["add", "subtract", "multiply", "divide", "modulo"].includes(op)) {
|
|
557
|
+
throw new FilterV2Error(`unknown arithmetic operator ${op}`);
|
|
558
|
+
}
|
|
559
|
+
return { node, op: op as any, left: child(object.left), right: child(object.right) };
|
|
560
|
+
}
|
|
561
|
+
if (node === "negate") {
|
|
562
|
+
expectKeys(object, ["node", "expression"], [], "negate");
|
|
563
|
+
return { node, expression: child(object.expression) };
|
|
564
|
+
}
|
|
565
|
+
if (node === "call") {
|
|
566
|
+
expectKeys(object, ["node", "function", "arguments"], ["options"], "call");
|
|
567
|
+
let fn: StandardFilterFunction | FunctionIdentity;
|
|
568
|
+
if (typeof object.function === "string") {
|
|
569
|
+
if (!STANDARD_FUNCTIONS.has(object.function as StandardFilterFunction)) {
|
|
570
|
+
throw new FilterV2Error(`unknown standard filter function ${object.function}`);
|
|
571
|
+
}
|
|
572
|
+
fn = object.function as StandardFilterFunction;
|
|
573
|
+
} else {
|
|
574
|
+
fn = this.identity(object.function, "call.function");
|
|
575
|
+
const key = filterFunctionIdentityKey(fn);
|
|
576
|
+
if (!supportsExtensionFilterFunction(fn)) throw new FilterV2Error(`unknown extension function ${key}`);
|
|
577
|
+
if (!(this.options.extensionFunctions ?? []).some((value) => filterFunctionIdentityKey(value) === key)) {
|
|
578
|
+
throw new FilterV2Error(`extension function ${key} was not advertised`);
|
|
579
|
+
}
|
|
179
580
|
}
|
|
581
|
+
const args = expectArray(object.arguments, "call.arguments");
|
|
582
|
+
if (args.length > MAX_ARGUMENTS) throw new FilterV2Error("call argument limit exceeded");
|
|
583
|
+
if (args.length !== 2) throw new FilterV2Error("registered v2 filter functions require exactly two arguments");
|
|
584
|
+
const parsedArgs = args.map(child);
|
|
585
|
+
if (typeof fn === "string") validateStandardCall(fn, parsedArgs);
|
|
586
|
+
const options = object.options === undefined ? undefined : expectObject(object.options, "call.options");
|
|
587
|
+
if (typeof fn === "string" && options !== undefined) throw new FilterV2Error("standard functions forbid options");
|
|
588
|
+
if (options && Object.keys(options).length) throw new FilterV2Error("extension function options are unsupported");
|
|
589
|
+
return { node, function: fn, arguments: parsedArgs, options };
|
|
590
|
+
}
|
|
591
|
+
if (node === "runtime_filter") {
|
|
592
|
+
expectKeys(object, ["node", "algorithm", "input", "artifact_ref", "null_handling"], [], "runtime_filter");
|
|
593
|
+
if (!root) throw new FilterV2Error("runtime_filter is allowed only at a predicate root");
|
|
594
|
+
const algorithm = this.identity(object.algorithm, "runtime_filter.algorithm");
|
|
595
|
+
const key = filterFunctionIdentityKey(algorithm);
|
|
596
|
+
if (!KNOWN_RUNTIME_ALGORITHMS.has(key)) throw new FilterV2Error(`unknown runtime filter ${key}`);
|
|
597
|
+
const payload = this.payload("artifact", object.artifact_ref);
|
|
598
|
+
validateArrowExtensions(payload.field);
|
|
599
|
+
const nullHandling = expectString(object.null_handling, "runtime_filter.null_handling");
|
|
600
|
+
if (nullHandling !== "pass" && nullHandling !== "reject") throw new FilterV2Error("invalid runtime null handling");
|
|
601
|
+
const supported = (this.options.runtimeAlgorithms ?? []).some((value) => filterFunctionIdentityKey(value) === key);
|
|
602
|
+
if (supported) throw new FilterV2Error("runtime algorithm was advertised without an evaluator");
|
|
180
603
|
return {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
children: kids,
|
|
604
|
+
node, algorithm, input: child(object.input), artifactRef: payload.ref,
|
|
605
|
+
field: payload.field, artifact: payload.value, nullHandling, supported,
|
|
184
606
|
};
|
|
185
607
|
}
|
|
608
|
+
throw new FilterV2Error(`unknown expression node ${String(node)}`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function expressionType(expression: FilterExpression): VgiDataType | undefined {
|
|
613
|
+
switch (expression.node) {
|
|
614
|
+
case "column_ref": return expression.dataType;
|
|
615
|
+
case "field_ref": return expression.dataType;
|
|
616
|
+
case "literal": return expression.field.type;
|
|
617
|
+
case "cast": return expression.field.type;
|
|
618
|
+
default: return undefined;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function validateStandardCall(fn: StandardFilterFunction, args: FilterExpression[]): void {
|
|
623
|
+
const types = args.map(expressionType);
|
|
624
|
+
let matches = false;
|
|
625
|
+
if (fn === "starts_with" || fn === "ends_with" || fn === "contains") {
|
|
626
|
+
matches = types.every((type) => type !== undefined && isUtf8(type));
|
|
627
|
+
} else if (fn === "list_contains") {
|
|
628
|
+
const listType = types[0];
|
|
629
|
+
const needleType = types[1];
|
|
630
|
+
const element = listType && isList(listType) ? (listType as any).children?.[0]?.type as VgiDataType | undefined : undefined;
|
|
631
|
+
matches = element !== undefined && needleType !== undefined && typeSignature(element) === typeSignature(needleType);
|
|
632
|
+
}
|
|
633
|
+
if (!matches) throw new FilterV2Error(`${fn} arguments do not bind under vgi.duckdb.standard.v1`);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function isBooleanExpression(expression: FilterExpression): boolean {
|
|
637
|
+
if (["comparison", "and", "or", "not", "is_null", "in", "runtime_filter", "call"].includes(expression.node)) {
|
|
638
|
+
return true;
|
|
639
|
+
}
|
|
640
|
+
return expression.node === "literal" && isBool(expression.field.type);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function requiresSessionContext(expression: FilterExpression): boolean {
|
|
644
|
+
switch (expression.node) {
|
|
645
|
+
case "arithmetic":
|
|
646
|
+
return expression.op === "divide" || expression.op === "modulo" ||
|
|
647
|
+
requiresSessionContext(expression.left) || requiresSessionContext(expression.right);
|
|
648
|
+
case "cast":
|
|
649
|
+
const source = expressionType(expression.expression);
|
|
650
|
+
const contextual = (type: VgiDataType | undefined): boolean => !!type &&
|
|
651
|
+
(isUtf8(type) || isDate(type) || isTime(type) || isTimestamp(type));
|
|
652
|
+
return (contextual(source) && contextual(expression.field.type)) || requiresSessionContext(expression.expression);
|
|
653
|
+
case "field_ref": case "not": case "is_null": case "negate":
|
|
654
|
+
return requiresSessionContext(expression.expression);
|
|
655
|
+
case "comparison":
|
|
656
|
+
return requiresSessionContext(expression.left) || requiresSessionContext(expression.right);
|
|
657
|
+
case "and": case "or":
|
|
658
|
+
return expression.children.some(requiresSessionContext);
|
|
659
|
+
case "in":
|
|
660
|
+
return requiresSessionContext(expression.expression);
|
|
661
|
+
case "call":
|
|
662
|
+
return expression.arguments.some(requiresSessionContext);
|
|
663
|
+
case "runtime_filter":
|
|
664
|
+
return requiresSessionContext(expression.input);
|
|
186
665
|
default:
|
|
187
|
-
return
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
case "ne": case "!=": return ComparisonOp.NE;
|
|
195
|
-
case "gt": case ">": return ComparisonOp.GT;
|
|
196
|
-
case "ge": case ">=": return ComparisonOp.GE;
|
|
197
|
-
case "lt": case "<": return ComparisonOp.LT;
|
|
198
|
-
case "le": case "<=": return ComparisonOp.LE;
|
|
199
|
-
default: return ComparisonOp.EQ;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Deserialize pushdown filters from an Arrow RecordBatch.
|
|
205
|
-
*
|
|
206
|
-
* Wire format:
|
|
207
|
-
* - Column 0: UTF8 — JSON array of filter specs. Field metadata: vgi_filter_version: "1"
|
|
208
|
-
* - Columns 1+: Scalar values referenced by value_ref → column N+1
|
|
209
|
-
*/
|
|
210
|
-
export function deserializeFilters(
|
|
211
|
-
batch: VgiBatch,
|
|
212
|
-
getJoinKeysColumn?: (columnName: string) => any[] | null,
|
|
213
|
-
): PushdownFilters {
|
|
214
|
-
// Validate version
|
|
215
|
-
const field0 = batch.schema.fields[0];
|
|
216
|
-
const version = field0.metadata?.get("vgi_filter_version");
|
|
217
|
-
if (!version) {
|
|
218
|
-
throw new Error(
|
|
219
|
-
"Filter batch missing vgi_filter_version metadata on field 0",
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
if (version !== "1") {
|
|
223
|
-
throw new Error(`Unsupported filter version: ${version}`);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Parse JSON filter specs from column 0, row 0
|
|
227
|
-
const jsonCol = batch.getChildAt(0)!;
|
|
228
|
-
const jsonStr = jsonCol.get(0) as string;
|
|
229
|
-
let specs: any[];
|
|
230
|
-
try {
|
|
231
|
-
specs = JSON.parse(jsonStr);
|
|
232
|
-
} catch (e) {
|
|
233
|
-
throw new Error(`Failed to parse filter JSON: ${e}`);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// Value resolver: value_ref N → column N+1, row 0. Read through the canonical
|
|
237
|
-
// reader so filter literals land in the SAME representation as the column
|
|
238
|
-
// cells they're compared against (e.g. timestamp -> bigint on both backends),
|
|
239
|
-
// and so it works on flechette (no arrow-js `.get()`).
|
|
240
|
-
const getValue = (ref: number): any => {
|
|
241
|
-
const col = batch.getChildAt(ref + 1);
|
|
242
|
-
if (!col) {
|
|
243
|
-
throw new Error(`Filter value_ref ${ref} references non-existent column ${ref + 1}`);
|
|
244
|
-
}
|
|
245
|
-
const type = batch.schema.fields[ref + 1].type as unknown as VgiDataType;
|
|
246
|
-
return readCanonicalValue(type, col, 0);
|
|
247
|
-
};
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export function deserializeFilters(batch: VgiBatch, options: DeserializeFilterOptions): PushdownFilters {
|
|
671
|
+
return new FilterParser(batch, options).snapshot();
|
|
672
|
+
}
|
|
248
673
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
674
|
+
export function applyFilterDelta(prior: PushdownFilters, batch: VgiBatch): PushdownFilters {
|
|
675
|
+
return new FilterParser(batch, prior.options).delta(prior);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** @deprecated External-set references are positional in v2; pass joinKeyBatches instead. */
|
|
679
|
+
export function buildJoinKeysLookup(joinKeyBatches: VgiBatch[]): (columnName: string) => unknown[] | null {
|
|
680
|
+
const values = new Map<string, unknown[]>();
|
|
681
|
+
for (const batch of joinKeyBatches) {
|
|
682
|
+
batch.schema.fields.forEach((field, columnIndex) => {
|
|
683
|
+
const column = batch.getChildAt(columnIndex);
|
|
684
|
+
if (!column) return;
|
|
685
|
+
values.set(field.name, Array.from({ length: batch.numRows }, (_, row) => readCanonicalValue(field.type, column, row)));
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
return (name) => values.get(name) ?? null;
|
|
253
689
|
}
|