Skip to content

fix(query): place BIND and FILTER after the triple that binds their variable - #1995

Merged
bplatz merged 8 commits into
mainfrom
fix/values-undef-bind-placement
Sep 30, 2026
Merged

bplatz merged 8 commits into
mainfrom
fix/values-undef-bind-placement

Conversation

@bplatz

@bplatz bplatz commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A BIND or FILTER could run before the triple that binds its variable, whenever that variable was listed in the upstream schema but unbound on some rows. Three things leave a variable in that state: an UNDEF cell in a VALUES table, an unmatched OPTIONAL, and a per-row seed. The planner treated "in the schema" as "bound".

This came from a dashboard report on ccv-claims:

SELECT ?payer ?name WHERE {
  VALUES ?payer { UNDEF }
  ?c ex:payer ?payer ; ex:patientAge ?a .
  FILTER(?a > 0)
  BIND(STR(?payer) AS ?name)
}
  • ?name came back unbound.
  • A FILTER reading ?payer (for example FILTER(BOUND(?payer))) dropped every row.
  • Trigger: a FILTER that pushes a range bound (?a > 0) moves ?c ex:patientAge ?a ahead of ?c ex:payer ?payer. The BIND or FILTER was then inlined into the first join step, before ?payer was bound.
  • An unmatched OPTIONAL followed by a triple that binds the same variable failed the same way.
  • FLUREE_DISABLE_QUERY_FAST_PATHS returned the same wrong rows, so the bug is in the plan both lanes share.

The same readiness logic had been reimplemented in several places, so this PR also consolidates it. The review turned up two more bugs of the same kind, and both are fixed here.

Changes

One commit per concern, in this order:

  1. fix(query): hold BIND and FILTER until the triple that binds their variable has run.
    • Reorder: BIND now waits on its variables' binders, as FILTER/EXISTS already did (attach_filter_binders).
    • Join chain: build_sequential_join_block keeps a BIND or FILTER off a step while a triple not yet joined can still bind one of its variables. This covers inline ops, deferred BINDs/FILTERs and range-semijoin folds.
    • Seeds: Operator::bound_in_every_row reports which variables a seed binds on every row. It is exact for SeedOperator, BatchSeedOperator, BatchReplayOperator and the OPTIONAL MaterializedSeedOperator.
    • UNDEF columns: a VALUES column that is UNDEF in every row is dropped before planning when another pattern binds its variable. VALUES ?x { UNDEF } then plans exactly like the query without it.
  2. refactor(query): one must-bind analysis.
    • must_bind_vars(pattern, BindTargets) makes every caller say whether a BIND target counts as bound on every row. An erroring expression leaves it unbound.
    • values_bound_in_every_row / values_column_all_undef replace three copies of the same VALUES column test.
    • collect_guaranteed_vars (which was plain produced_vars) is renamed produced_vars_of.
  3. fix(query): OPTIONAL barriers settle only on seed variables bound on every row.
  4. refactor(query): one readiness type. Settled { bound, unsettled } with admits replaces a pair of loose sets that was threaded through six helpers, plus an inline copy in the range-semijoin fold.
  5. fix(query): a property-join star applies every FILTER collected with it.
    • build_property_join_block used to discard the BINDs and FILTERs it could not inline: let (child, _, _) = apply_deferred_patterns(..).
    • On an anchored star of three or more triples, FILTER(?a > 14 || EXISTS {…}) and FILTER(?unbound = 1) were silently dropped, so the query returned the rows the FILTER rejects. This reproduces on main in both SPARQL and JSON-LD.
    • Both block builders now end with apply_all_remaining.
  6. fix(query): UNWIND waits for the triple that binds its variable. The reorder places UNWIND by its dependencies, as it does BIND, but UNWIND was missing from waits_for_binders. After a VALUES UNDEF cell, JSON-LD ["unwind", "?x", "(list (str ?payer))"] unwound a null on every row that came through the cell. This reproduces on main.
  7. perf(query): prune with a guarded copy of a FILTER held for a later triple.
    • A FILTER that reads a variable a later triple of the block can still bind waits for that triple. A BIND's target is always such a variable, so in ?s ex:num ?num . BIND(IRI(CONCAT(…, STR(?num))) AS ?t) ?t ex:val ?v . FILTER(STRENDS(STR(?t), "77")) every row paid for its ex:val lookup before the FILTER dropped it: about 3× the fuel of the same FILTER written on ?num.
    • When unsettled variables are all that hold a FILTER, and the expression is safe to evaluate twice (expression_is_duplication_safe), the step also runs !BOUND(?v) || … || filter, once per FILTER. A join can't change a variable a row already binds, so the copy drops only rows the FILTER drops. A row where the variable is unbound passes, and the FILTER decides after the triple.
    • This also restores early pruning on the rows that do bind the variable in the VALUES UNDEF, OPTIONAL and per-row-seed shapes.

Behavior changes to be aware of

  • BIND targets: in the join chain, a BIND's target no longer counts as bound on every row, whether the BIND is at the top level or nested inside UNION, GRAPH, SERVICE or a subquery. A BIND whose expression errors leaves it unbound, and a later triple of the block can bind it. A BIND or FILTER reading it waits for that triple; a FILTER also prunes early through the guarded copy (change 7). On main, no BIND target was held.
  • BIND written before its binder: now sees the bound value, for example BIND(STR(?payer) AS ?name) written before the triple. This matches how BIND was already placed by its dependencies whenever no VALUES was involved.
  • Planner sites that still count BIND targets as bound: the reorder's barrier and binder sites still pass BindTargets::Bound, as before. The known BIND-then-OPTIONAL hole documented on must_bind_vars is unchanged. Tightening it changes placement and should get its own tests.
  • Opaque seeds: seeds that cannot see their rows return None from bound_in_every_row, for example the default-graph edge-annotation lane. They keep the previous assumption that every schema variable is bound. The annotation lane returns wrong rows because of it, on main too (see Follow-ups).

Query-plan verification

The consolidation commits were meant to be plan-neutral, and the fix commits were meant to change plans only in the broken shapes. I checked this across the test corpus by diffing physical plans between the commit before the consolidation (aa685685f) and this head (f8fe559c5).

Method (temporary instrumentation, not committed):

  • An env-gated wrapper around build_where_operators_seeded_with_needed logged every where-plan, including per-row sub-plans: hash(patterns, seed schema, seed bound_in_every_row, needed vars, group-by, dedup flag, required vars) → describe() JSON.
  • describe() does not show where BIND/FILTER are inlined, so the patch also added inline_ops to the plan details of NestedLoopJoinOperator, PropertyJoinOperator, ScanDatasetBuilder and BinaryScanOperator, and expr to BindOperator/FilterOperator. None of those files differ between the two commits, so the identical patch applied to both.
  • Corpus: the full cargo test -p fluree-db-api plus the testsuite-sparql W3C suite, run twice on the head (to measure noise) and once on the base.

Results:

Corpus Comparable query shapes Changed
W3C SPARQL suite 548 0
fluree-db-api suite 3,519 37, all accounted for below
# Classification How it was confirmed
4 Intended. 3 property-join queries now apply their leftover FILTER; 1 OPTIONAL barrier now holds over a seed variable left unbound. These are the new tests' own queries.
9 GraphOperator ↔ SqlBlockOperator flips The process-global FAST_PATHS_DISABLED flag is flipped at runtime by it_differential_fastpath, which shares the grp_query binary with the xl-beta-* GRAPH tests.
18 List order only (3), or a plan variant seen in only one run (15) Mechanical triage. The one subquery case (BSBM BI Q4) bisected to commit 3. Logging the reorder directly showed identical decisions before and after that commit, and the base build produces the same plan shape under a different parent key.
2 Only a cost estimate (driving-est) differs, and it swaps between two keys The plan structure is identical.
4 Range-semijoin and cyclic-BGP lane choices (3), and the order of two object-VALUES joins (1) In isolated repeat runs, the head reproduces the base plans, the two sides produce identical plan sets, or the base build itself varies between the same variants. it_cyclic_bgp_* set FLUREE_CYCLIC_BGP=0 via std::env::set_var mid-test.

Limits:

  • This shows no unexplained differences; it does not prove none exist.
  • About 15% of plan keys can't be compared from run to run: required_where_vars reaches the planner in hash order, and nested sub-plans get filed under different parent keys.
  • No test exercises the nested-BIND change above, so its absence from the diff says nothing about it.
  • The diff predates changes 6 and 7, and I did not re-run it for them. They change plans only where UNWIND reads a variable a later triple can still bind, and where a FILTER is held by unsettled variables (an extra inline filter at an earlier step).

Tests

New tests. Each one fails without its fix; I checked this by reverting each mechanism in turn, and against main.

  • it_values_undef_placement.rs (grp_query_sparql):
    • The reported shape, on a mixed { UNDEF ex:payer1 } table, in novelty and indexed ledgers. It asserts the range-bounded triple drives the chain, so the test cannot pass vacuously.
    • FILTER on the UNDEF variable (BOUND, STRSTARTS).
    • BIND after an unmatched OPTIONAL.
    • A per-row UNION branch.
    • The Planner hoists a triple above a preceding OPTIONAL that binds a shared variable, returning wrong rows #1924 barrier under an unbound seed variable.
    • All-UNDEF VALUES plans like the query without it (plan comparison).
    • JSON-LD twin of the reported shape.
    • A FILTER on a BIND's target, over an index, burns the same fuel as the same FILTER on the BIND's input (1.03 each; 3.01 without the guarded copy).
    • A row whose BIND errored keeps the targets the later triple binds. It fails if the copy is placed without its guard.
    • JSON-LD UNWIND after an UNDEF cell.
  • it_property_join_filters.rs (grp_query_sparql): FILTER with EXISTS, and FILTER on a variable nothing binds, on a property-join star. Each asserts the PropertyJoinOperator precondition. Includes a JSON-LD twin.
  • Planner unit tests: left_join_barrier_ignores_a_seed_var_unbound_on_some_rows, and values_barrier_ignores_a_seed_var_unbound_on_some_rows, which asserts both directions. Join-chain unit tests: filter_on_an_unsettled_var_runs_a_guarded_copy_once and filter_that_differs_per_evaluation_gets_no_guarded_copy.

Suites, on this head with main merged in (#1966, #1988, #1990):

  • cargo test -p fluree-db-query --lib: 1647 passed.
  • cargo test -p fluree-db-api: 3,902 passed, 0 failed. it_limit_stops_work::distinct_limit_stops_a_chain_after_one_small_probe is a pre-existing flake that passed this run: it fails 1 in 4 parallel runs with main's query crate, passes in isolation, and its thread-local tracing capture sometimes sees no events.
  • Two other tests failed once each under heavy parallel load during the plan runs and passed on rerun: it_annotation_filter_pushdown::annotation_body_threshold_reduces_scan_work_on_both_surfaces and it_bounded_overlay_translation::warm_whole_product_short_circuits_and_respects_epoch. The latter also failed on the base commit.
  • testsuite-sparql: green; no register entry changed state.
  • cargo fmt --all and cargo clippy --all-targets --no-deps are clean for fluree-db-query and fluree-db-api.

Follow-ups (not in this PR)

  • Subquery join keys: self_produced_vars (triples and paths only) and the planner's must_bind_vars decide "does the body bind this on every row" differently. Unifying them changes hash-key selection and needs its own tests.
  • Poisoned vs Unbound: the two are treated inconsistently across operators. The Cypher sequential write driver maps Poisoned to Unbound when it threads rows into VALUES (cypher_seq.rs normalize_binding). That is confirmed in code; no wrong answer has been reproduced yet.
  • Hand-written variable walkers with gaps: collect_var_stats has no arm for SERVICE or the search adapters, and SPARQL collect_bound_variables omits the GRAPH ?g and annotations.
  • Default-graph edge-annotation lane: it plans on top of a child that returns None from bound_in_every_row, so after OPTIONAL { ?a ex:joined ?since }, a FILTER on ?since drops the rows where only ?a ex:knows ?b {| ex:since ?since |} binds it. This predates this PR. Follow-up: FILTER over an OPTIONAL variable drops rows an RDF-star annotation binds #1999
  • Test isolation: tests flip FAST_PATHS_DISABLED and call set_var("FLUREE_CYCLIC_BGP") at runtime, which changes plans for other tests running in parallel in the same binary.

Partially addresses #1986: seed variables are now settled only when the seed binds them on every row. Cost-based placement of MINUS/EXISTS/NOT EXISTS remains.

…riable has run

A variable can be listed in the upstream schema yet unbound on some rows:
an UNDEF cell in a VALUES table, an unmatched OPTIONAL, or a per-row seed.
The planner read "in the schema" as "bound", so a BIND or FILTER reading
such a variable could run before the triple that actually binds it.
`BIND(STR(?payer) AS ?name)` came back unbound, and a FILTER reading
`?payer` dropped every row. A range FILTER on another triple exposed it by
moving that triple to the front of the join chain.

Placement is decided in three places, and each now waits until the
variable is settled (bound on every row, or no remaining pattern can
bind it):

- reorder: BIND waits on its variables' binders, as FILTER and EXISTS
  already did. A seed variable counts as settled only if the seed binds
  it on every row (`reorder_patterns_with_seed`).
- join chain: `build_sequential_join_block` keeps a BIND or FILTER off
  a step while a later triple can still bind one of its variables,
  including the inline ops, deferred binds/filters and range-semijoin
  folds.
- seeds: `Operator::bound_in_every_row` lets seed operators report the
  variables bound in their rows, so per-row UNION, OPTIONAL, GRAPH and
  EXISTS plans get the exact set. Subquery EXPLAIN plans its placeholder
  row as bound, like the rows it stands for. Operators that cannot tell
  keep the previous assumption.

A VALUES column that is UNDEF in every row is also dropped before
planning when another pattern binds its variable, so `VALUES ?x { UNDEF }`
plans like the query without it.
…D targets

"Which variables does this pattern bind on every row" was answered by
`must_bind_vars` in the planner and a second copy in the join-chain
planner that differed only on BIND, and the "no UNDEF in this VALUES
column" test was written three times.

- `must_bind_vars` takes `BindTargets`: `Bound` for subquery correlation
  and the existing barrier and binder sites, `MayBeUnbound` for join-chain
  placement. Every caller now states which it means.
- `values_bound_in_every_row` and `values_column_all_undef` in
  `ir::pattern` replace the three inline VALUES column tests.
- `collect_guaranteed_vars` was plain `produced_vars` despite its name;
  it is now `produced_vars_of`.

No behavior change.
…every row

The reorder took two seed sets and used them inconsistently: the binder
attachment used the variables the seed binds on every row, while the
VALUES-after-OPTIONAL barrier (#1690) and the left-join order barrier
(#1924) still counted every seed schema variable as bound. In a per-row
UNION, OPTIONAL or EXISTS plan whose seed row leaves a variable unbound,
the OPTIONAL can still introduce it, yet the barrier did not fire: a
triple reading it was hoisted ahead of the OPTIONAL and every pair it
produced survived the left join.

`SeedVars` now carries both sets, and each use picks one: the schema for
join availability and cost, `bound_in_every_row` for binders and both
barriers. `reorder_patterns` keeps its signature as the all-bound case.
Placement checks passed "bound" and "unsettled" as two loose sets
through six helpers, and the range-semijoin fold restated the check
inline. `Settled` holds both, and `admits` / `admits_var` are the only
readiness tests. The join chain keeps one `Settled` and refreshes its
unsettled part at each step; the end-of-block and property-join paths
start from `Settled::all`, with nothing left to bind.

No behavior change.
`build_property_join_block` applied the BINDs and FILTERs it could and
discarded the rest (`let (child, _, _) = apply_deferred_patterns(..)`).
A FILTER containing EXISTS is never inlined, and one reading a variable
nothing binds never becomes ready, so on an anchored star of three or
more triples both were silently dropped: the query returned the rows
the FILTER rejects. The sequential join chain applies them at the end
of the block.

Both block builders now end with `apply_all_remaining`.
@bplatz bplatz added bug Something isn't working as expected area:query Query execution, planning, fast paths, overlay, result formatting labels Sep 30, 2026
@bplatz
bplatz requested review from aaj3f and zonotope September 30, 2026 01:31

@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 the property-join fix alone makes this worth it (that star was silently returning the rows its FILTER rejects, in both SPARQL and JSON-LD), and the consolidation lands at the right altitude. Two things I'd want in before merging: holding a FILTER on a BIND's target until after the join that uses it costs about 6.6× the fuel on a computed-key shape, and UNWIND still has the same bug this fixes for BIND. Claude-assisted review below:


✅ Approving to unblock you — with 2 things that need to be addressed before this merges.

  1. A perf regression on a computed-key shape (fluree-db-query/src/execute/where_plan.rs:1856). A FILTER that reads a BIND's target is now held until after a later triple in the same block that joins on that target. ?s ex:num ?num . BIND(IRI(CONCAT(…, STR(?num))) AS ?t) ?t ex:val ?v . FILTER(STRENDS(STR(?t), "77")) goes from 1.07 to 7.01 fuel (~9 → ~19.5 ms warm) against the merge-base, with the same plan and the same rows. The fix path is inline: either a guarded early copy (!BOUND(?v) || filter) or treating applied BIND targets as settled in the join chain, plus a fuel assertion so it can't slip back.
  2. UNWIND has the same bug, and it's reachable from JSON-LD (fluree-db-query/src/planner.rs:1520). ["unwind", "?x", "(list (str ?payer))"] after a VALUES with an UNDEF cell returns ?x null on 5 of 7 rows at this head. That part is pre-existing (the merge-base returns the same nulls for the UNWIND and the BIND form alike, and this PR fixes the BIND one). Adding Pattern::Unwind { .. } to waits_for_binders fixes it, and the query crate's lib tests stay green. That's the body's "UNWIND gets no binder gating" follow-up, and I'd fold it in here.

Beyond those, I think this is really well done. Settled, must_bind_vars(…, BindTargets) making every caller say what it means, and bound_in_every_row defaulting to the old assumption are all at the right altitude. The plan-diff method in the body is carefully built too. I checked the core claims: all ten new integration tests pass at this head, and all ten fail when I put fluree-db-query/src back to the merge-base. I also reverted three mechanisms separately (the property-join discard, SeedOperator::bound_in_every_row, and the all-UNDEF column drop), and each one took down exactly the tests it should.

On the column drop specifically, I compared eleven shapes at merge-base and head. They were SELECT *, multi-column, a binder only inside OPTIONAL, MINUS-only, UNDEF UNDEF multiplicity, VALUES inside OPTIONAL, UNION or a sub-SELECT, VALUES written after the triple, GROUP BY/ORDER BY, and an empty table. Rows and head.vars match on all eleven.

One more is pre-existing, so it's not on you: the OPTIONAL + {| ex:since ?since |} annotation lane returns wrong rows on main too. The details and the repro are inline on operator.rs:85. Folding it in or filing it is your call.

The rest of the body's follow-ups all look genuinely separate to me:

  • Subquery join keys: I confirmed self_produced_vars (subquery.rs:978) and must_bind_vars disagree. Unifying them moves hash-key selection, so it's a design call of its own.
  • Poisoned vs Unbound: I confirmed normalize_binding (cypher_seq.rs:1711) collapses both to Unbound. I didn't try to reproduce a wrong answer. How the Cypher row table should carry null into VALUES is a decision bigger than this PR. For placement, counting Poisoned as bound in values_bound_in_every_row is right, since nothing can re-bind it.
  • The walker gaps in collect_var_stats, and the runtime set_fast_paths_disabled / set_var("FLUREE_CYCLIC_BGP") isolation issue: both confirmed in code, and both unrelated to this fix's mechanism.

Adherence to repo commitments:

  • Patterns/abstractions: ✔ It extends the shared machinery instead of adding a parallel one: one readiness type, one must-bind analysis with an explicit BindTargets, and a trait method whose None default preserves the old plan. SPARQL and JSON-LD share the fix, with twins for both the reported shape and the property-join star. The reorder (Bound) and the join chain (MayBeUnbound) still disagree on BIND targets, which is documented.
  • Performance (speed first, memory second): 🔴 The query engine is touched. The new analyses are plan-time only, with no per-row operator work. But holding a FILTER on a BIND target past the triple that joins on it costs 6.6× the fuel on the shape above, and no bench covers it.
  • Deployment targets: ✔ n/a. This is pure planning logic in fluree-db-query, reached by every host (solo's Lambdas and standalone binary via fluree-db-api, fluree-db-server, wasm32). It adds no threads, clock, filesystem or process-lifetime cache, and wasm32/wasm-smoke are green.
  • Testing: ⚠️ There are 2 planner unit tests plus 10 integration tests, wired through grp_query_sparql and run by name in CI. All 10 fail against the merge-base query crate, and each of the three mechanisms I mutated went red. The BIND-target placement change (top-level and nested) and UNWIND have no coverage.
  • Conventions: ✔ Five commits, one per concern, each with a full body. fmt and clippy are green, and no user-facing docs are affected.

Verified locally at f8fe559c5: cargo test -p fluree-db-query --lib (1641 passed); cargo test -p fluree-db-api --test grp_query_sparql -- it_values_undef_placement it_property_join_filters (10/10, and 10/10 red with the merge-base query crate); three mutation checks; and fuel/row probes at merge-base vs head. It also composes cleanly: on a trial merge of current main + #1988 + #1990 + this PR, the ten new tests pass and the query crate's lib suite is green (1644).

Approving now so you can merge without waiting on another pass from me. Just be sure the perf fix and the UNWIND arm are in before you do.

}
hash_planner.before_step(tp, &settled.bound);
guaranteed.extend(tp.produced_vars());
settled.unsettled = unsettled_vars(&triples[k + 1..], &folded[k + 1..], &guaranteed);

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.

🔴 Must address before merge: a FILTER on a BIND's target now waits until after the join that uses that target as a key, which costs about 6.6× the fuel on a computed-key shape I measured.

unsettled_vars marks every variable a later triple of the block produces as unsettled unless it's in guaranteed, and a BIND's target never enters guaranteed (9c6c58ccb makes that explicit with BindTargets::MayBeUnbound). So in ?s ex:num ?num . BIND(IRI(CONCAT("http://example.org/ns/t", STR(?num))) AS ?t) ?t ex:val ?v . FILTER(STRENDS(STR(?t), "77")) the BIND still inlines at the ex:num step, but the FILTER on ?t can't go with it anymore. ?t stays unsettled until ?t ex:val ?v has run, so every driving row does its subject lookup first and the FILTER throws ~99% of them away afterwards. On main the FILTER ran right after the BIND, ahead of the lookup.

I ran exactly that on an indexed ledger with 3,000 ex:num subjects and 6,000 ex:val subjects (the FILTER keeps 30). Both sides use the same plan shape (ex:num scan, then a subject-driven NLJ on ex:val) and return the same 30 rows. But fuel goes from 1.07 with the query crate at the merge-base to 7.01 at this head, and warm wall time from ~9 ms to ~19.5 ms. The cost scales with driving rows × (1 − selectivity). The plan-diff in the body couldn't see this because nothing in the corpus has this shape. I also think the "Behavior changes" bullet understates it a bit: "Previously only a top-level BIND was excluded" is relative to aa685685f. On main no BIND target was held at all, top-level or nested.

I see two reasonable ways out, and which one is your call:

  1. Keep the correctness and get the pruning back with a guarded early copy. When the only thing holding a FILTER is unsettled variables, inline !BOUND(?v) || <filter> at the early step (only when expression_is_duplication_safe) and keep the original pending for the late step. On a row where ?v is bound no later triple can change it, so dropping there is exact; where it's unbound the copy is true and the late FILTER decides. This also gives the bound rows of the VALUES-UNDEF, OPTIONAL and per-row-seed cases their early pruning back.
  2. Count an applied BIND's target as settled in the join chain, matching the BindTargets::Bound the reorder already uses. That restores main's placement and gives up only the case where the BIND errors and a later triple rebinds its target, which main also got wrong.

Either way, I'd add a fuel assertion on this shape (fuel is deterministic, which it_annotation_filter_pushdown already relies on) plus a result test for the BIND-error row, to pin whichever semantics you pick. There's no test on the BIND side of this change today, top-level or nested.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 4111f4e

Comment thread fluree-db-query/src/planner.rs Outdated
matches!(
p,
Pattern::Filter(_) | Pattern::Exists(_) | Pattern::NotExists(_)
Pattern::Filter(_) | Pattern::Exists(_) | Pattern::NotExists(_) | Pattern::Bind { .. }

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.

🔴 Must address before merge: UNWIND has the same bug this PR fixes for BIND, and it's reachable from JSON-LD. I'd fold the "UNWIND gets no binder gating" follow-up into this PR.

UNWIND is placed by its dependencies exactly the way BIND is (PatternEstimate::Deferred, with required_vars from list.referenced_vars()), but it isn't in waits_for_binders. So its list expression runs as soon as a VALUES with an UNDEF cell puts the variable in scope, ahead of the triple that actually binds it. It isn't Cypher-only either: JSON-LD has ["unwind", "?var", expr].

I used the same five claims it_values_undef_placement seeds and this where-clause: ["values", ["?payer", [null, {"@type": "@id", "@value": "ex:payer1"}]]], {"@id": "?c", "ex:payer": "?payer", "ex:patientAge": "?a"}, ["filter", "(> ?a 0)"], ["unwind", "?x", "(list (str ?payer))"]. At this head it returns seven rows, five of them with ?x null (every row that came through the UNDEF cell). Swapping the last clause for ["bind", "?x", "(str ?payer)"] returns all seven correctly. That's this PR's fix doing its job; UNWIND is the arm it doesn't reach. To be clear, it isn't new: with the merge-base query crate the BIND form returns those same five nulls too, so this PR fixes one of the two.

Adding Pattern::Unwind { .. } to the matcher made that query return all seven rows correctly, and cargo test -p fluree-db-query --lib still passes 1641/1641 with it. This is the form rustfmt settles on:

        matches!(
            p,
            Pattern::Filter(_)
                | Pattern::Exists(_)
                | Pattern::NotExists(_)
                | Pattern::Bind { .. }
                | Pattern::Unwind { .. }
        )

It's the same bug class, a one-line fix, and a silent wrong answer on a non-Cypher surface. So I'd rather see it land here, with a JSON-LD twin next to jsonld_bind_reads_the_value_its_triple_binds_after_an_undef_cell, than go to the backlog.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in f6a953e

/// its rows. A plan seeded by this operator holds a BIND or FILTER reading
/// any other schema variable until the triples that could still bind it
/// have run. `None` plans every schema variable as bound.
fn bound_in_every_row(&self) -> Option<Vec<VarId>> {

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 address (pre-existing, not introduced here): the default-graph edge-annotation lane the body calls out as opaque is reachable, and it returns wrong rows today.

DefaultGraphSourceOperator plans its chain on top of whatever operator precedes it (build_where_operators_seeded(Some(child), …) in default_graph_source.rs). That child answers None here, so everything in its schema is planned as bound on every row. sink_filters_into_annotation_chains also copies a FILTER into the chain whenever the chain's triples bind all of its variables.

Here's a concrete case. The data is ex:a1 ex:name "A1" ; ex:knows ex:b1 {| ex:since 2021 |} (no ex:joined), a2 (joined 2021, since 2021) and a3 (joined 1990, since 1990). The query SELECT ?a ?b ?since WHERE { ?a ex:name ?n . OPTIONAL { ?a ex:joined ?since } ?a ex:knows ?b {| ex:since ?since |} . } returns a1 with ?since unbound, when it should be 2021 from the annotation. Adding FILTER(?since > 2000) drops a1 entirely. I get identical answers with the query crate at the merge-base, so this PR didn't cause it. And since the no-FILTER query loses the binding too, I think there's more going on in that lane than placement.

The where-plan loop already knows guaranteed at the point it builds the wrapper, so passing that into the wrapper's inner build as the seed's bound_in_every_row may well cover the FILTER half. The lost binding probably needs its own look. I'm fine with either folding it in, if it turns out to be one mechanism, or an issue with this repro. It's a different lane from the rest of this PR, so that one's your call. Commenting here because fluree-db-query/src/default_graph_source.rs is not in this diff.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Filed as #1999. Only the FILTER half reproduced; without the FILTER, a1 comes back with 2021.

let mut bound = bound_vars_from_operator(&operator);
if let Some(child) = operator.take() {
let (child, _, _) = apply_deferred_patterns(
operator = Some(apply_all_remaining(

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.

👍 This is a real catch. I put the old let (child, _, _) = apply_deferred_patterns(..) back, and all three it_property_join_filters tests go red returning c1, c3, c5 (the rows the FILTER rejects), SPARQL and JSON-LD alike. Ending every block builder in apply_all_remaining is the right place to close it.

The reorder defers UNWIND and places it by its dependencies, as it does a
BIND, but attach_filter_binders left it out of the patterns that wait on
binders. A VALUES UNDEF cell put the variable in scope, so the list
expression ran before the triple that binds it, and every row that came
through the UNDEF cell unwound a null. JSON-LD reaches it with
["unwind", "?x", "(list (str ?payer))"].
…riple

A FILTER reading a variable that a later triple of the block can still
bind waits for that triple, because a row may reach it with the variable
unbound: a BIND that errored, a VALUES UNDEF cell, an OPTIONAL, a per-row
seed. A BIND's target is always such a variable, so in

  ?s ex:num ?num . BIND(IRI(CONCAT(..., STR(?num))) AS ?t)
  ?t ex:val ?v . FILTER(STRENDS(STR(?t), "77"))

every driving row paid for its ex:val lookup before the FILTER dropped
it, about 3x the fuel of the same FILTER written on ?num.

Where the only thing holding such a FILTER is those unsettled variables
and the expression is safe to evaluate twice, the step now also runs
!BOUND(?v) || ... || filter. A join cannot change a variable a row
already binds, so the copy drops only rows the FILTER drops; a row with
one unbound passes, and the FILTER decides after the triple. It is
placed once per FILTER.

Tests: equal fuel for the FILTER on ?t and on ?num over an index; a row
whose BIND errored keeps the targets the later triple binds.
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 bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants