Skip to content

fix(query): range-filter pushdown keeps every conjunct; push xsd:decimal bounds - #1778

Merged
bplatz merged 6 commits into
mainfrom
fix/range-pushdown-partial-and
Sep 4, 2026
Merged

bplatz merged 6 commits into
mainfrom
fix/range-pushdown-partial-and

Conversation

@bplatz

@bplatz bplatz commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

A FILTER that conjoins two bounds on one variable silently dropped any bound the planner could not represent as a RangeValue, most commonly an xsd:decimal literal such as 0.01. FILTER(?v >= 0 && ?v < 0.01) returned every row with ?v >= 0. A SHACL rule built on this shape flagged 193,337 of 195,338 receipt lines instead of 492.

Two defects, fixed in two commits:

  1. Partial pushdown replaced the whole filter. The And branch of extract_range_constraints skipped conjuncts that yielded no constraint and still returned the rest. The pushdown consumer then marked the filter consumed because every variable had a bound, without checking that every conjunct contributed. Now a conjunct that fails to extract fails the whole expression, so the filter is kept and evaluated in full. Queries whose conjuncts all extract plan exactly as before.

  2. Bounds of unlisted types compared as Equal. compare_range_values fell back to Equal for any pair it did not list, which included every temporal pair. merge then kept whichever same-side bound it saw first: ?d >= 2020-01-01 && ?d >= 2021-06-01 pushed the 2020 bound and consumed the filter. The comparator now orders within a class via the same numeric_cmp / temporal_cmp the scan uses and returns None for unordered pairs; merge reports failure on an unordered same-side pair and both extraction paths bail to "no pushdown".

A fourth commit closes an adjacent parser hole found while testing the above. xsd:integer is unbounded and the lexer has long emitted a distinct BigInteger token past i64; term position consumes it and lowering promotes it to FlakeValue::BigInt, but try_parse_literal had no arm for it, so FILTER(?v > 100000000000000000000) was a parse error while the same bound as a typed literal worked. Expressions parse - as the unary operator, so only the unsigned literal was missing. Parse-only: such bounds still have no RangeValue and do not push down.

RangeValue::Decimal is added so decimal bounds push into the scan-side ObjectBounds like integers and doubles. No encoding work was needed: ObjectBounds::matches is already class-aware, and the scan's key-range narrowing only engages for temporal types.

Performance

No plan changes for queries that pushed down correctly before. Mixed decimal filters now take the same in-scan lane as all-integer filters instead of the generic filter operator. Partially addresses #1335: decimal bounds now push into the scan-side ObjectBounds, but the inline order-preserving decimal encoding and the seek-level key narrowing for numeric bounds both remain, and the issue stays open.

Tests

  • Planner units: mixed decimal shape extracts both bounds; cross-type tighter merge in either order; cross-type contradiction; temporal same-side merge; unordered same-side bounds rejected; boolean conjunct still refuses the whole And; pushdown consumer neither narrows nor consumes a partially-extractable filter.
  • End-to-end SPARQL: a case table over the six-row repro (plus four dated rows) runs against a novelty-only ledger and an indexed one, asserting the index landed at the seed commit.
  • All new tests were run against the pre-fix code and fail there: the mixed decimal case returned all six rows, the date case returned the row the tighter bound excludes, in both lanes.
  • Parser unit: an oversized xsd:integer parses in expression position, unsigned and negated, and i64::MAX still lexes as Integer while one past it becomes BigInteger. The e2e table gained the three bare-form cases; under the reverted parser arm they fail with "Expected expression".
  • A third commit restores independent teeth for defect 1. Once RangeValue::Decimal landed, every literal in the case table became representable, so reverting the total-or-nothing arm left both e2e tests green and only the planner units caught it. An xsd:integer bound past i64 has no RangeValue at all, so a conjunct carrying one still exercises the partial-extraction path; both directions are covered so a lane returning nothing cannot pass the empty case by accident. Re-verified under the reverted arm: both lanes fail on that case, returning all six rows instead of none.

Not in scope

Gates

cargo fmt --check, cargo clippy --all --all-features --all-targets -D warnings, cargo nextest run --workspace --all-features: clean. The one workspace failure was the known LocalStack PortNotExposed testcontainer flake, which passes isolated.

main is merged into the branch (clean). Re-run after the merge: cargo fmt --all --check, cargo test -p fluree-db-query --lib planner:: (104 passed), cargo test -p fluree-db-api --test grp_query_sparql (374 passed). On the merged head every CI job was green: fmt, clippy, test, testsuite-sparql, wasm32, wasm-smoke, sql-bridge, bench-paths and bench-compare.

For the parser commit, re-run locally: cargo test -p fluree-db-sparql (642 passed), cargo test -p fluree-db-api --test grp_query_sparql (374 passed), and the workspace-excluded testsuite-sparql W3C suite (81 passed, 0 failed) since this one touches the parser. cargo fmt --all --check and cargo clippy -p fluree-db-sparql -p fluree-db-api --all-targets --no-deps clean.

`extract_range_constraints` skipped any AND conjunct that yielded no
constraint and still returned the ones it could represent. The pushdown
consumer then dropped the whole FILTER as "fully pushed" because every
variable in it had a bound, so the skipped conjunct was never evaluated.

The common trigger is a bare SPARQL literal like `0.01`, which is
xsd:decimal and has no `RangeValue`: `FILTER(?v >= 0 && ?v < 0.01)`
returned every row with `?v >= 0`. Booleans or any other constant that
`extract_const` rejects behave the same way.

Now a conjunct that fails to extract fails the whole expression, so the
filter is kept and evaluated in full. Queries whose conjuncts all
extract plan exactly as before.

Adds planner unit tests for the mixed-conjunct, reversed, nested and
boolean shapes, plus an end-to-end SPARQL test over the six-row repro
from the report. All fail without the fix.
Add `RangeValue::Decimal` so a bare SPARQL literal like `0.01` pushes
into the scan-side object bounds instead of forcing the whole FILTER
back onto the generic lane. The scan already compares bounds with the
class-aware `ObjectBounds::matches`, so no encoding work is needed.

`compare_range_values` now orders values within their class and returns
`None` for pairs that have no order, using the same `numeric_cmp` and
`temporal_cmp` the scan uses. It previously fell back to `Equal` for
every pair it did not list, which included all temporal pairs and any
cross-class pair. With `Equal`, `merge` kept whichever bound it saw
first, so `?d >= 2020-01-01 && ?d >= 2021-06-01` pushed the 2020 bound
and consumed the filter, returning rows the 2021 bound excluded.

`RangeConstraint::merge` now reports failure when two same-side bounds
cannot be ordered, and both extraction paths bail to "no pushdown" in
that case. `is_unsatisfiable` treats an unordered pair as satisfiable;
the scan's class check rejects every row for it anyway.

Tests: planner units for the mixed decimal shape, cross-type tighter
merges, cross-type contradiction, the temporal same-side merge, and
unordered same-side bounds. The end-to-end SPARQL table gains
same-side, contradictory and date cases and now runs over both a
novelty-only ledger and an indexed one, asserting the index landed.
The temporal cases fail in both lanes without the comparator change.
@bplatz bplatz added P1 Next up: wrong results, live user pain, unblocks area:query Query execution, planning, fast paths, overlay, result formatting labels Sep 3, 2026
@bplatz
bplatz requested review from aaj3f and zonotope September 3, 2026 18:35

@aaj3f aaj3f left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bplatz good catch & good fix. The kind where the shape (?v >= 0 && ?v < 0.01) is so ordinary that silently returning every ?v >= 0 row is the worst possible failure, and the diagnosis is exactly right on both counts: the And arm dropped what it couldn't represent while the consumer's per-variable check couldn't see a missing conjunct, and the comparator's Equal fallback let merge keep whichever temporal bound came first. I verified both rather than reading them: restoring the conjunct skip reddens the two planner unit tests, and restoring the Equal fallback for temporal pairs reddens both e2e tests on precisely the >= 2020 && >= 2021 case in the novelty and indexed lanes. Making compare_range_values route through the same numeric_cmp/temporal_cmp the scan's ObjectBounds::matches (and the generic comparator) already use is the part I like most — it closes the class of planner-vs-scan disagreement rather than this instance, and RangeValue::Decimal just falls out of it.

One small thing I'd fold in: the SPARQL case table no longer pins the total-or-nothing fix on its own — with commit 1 reverted both e2e tests still pass, because commit 2 made every literal in the table representable. A single case with a typed oversized-integer conjunct (?v >= 0 && ?v > "100000000000000000000"^^xsd:integer[]) restores independent teeth; I confirmed it goes red under the reverted arm and green at HEAD. Also worth knowing for the "not in scope" note: the bare 100000000000000000000 form is a parse error today, so that item is only reachable via the typed literal (pre-existing).

Adherence to repo commitments:

  • Patterns/abstractions: ✔ fix at the extractor, not a second conjunct walk in the consumer; comparator reuses numeric_cmp/temporal_cmp shared with the scan and the generic lane; SPARQL/JSON-LD parity preserved by construction (shared IR, IR-level unit tests).
  • Performance (speed first, memory second): ✔ planner-only, O(conjuncts) per query; scan untouched; mixed decimal filters now take the in-scan bounds lane instead of the generic filter operator. No performance-degradation risk.
  • Testing: ✔ seven planner unit tests plus a two-lane SPARQL case table in the declared grp_query_sparql target, mutation-verified here; ⚠️ the e2e table needs one unrepresentable-conjunct case to pin defect 1 independently.
  • Conventions: ✔ two thorough multi-line commits, P1/area:query labelled, self-describing title; fmt/clippy green in CI.

Verified locally at branch HEAD 29c99cbf2: cargo test -p fluree-db-query -- planner::tests::… → 33 passed; cargo test -p fluree-db-api --test grp_query_sparql sparql_range_filter → 2 passed; mutation (restore conjunct skip) → 2 unit tests FAILED, e2e still green → teeth note; mutation + typed-BigInt case → e2e FAILED as expected, restored clean; mutation (temporal → Equal) → both e2e FAILED as expected, restored clean; cargo fmt --check clean.

Approving so you can merge when ready, but maybe worth adding that one case first.

("ex:price", "?v < 5 && ?v < 0.01", &["b"]),
("ex:price", "?v < 0.01 && ?v < 5", &["b"]),
("ex:price", "?v > 0.5 && ?v > 0e0", &["a", "c", "e"]),
// Contradictory across types: empty, not "everything".

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix (test teeth, fold in now). The SPARQL table doesn't pin the total-or-nothing fix on its own any more.

With the And arm's conjunct skip put back (planner.rs:631), the two planner unit tests go red but both sparql_range_filter_keeps_every_conjunct_* tests stay green — because after commit 2 every literal in the table is representable (?v < 0.01 now extracts as a decimal bound), so there's no conjunct left to drop.

A case with a parseable-but-unrepresentable conjunct gives it independent teeth. This one passes at HEAD and fails under the reverted arm (returns all six rows):

("ex:price", "?v >= 0 && ?v > \"100000000000000000000\"^^xsd:integer", &[]),

One line, and it keeps the e2e honest against a future refactor of the extractor.

/// Returns `None` for pairs that have no order (numeric vs string, date vs
/// time, ...) — never a fabricated `Equal`, which would let `merge` keep an
/// arbitrary bound and silently drop the other.
fn compare_range_values(a: &RangeValue, b: &RangeValue) -> Option<std::cmp::Ordering> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Praise. Routing numeric pairs through FlakeValue::numeric_cmp and temporal pairs through temporal_cmp — the same functions ObjectBounds::matches and the generic comparator use — is the fix that closes the class rather than the instance: the planner can no longer keep a bound the scan would order differently. Returning None instead of a fabricated Equal is the whole bug.

/// lower bound). The tighter bound is then unknowable, so the caller must
/// not push either one down; `self` is left unchanged in that case.
#[must_use]
pub fn merge(&mut self, other: &RangeConstraint) -> bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Praise. Computing both sides into locals and committing only when both merges succeed means a failed merge leaves self untouched, and #[must_use] makes the two callers check it. Worth keeping that shape if the function grows.

// Bounds of different classes (numeric vs string, ...) are
// not provably contradictory here; the scan's class-aware
// `ObjectBounds::matches` rejects every row anyway.
None => false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note (no change needed). I traced whether None => false here can let an unordered pair through: it can only be an opposite-side cross-class pair (same-side ones already failed merge), and ObjectBounds::matches rejects every row for it via class_cmp — which is also what SPARQL says for a number-vs-string comparison (type error → excluded). Consistent; the comment already says so.

The SPARQL case table lost its teeth for the first fix once RangeValue
gained Decimal: every literal in the table became representable, so
restoring the dropped-conjunct skip left both e2e tests green. Only the
planner units still caught it.

An xsd:integer bound past i64 has no RangeValue at all, so a conjunct
carrying one exercises the partial-extraction path the decimal cases no
longer reach. Both directions are covered so a lane that returns nothing
cannot pass the empty case by accident.

Verified non-vacuous: with the conjunct skip restored, both lanes fail on
`?v >= 0 && ?v > "1e20"^^xsd:integer`, returning all six rows instead of
none.
xsd:integer is unbounded, and the lexer has emitted a distinct
`BigInteger` token past i64 for a while. Term position consumes it, both
signed and unsigned, and lowering promotes it to `FlakeValue::BigInt`.
Expression position was the one hole: `try_parse_literal` matched
Integer, Decimal, Double, String and Boolean but had no `BigInteger`
arm, so `FILTER(?v > 100000000000000000000)` failed with "Expected
expression" while the identical bound written as a typed literal parsed
fine.

Adds the missing `consume_big_integer` helper alongside its Integer and
Decimal twins and the arm that uses it. The sign needs no special
handling here: unlike term position, expressions parse `-` as the unary
operator.

This only closes the parse hole. Bounds past i64 still have no
`RangeValue`, so they do not push down; the filter stays and is
evaluated generically, which is the correct-and-slower path already
described in #1335.
Accepting BigInteger in expression position made lowering paths
reachable that a bare oversized literal could not previously reach.
All five LiteralValue match sites already had a BigInteger arm, but
that was worth exercising rather than reading: BIND, arithmetic,
unary negation, ORDER BY and HAVING each route through a different
one.
@bplatz

bplatz commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @aaj3f — both parts of that were right, and I verified rather than taking them on faith.

The teeth gap is real. Restoring the conjunct skip and re-running the e2e pair: both still green, exactly as you said. RangeValue::Decimal made every literal in the table representable, so defect 1 was left pinned only by the planner units. Fixed in a503dbac4 with the typed oversized-integer case you suggested, in both directions so a lane that returns nothing can't pass the empty case by accident. Under the reverted arm both lanes now fail on that case, returning all six rows instead of none.

The bare-form aside was also correct, though for a narrower reason than "parse error" suggests — and chasing it turned up something worth fixing:

The lexer has emitted a distinct BigInteger token past i64 for a while (lex/lexer.rs:870), term position consumes it both signed and unsigned (parse/query/term.rs:400,463), and lowering promotes it to FlakeValue::BigInt (lower/expression.rs:198). The only hole was try_parse_literal in parse/expr.rs, which matched Integer/Decimal/Double/String/Boolean and had no BigInteger arm. So the token was reachable in a triple pattern but not in a FILTER.

Fixed in 3ffd8605d: the missing consume_big_integer helper next to its Integer/Decimal twins, plus the arm using it. No sign handling needed — unlike term position, which recombines Plus/Minus with the numeric token, expressions already parse - as UnaryOp::Neg, so only the unsigned literal was missing. This is parse-only; such bounds still have no RangeValue and don't push down, so the filter stays and evaluates generically.

Since widening the parser makes previously-unreachable lowering paths reachable, I checked all five LiteralValue match sites — each already had a BigInteger arm, and the three nearby wildcards are in unrelated matches. ee7a4cb2f exercises that rather than leaving it a code read: BIND, arithmetic, unary negation, ORDER BY and HAVING each route through a different one.

I ran the workspace-excluded testsuite-sparql W3C suite for this specifically, since a parser that starts accepting new syntax is what could flip a negative-syntax test: 81 passed, 0 failed. Expected — SPARQL's INTEGER ::= [0-9]+ is unbounded, so this makes us more conformant, not less.

Also folded in while here: main merged (clean), and the #1335 reference is now Partially addresses #1335 with what remains spelled out — decimal bounds reach ObjectBounds, but the inline encoding and seek-level narrowing don't, and per the repo's linking convention a bare (#1335) was indistinguishable from a fix.

Body updated throughout, including the Tests section, which had been claiming all new tests failed against the pre-fix code — the exact claim that had stopped holding.

Re-review is welcome given the parser commit landed after your approval, though @bplatz was happy to fold it in rather than split it out.

@bplatz
bplatz merged commit 44629c1 into main Sep 4, 2026
17 checks passed
@bplatz
bplatz deleted the fix/range-pushdown-partial-and branch September 4, 2026 01:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:query Query execution, planning, fast paths, overlay, result formatting P1 Next up: wrong results, live user pain, unblocks

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants