Skip to content

feat(python): typed filter-expression builder as an alternative to raw SQL - #9328

Closed
jonasdedden wants to merge 1 commit into
lance-format:mainfrom
jonasdedden:typed-filter-builder
Closed

jonasdedden wants to merge 1 commit into
lance-format:mainfrom
jonasdedden:typed-filter-builder

Conversation

@jonasdedden

Copy link
Copy Markdown
Contributor

Problem

Raw SQL filters are parsed without any knowledge of the caller's intent, so three common generator patterns hit planner pitfalls:

The existing typed path, pa.compute.Expression \u2192 Substrait, avoids the #9318/#9319 traps (verified: all spellings use the index, both boolean orders plan) but cannot express what filter generators need: pyarrow refuses to serialize the implicit safe cast in i > 1.5 (Substrait is only capable of representing unsafe casts), the planner rejects nested references in Substrait filters, and Expression has no constructors for string functions, temporal extraction, or list/struct access.

Chosen rule

A minimal client-side builder, lance.filter (col/lit/to_sql), rendered against the dataset schema. ScannerBuilder.filter (hence scanner/to_table), count_rows, delete, and update accept an Expr and render it with the dataset schema. Three index-preserving rules fire only with a schema in hand:

  • fractional literal vs integer column \u2192 equivalent integer bound (i > 1.5 \u2192 i > 1), with null-aware tautologies for =/!=, infinities, and out-of-range bounds;
  • literal vs Float32 \u2192 CAST(<literal> AS float), so the column keeps its type and index;
  • boolean expression vs boolean expression \u2192 AND/OR/NOT expansion, so either operand order plans.

Negation is pushed through comparisons (the rewrites are filter-equivalent but not value-equivalent: NOT (i IS NOT NULL) would keep null rows that ~(i != 1.5) must drop). Anything without an exact spelling raises FilterError instead of guessing: division/modulo/power, NaN literals, timezone-aware timestamps, decimals, out-of-range integer literals.

Alternatives considered

Compatibility and minimum version

Pure-Python SQL generation; no Rust changes, no new dependencies, no format changes. Emitted SQL was executed verbatim on 9.0.0, 12.0.0b6, and 12.0.0b7: identical row counts everywhere except float-zero equality (f = 0.0 misses -0.0 before b7, as established for #6236). So float filters need pylance >= 12.0.0b7 (12.0.0 stable once published); every other spelling already plans on 9.0.0.

Test results

New test_filter.py section (9 tests): SQL snapshot spellings; positive and negated filters vs hand oracles over nulls, both zeros, infinities, nested fields, strings, dates, and microsecond timestamps; explain_plan assertions that the bound rewrite, the Float32 cast spelling, IN lists, and starts_with use ScalarIndexQuery while the hand-written double-cast baseline does not; count_rows/delete/update acceptance; error cases. Validated locally against the 13.0.0b4 wheel (9 passed); full CI pending. ruff check and ruff format --check pass on all touched files. Docs: new Typed filter expressions subsection in the read-and-write guide.

Limitations (why draft)

  • Prototype scope: comparisons, boolean logic, null/NaN tests, IN, BETWEEN, + - *, starts_with/ends_with/contains; no temporal extraction, list/map access, or decimal/binary literals.
  • Float column-vs-column comparisons render plain SQL and keep total-order semantics (bug: -0.0 and 0.0 still compare unequal between columns and in array_has #9316 scope, not handled here).
  • Fragment.scanner still takes SQL strings only.
  • Seeking direction: whether reviewers prefer this builder, a DataFusion-expression acceptance, or Substrait improvements before this leaves draft.

Related: #9317, #9318, #9319. Closes none of them.

…o raw SQL

Raw SQL filters are parsed without knowledge of the caller's intent:
an integer column against a fractional literal fails to plan (lance-format#9317),
a double literal against Float32 casts the column and skips its index
(lance-format#9318), and a boolean column against a boolean expression fails in one
operand order (lance-format#9319). The Substrait path avoids some of this but cannot
express nested fields, string functions, or mixed int/float comparisons.

Add a minimal lance.filter builder (col/lit/to_sql plus Expr accepted
by scanner/count_rows/delete/update) that renders against the dataset
schema with three index-preserving rules: integer bounds for fractional
comparisons, CAST AS float literals for Float32, and AND/OR/NOT expansion
for boolean-vs-boolean comparisons. Anything without an exact spelling
raises FilterError instead of guessing.

Tests cover positive and negated filters against nulls, signed zeros,
infinities, nested fields, and both index types, with explain_plan
evidence that the indexed spellings stay indexed.
@github-actions github-actions Bot added A-python Python bindings A-docs Documentation enhancement New feature or request labels Sep 17, 2026
@jonasdedden

Copy link
Copy Markdown
Contributor Author

Closing in favor of #9333.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-docs Documentation A-python Python bindings enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant