@query-farm/vgi 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@query-farm/vgi",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "description": "TypeScript framework for extending DuckDB with user-defined functions via the Vector Gateway Interface (VGI) over Arrow IPC.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Query Farm LLC (https://query.farm)",
@@ -633,11 +633,29 @@ function validateStandardCall(fn: StandardFilterFunction, args: FilterExpression
633
633
  if (!matches) throw new FilterV2Error(`${fn} arguments do not bind under vgi.duckdb.standard.v1`);
634
634
  }
635
635
 
636
+ /**
637
+ * Whether `expression` resolves to BOOLEAN.
638
+ *
639
+ * Ask the type, never the node kind. The two agree for every node whose type
640
+ * is structurally fixed — a comparison is BOOLEAN whatever its operands — and
641
+ * that is exactly why enumerating kinds looked sufficient. It is not:
642
+ * `column_ref`, `field_ref` and `cast` are BOOLEAN or not depending on a type
643
+ * resolved from elsewhere (the bind output schema, or a parent expression),
644
+ * and the schema admits all of them as a predicate root — `coreExpression`
645
+ * lists `columnRef` first.
646
+ *
647
+ * Enumerating kinds rejected the lot, so the idiomatic `WHERE flag` /
648
+ * `WHERE NOT flag` — which DuckDB pushes down as a bare column reference
649
+ * rather than rewriting to `flag = true` — was refused as "predicate root
650
+ * must resolve to BOOLEAN", on a node whose type the parser had already
651
+ * resolved to BOOLEAN and stored. See vgi-python 0.36.2.
652
+ */
636
653
  function isBooleanExpression(expression: FilterExpression): boolean {
637
654
  if (["comparison", "and", "or", "not", "is_null", "in", "runtime_filter", "call"].includes(expression.node)) {
638
655
  return true;
639
656
  }
640
- return expression.node === "literal" && isBool(expression.field.type);
657
+ const type = expressionType(expression);
658
+ return type !== undefined && isBool(type);
641
659
  }
642
660
 
643
661
  function requiresSessionContext(expression: FilterExpression): boolean {
@@ -388,7 +388,46 @@ function expressionColumns(expression: FilterExpression, output = new Map<number
388
388
  return output;
389
389
  }
390
390
 
391
+ /**
392
+ * `WHERE flag` / `WHERE NOT flag` — a BOOLEAN column is a predicate on its own.
393
+ *
394
+ * DuckDB pushes such a predicate down as a bare `column_ref` rather than
395
+ * rewriting it to `flag = true`, and the schema admits that: `coreExpression`
396
+ * lists `columnRef` first. `flag` on its own is legal SQL, so this is not
397
+ * about the fragment being invalid — it is that the shape is invisible to
398
+ * everything that looks for a column/constant pair: `getColumnValues` returns
399
+ * nothing for it, and the rendering disagrees with every other VGI SDK, all
400
+ * of which spell it `flag = true`.
401
+ *
402
+ * The projection is exact rather than approximate, including under NULLs:
403
+ * `WHERE flag` keeps only TRUE (a NULL predicate is not satisfied) and so does
404
+ * `flag = true`; `WHERE NOT flag` keeps only FALSE (`NOT NULL` is NULL) and so
405
+ * does `flag = false`. Three-valued logic makes both pairs agree on every
406
+ * input, which is what lets this be a projection and not a change of meaning.
407
+ *
408
+ * A column that is not BOOLEAN is left alone rather than guessed at.
409
+ *
410
+ * @returns the column name and the constant it compares equal to, or
411
+ * undefined when `expression` is neither shape.
412
+ */
413
+ function booleanColumnLeaf(expression: FilterExpression): { columnName: string; value: boolean } | undefined {
414
+ const column = (node: FilterExpression) =>
415
+ node.node === "column_ref" && node.dataType !== undefined && isBool(node.dataType)
416
+ ? node.columnName
417
+ : undefined;
418
+ if (expression.node === "not") {
419
+ const name = column(expression.expression);
420
+ return name === undefined ? undefined : { columnName: name, value: false };
421
+ }
422
+ const name = column(expression);
423
+ return name === undefined ? undefined : { columnName: name, value: true };
424
+ }
425
+
391
426
  function discreteValues(expression: FilterExpression, columnName: string): unknown[] | null {
427
+ // `WHERE flag` pins `flag` to TRUE exactly as `flag = true` does, so a
428
+ // value-pruning caller sees the constant either way round.
429
+ const leaf = booleanColumnLeaf(expression);
430
+ if (leaf && leaf.columnName === columnName) return [leaf.value];
392
431
  if (expression.node === "comparison" && expression.op === ComparisonOp.EQ) {
393
432
  if (expression.left.node === "column_ref" && expression.left.columnName === columnName && expression.right.node === "literal") return [expression.right.value];
394
433
  if (expression.right.node === "column_ref" && expression.right.columnName === columnName && expression.left.node === "literal") return [expression.left.value];
@@ -502,7 +541,7 @@ export class PushdownFilters {
502
541
  }
503
542
 
504
543
  toSql(): string {
505
- return this.predicates.map((predicate) => expressionToSql(predicate.expression)).join(" AND ");
544
+ return this.predicates.map((predicate) => predicateToSql(predicate.expression)).join(" AND ");
506
545
  }
507
546
  }
508
547
 
@@ -514,6 +553,16 @@ function quote(value: unknown): string {
514
553
  return String(value);
515
554
  }
516
555
 
556
+ /**
557
+ * Render one predicate, projecting a bare boolean column onto its equality
558
+ * form first. Predicate positions are the root and the children of `and`/`or`
559
+ * — the shape that actually turns up is `other = x AND NOT flag`.
560
+ */
561
+ export function predicateToSql(expression: FilterExpression): string {
562
+ const leaf = booleanColumnLeaf(expression);
563
+ return leaf ? `${leaf.columnName} = ${quote(leaf.value)}` : expressionToSql(expression);
564
+ }
565
+
517
566
  export function expressionToSql(expression: FilterExpression): string {
518
567
  switch (expression.node) {
519
568
  case "column_ref": return expression.columnName;
@@ -527,7 +576,7 @@ export function expressionToSql(expression: FilterExpression): string {
527
576
  };
528
577
  return `${expressionToSql(expression.left)} ${symbols[expression.op]} ${expressionToSql(expression.right)}`;
529
578
  }
530
- case "and": case "or": return `(${expression.children.map(expressionToSql).join(` ${expression.node.toUpperCase()} `)})`;
579
+ case "and": case "or": return `(${expression.children.map(predicateToSql).join(` ${expression.node.toUpperCase()} `)})`;
531
580
  case "not": return `(NOT ${expressionToSql(expression.expression)})`;
532
581
  case "is_null": return `${expressionToSql(expression.expression)} IS ${expression.negated ? "NOT " : ""}NULL`;
533
582
  case "in": return `${expressionToSql(expression.expression)} ${expression.negated ? "NOT " : ""}IN (${expression.set.values.map(quote).join(", ")})`;