Skip to content

fix(query): keep a VALUES written after an OPTIONAL below it - #1701

Merged
aaj3f merged 3 commits into
mainfrom
fix/values-after-optional-hoist
Aug 28, 2026
Merged

fix(query): keep a VALUES written after an OPTIONAL below it#1701
aaj3f merged 3 commits into
mainfrom
fix/values-after-optional-hoist

Conversation

@aaj3f

@aaj3f aaj3f commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

{ ?ev :entity1 ?a . OPTIONAL { ?ev :entity2 ?b } VALUES ?b { :B } } has been returning rows that report a binding the data never had for them. On a 6-row fixture where the OPTIONAL binds ?b to ns:X0..X5/ns:B, the in-group VALUES form returned all six rows saying ?b = ns:B — a fabricated value, not merely an extra row. Correct answer there is 1 row. Both surfaces were affected (the JSON-LD ["values", …] after ["optional", …] clobbered identically), which is the tell that the cause is in the shared planner rather than in SPARQL lowering.

The fixture choice turns out to be load-bearing rather than incidental, so it's worth stating up front. The defect only surfaces once the OPTIONAL binds a different value than the VALUES supplies. On a shape where one subject has the OPTIONAL predicate and every other driving row leaves the variable unbound, the buggy and the correct paths agree exactly — the fabricated rows and the legitimately-adopting rows are indistinguishable by count. #1690's own filed repro is that shape, which is why its 1500 rows look like a cross-product and its "correct answer is 1" is really the FILTER answer (see below — FILTER is not the equivalent rewrite). The tests here deliberately use a fixture where the OPTIONAL binds something else, because that is the only arrangement that separates the two.

Mechanism

reorder_patterns in fluree-db-query/src/planner.rs classifies Pattern::Values as an exact-cardinality source, and — via seed_anchor_vars — it wins the seed race outright. EXPLAIN's logical plan lists source values first even for a VALUES written last.

The order-sensitivity guard immediately above it (the Minus | Exists | NotExists arm, and the correlated-subquery deferral just below) exists precisely so the reorder cannot outrun what feeds a pattern. A Values whose variable a preceding Optional produces was never covered by it. That is the whole gap.

In algebra terms the hoist rewrites

Join(LeftJoin(P, O), V)   →   LeftJoin(Join(P, V), O)

and those are the same query only when V binds nothing that O introduces. When it does, ?b is already bound before the left join runs, the left join matches on the seeded value, and — being a left join — drops nothing, so every driving row exits carrying it. The physical plan shows it directly: OptionalOperator > ValuesOperator before, ValuesOperator > OptionalOperator after.

The fix

A small order-sensitivity barrier alongside the existing MINUS/EXISTS one. values_optional_barrier_indices (planner.rs) answers "does a preceding Optional introduce one of this VALUES' variables"; when it does, the VALUES is deferred on the preceding patterns' produced vars — which include the OPTIONAL's own, per Pattern::produced_vars — so it lands immediately after the OPTIONAL.

Two deliberate narrowings, both to avoid paying for this where it isn't a hazard:

  • "Introduces", not "mentions." A variable already bound ahead of the OPTIONAL is not introduced by it — there the OPTIONAL only restricts a required variable, and restricting a required variable commutes across the left join. So VALUES ?ev over an ?ev a preceding triple binds keeps its seed. Without that carve-out every OPTIONAL mentioning the seed variable would cost the exact-cardinality seed, which is the 21k-row UNWIND cliff values_seed_beats_disconnected_class_anchor was written to pin; values_on_a_required_var_still_seeds_past_an_optional guards the carve-out from the other side.
  • A barriered VALUES no longer joins seed_anchor_vars. It isn't seeding anything, so letting it demote a disconnected class anchor would cost that anchor its seed with nothing taking its place.

The carve-out has to be computed over must-bind, not may-bind

This is the load-bearing subtlety, and it's wrong in a way that looks right. Pattern::produced_vars() is a may-bind set: Union unions its branches, Graph recurses straight through a nested Optional, Subquery is just the SELECT list, and Values is its whole variable list regardless of UNDEF. Accumulate "already bound ahead of the OPTIONAL" from that and the carve-out above stops being a narrowing and becomes a suppressor — a variable bound in only one branch/column is recorded as required-bound, the barrier stops firing for a later sibling OPTIONAL that genuinely introduces it, and the hoist comes straight back one UNION (or one UNDEF) away from the shape the barrier was written for.

So three things hold it together:

  • must_bind_vars is the single definition of "bound in every solution this pattern emits" — empty for Optional and the pure row filters, branch intersection for Union, recursive through Graph/Service, select ∩ body-must-bind for a subquery, and for Values only the columns with no Binding::Unbound cell. I didn't write it fresh: subquery_correlation_vars needs exactly this rule and already stated it almost verbatim in its own comment, as an inline matches! allow-list that was flat (no container recursion) and blunt about UNION (simply absent). Both call sites now share the helper. Worth flagging that collect_guaranteed_vars is not this despite the name — it's plain produced_vars with the intersection done at its call site.
  • Detection recurses, and is a separate notion from accumulation. left_join_introduced_vars walks preceding containers, so an OPTIONAL nested in a GRAPH/SERVICE/UNION raises the barrier too. It deliberately does not include a UNION that merely binds a variable on one branch, because Join is commutative: a VALUES hoisted past a UNION still meets the other branch's rows unbound and they still adopt the value. Branch intersection separates the two exactly — {?f} UNION {?f} keeps its seed (it really is must-bound), {?f} UNION {?e} raises the barrier.
  • The barrier had to become positional. This one surprised me: with must-bind alone the plan is still wrong. required_vars is a variable-readiness test, so a sibling UNION branch binding the same variable satisfies it and drains the VALUES before the OPTIONAL has been placed at all. after_indices names the blocking patterns and drain_ready_deferred holds the VALUES until they're placed.

UNDEF is the second may-bind spelling, and it's the motivating one

Pattern::Values is itself may-bind, and it was the last variant the arm misclassified. Both surfaces lower UNDEF to Binding::Unbound — SPARQL in lower_values_pattern, JSON-LD in lower_values_cell — so an all-UNDEF column binds its variable in no row while produced_vars still reports it. That reproduces the fabrication with no UNION anywhere:

?s schema:name ?name .
VALUES (?s ?f) { (ex:alice UNDEF) (ex:cam UNDEF) (ex:liam UNDEF) (ex:brian UNDEF) (ex:nikola UNDEF) }
OPTIONAL { ?s ex:friend ?f }
VALUES ?f { ex:alice }

The leading table recorded ?f as required-bound, the barrier didn't fire, and the trailing VALUES seeded: 5 rows including ["ex:alice","ex:alice"], whose only ex:friend is ex:brian. The reference answer is 4. This is not a synthetic corner — a placeholder UNDEF column is exactly the parameterized-query idiom #1690 names as its motivating usage, which makes it the shape most likely to be hit in the wild.

must_bind_vars now counts only the columns with no Unbound cell, so ?s stays required-bound and ?f correctly does not. A zero-row table stays vacuously must-bind on every column — it emits no solutions at all, and the missing-predicate sentinels in parse/lower.rs and empty_path_result are both that shape. Cypher's WITH-pipeline binders never emit Unbound, so the narrowing cannot reach the self_produced set subquery_correlation_vars builds for them.

This one is a plan-shape fix only, and the test says so. With the arm the trailing VALUES lands above the left join as it must — and the answer becomes 8 rows still carrying the fabricated one, because #1713 bites the same query (below). Same posture as the UNION shape: an_undef_values_column_does_not_suppress_the_barrier asserts the physical plan, not the rows.

Bind is a deliberate exception, and the contract says so

Pattern::Bind is may-bind for the same reason — bind.rs yields Binding::Unbound when the expression errors — and a BIND-then-OPTIONAL lead can fabricate the same way a UNION lead does. must_bind_vars counts it anyway, because subquery_correlation_vars needs it counted: a variable the subquery produces through a WITH-pipeline binder must not be read as an external correlation, or the subquery is deferred on a variable only it can bind. That is the one concept where the two call sites genuinely want different answers, and it's resolved in the correlation site's favour.

So the doc comment states it as an exception rather than as an unconditional contract, and names which site depends on it. The helper's whole value is that a future reader trusts it, and "one relation over one concept" would otherwise read as an invariant — someone tightening Bind here on the strength of it would quietly break subquery correlation. It needs a fix at both sites or at neither.

Two things here are load-bearing and shouldn't be simplified away

1. This isn't an open semantics question. testsuite-sparql/rdf-tests/sparql/sparql11/bindings/values07 is exactly this shape (?s ?p1 ?o1 . OPTIONAL { ?s foaf:knows ?o2 } } VALUES (?o2) { (:b) }), it isn't in the skip register, and its .srx pins the answer at 5 rows. It has been passing all along — because it uses the post-query spelling (} VALUES …), which we already plan correctly. So the correct behaviour existed in-engine on the sibling path the entire time; this PR just makes the in-group spelling agree with it, and there's now a test asserting the two spellings return the same rows on the same data.

2. FILTER is not the equivalent rewrite, and a "drop the unbound rows" fix would be wrong. Per SPARQL 1.1 §18.2.4 this stays Join(…, ToMultiSet(data)), and Join keeps a solution when the two mappings are compatible — which, for a variable the OPTIONAL left UNBOUND, is trivially true. Those rows survive and adopt the VALUES binding. FILTER(?b = :B) evaluates to a type error on an unbound ?b and drops the row; VALUES adopts it. The distinction is invisible on a fixture where every row has the variable bound, which is exactly why an over-fix here would look green — so the tests pin all three outcomes on one fixture:

driving row OPTIONAL bound ?f to outcome
ex:cam, ex:liam ex:alice kept, with the binding the data gave them
ex:alice ex:brian dropped
ex:brian, ex:nikola nothing — no ex:friend at all kept, and adopts ex:alice

trailing_values_is_a_join_not_a_filter asserts both answer sets exactly rather than just asserting they differ. An assert_ne! between the two is vacuous here — the pre-fix VALUES answer is also unequal to the FILTER answer, so it passes with the barrier fully reverted. Pinning both sets exactly goes red under that mutation.

A second, pre-existing defect this deliberately doesn't fix

Two of the shapes above are planned correctly by this PR and still answer wrong, for a second and unrelated reason. The rows they return are the faithful §18.2.4 join of a wrong input.

The clearest statement of it needs no VALUES-after-OPTIONAL at all:

SELECT ?s ?f WHERE { ?s schema:name ?name .
  VALUES (?s ?f) { (ex:alice UNDEF) (ex:cam UNDEF) (ex:liam UNDEF) (ex:brian UNDEF) (ex:nikola UNDEF) }
  OPTIONAL { ?s ex:friend ?f } }

returns 8 rows — the correct multiplicity — with ?f null on every single one. Delete the VALUES line and the same query returns those 8 rows with ?f bound on 6 of them (ex:aliceex:brian, ex:camex:alice/ex:brian, ex:liamex:alice/ex:brian/ex:cam, and ex:brian/ex:nikola genuinely unbound). So the OPTIONAL did match the triples — the row count proves it — and then didn't write the binding, because ?f was already present in the row as Unbound. That is stronger than "the left join is a no-op": the join happened and the result was discarded.

The UNION spelling is the same defect through a third door:

SELECT ?s ?f WHERE { ?s schema:name ?name .
  { { ?s ex:friend ?f } UNION { ?s schema:age ?age } }
  OPTIONAL { ?s ex:friend ?f } }

returns 10 rows — byte-identical to the same query with the OPTIONAL deleted, where §18.2.4 says 13. I verified both on pristine a824bbfd1 with planner.rs and where_plan.rs reverted — no part of this PR present — and they reproduce identically. Neither can be this PR by construction: the second query contains no Pattern::Values and no subquery, so neither the barrier nor must_bind_vars is reached.

This looks like the nested-opt-1/nested-opt-2/join-scope-1 correlated-OPTIONAL-independence cluster already registered as a known divergence. It's filed as #1713 and I'd rather not widen this PR into it. The consequence: a_union_binding_the_var_does_not_suppress_the_barrier and an_undef_values_column_does_not_suppress_the_barrier assert the plan shape, not the rows, and say why in the tests — pinning rows there would pin the other defect's output. The row-level §18.2.4 contract is carried by the tests whose inputs are correct.

Tests

  • planner.rsvalues_after_optional_is_not_hoisted_above_it; union_may_bind_does_not_suppress_the_optional_barrier and an_undef_column_does_not_suppress_the_optional_barrier (the two may-bind hazards above); pure_union_without_an_optional_keeps_the_values_seed and a_bound_values_column_still_seeds_past_an_optional (the over-fire direction — {?f} UNION {?f} and a fully-bound VALUES column must both keep the seed); optional_nested_in_a_preceding_graph_still_raises_the_barrier; values_before_optional_still_seeds; values_on_a_required_var_still_seeds_past_an_optional (the carve-out that protects the UNWIND-cliff seed); subquery_conditionally_bound_output_stays_below_the_subquery.
  • where_plan.rsvalues_after_optional_builds_above_the_optional, the physical-plan pin via Operator::describe, so a future reorder change can't silently reintroduce this without a plan-shape failure.
  • it_query_values.rstrailing_values_over_an_optional_var_joins_instead_of_seeding (asserts the fabricated ["ex:alice","ex:alice"] row is absent, then pins the exact 4-row answer), in_group_values_matches_the_post_query_spelling, trailing_values_is_a_join_not_a_filter, a_union_binding_the_var_does_not_suppress_the_barrier, an_undef_values_column_does_not_suppress_the_barrier, a_subquery_exposing_an_optional_bound_var_still_joins, and the JSON-LD twin jsonld_values_after_optional_joins_instead_of_seeding per the three-surface parity rule.

One supporting change: DeferredPattern gains a nestable flag, and drain_ready_deferred honours it. Every existing deferral sets nestable: true, so those paths are byte-identical; only the barrier sets it false. I couldn't actually construct a case that reaches the state it guards — the barriered VALUES can't become ready until the OPTIONAL is placed, at which point try_nest_deferred rejects it anyway. It's kept as a cheap invariant guard in case required_vars is ever narrowed, and the doc comment says plainly that it's defensive and untested so nobody reads the missing test as missing coverage.

Mutation results, since "the tests are green" isn't the claim worth making:

mutation reddens
barrier forced to return no blockers 4 planner tests + 6 it_query_values tests
must_bind_varsproduced_vars (both may-bind bugs) union_may_bind_does_not_suppress_the_optional_barrier, an_undef_column_does_not_suppress_the_optional_barrier, and their two it_query_values twins
the Values arm deleted (UNDEF alone) an_undef_column_does_not_suppress_the_optional_barrier + an_undef_values_column_does_not_suppress_the_barrier, and nothing else
Values contributes no must-bind vars (over-fire) a_bound_values_column_still_seeds_past_an_optional, and nothing else

The last two are the point of the pair: each direction of the Values arm is pinned by exactly one test, and neither test goes red under the other's mutation. The carve-out and perf pins (values_before_optional_still_seeds, values_on_a_required_var_still_seeds_past_an_optional, pure_union_without_an_optional_keeps_the_values_seed, values_seed_beats_disconnected_class_anchor) stay green under all four, which is what they're for. Two tests are honestly vacuous w.r.t. the barrier and say so in their own comments: both subquery ones, because the correlated-subquery deferral already keeps the VALUES down for that shape — unsound on paper, not reachable in practice, and must_bind_vars closes it regardless.

W3C suite

I did run it — testsuite-sparql, cargo test --test w3c_sparql, submodule initialised: 36/36 groups green, registers untouched. Nothing flipped in either direction. values07 lives in sparql11_bindings and was already passing on the post-query spelling, so the fix neither greens a registered failure nor costs a passing test.

Worth saying plainly rather than letting the number imply more than it does: values07 is the only file in the entire W3C bindings/ directory that contains an OPTIONAL, so the suite has never covered the in-group spelling at all. 36/36 means this broke nothing — not that the suite validates the fix. The integration tests are carrying that load on their own.

The suite matters for a second reason here: subquery_correlation_vars now shares must_bind_vars, which both widens its self_produced set (the old inline allow-list excluded Graph/Service/search adapters outright) and narrows it for an UNDEF column. sparql11_subquery and sparql10_query_eval_tests are the oracle for that, and both are green.

Gates

  • cargo test -p fluree-db-query --lib — 1431 passed, 0 failed, 1 ignored
  • cargo test -p fluree-db-api --test grp_query_sparql — 356 passed, 0 failed
  • cargo test -p fluree-db-api --test grp_query — 422 passed, 0 failed, 2 ignored
  • cargo test -p fluree-db-api --features native --test it_values_object_bounds — 9 passed, 0 failed (the fix: make IN/NOT IN match encoded resources, price the row lanes max_fuel could not see #1681 regression file this issue came out of)
  • cargo test -p fluree-db-api --test it_query_explain — 13 passed, 0 failed
  • testsuite-sparql cargo test --test w3c_sparql — 36 passed, 0 failed
  • cargo clippy -p fluree-db-query -p fluree-db-api --all-targets --no-deps — clean, zero warnings
  • cargo fmt --all (root) and cargo fmt --all -- --check in testsuite-sparql — both clean

Fixes #1690

aaj3f added 2 commits August 26, 2026 14:23
`reorder_patterns` classifies `Pattern::Values` as an exact-cardinality
source and lets it win the seed race, so a VALUES written after an
OPTIONAL in the same group was hoisted ahead of it. That rewrites
`Join(LeftJoin(P, O), V)` as `LeftJoin(Join(P, V), O)`, which is only the
same query when V binds nothing O introduces. When it does, V seeds the
variable before the left join runs, the left join matches the seeded
value and — dropping nothing — lets every driving row out carrying it.
The result is not merely too many rows: a row whose OPTIONAL bound `?b`
to `ns:X0` was reported as `?b = ns:B`, a value the data never had.

Add the missing arm to the order-sensitivity guard that already defers
MINUS/EXISTS/NOT EXISTS and correlated subqueries. `values_needs_optional_barrier`
fires only when a preceding OPTIONAL *introduces* one of the VALUES
variables; a variable already bound ahead of that OPTIONAL is only being
restricted, which commutes across the left join, so those VALUES keep
their seed and the exact-cardinality seed race is untouched. A barriered
VALUES is also dropped from `seed_anchor_vars`, since it seeds nothing.

The barrier keeps the JOIN semantics of SPARQL 1.1 §18.2.4 — it is not a
filter. A solution whose OPTIONAL left the variable UNBOUND is compatible
with every VALUES row, so it survives and adopts the binding. W3C
`sparql11/bindings/values07` pins that outcome; it passed already because
it uses the post-query `} VALUES ...` spelling, which plans correctly.

`DeferredPattern` gains a `nestable` flag so the barriered VALUES cannot
be folded back above the OPTIONAL by `try_nest_deferred` when a preceding
UNION/GRAPH/SERVICE happens to be placed early. Every existing deferral
sets it true, so those paths are unchanged.
`Pattern::produced_vars` is a MAY-bind set: `Union` unions its branches,
`Graph` recurses straight through a nested `Optional`, and `Subquery` is
just the SELECT list. Using it to decide "this variable is already bound
ahead of the OPTIONAL, so the OPTIONAL only restricts it" turned the
barrier's carve-out into a suppressor: a variable bound in only ONE union
branch was recorded as required-bound, the barrier stopped firing for a
later sibling OPTIONAL that genuinely introduces it, and the fabricated
binding came back one UNION away from the shape the barrier was written
for.

Lift `must_bind_vars` as the single definition of "bound in every
solution this pattern emits" — empty for OPTIONAL and the pure row
filters, branch INTERSECTION for UNION, recursive through
GRAPH/SERVICE/DefaultGraphSource, and `select` ∩ body-must-bind for a
subquery. `subquery_correlation_vars` needed exactly this rule and stated
it as an inline `matches!` allow-list that was flat and treated UNION as
wholly absent; it now shares the helper, so the two relations over the
same concept cannot drift.

Detection is now a separate notion from accumulation.
`left_join_introduced_vars` recurses through preceding containers, so an
OPTIONAL nested in a GRAPH/SERVICE/UNION raises the barrier too — the
gap previously documented as a scope note. It deliberately does NOT
include a UNION that merely binds a variable on one branch: `Join` is
commutative, so a VALUES hoisted past a UNION still meets the other
branch's rows unbound and they still adopt the value. Branch
intersection separates the cases exactly, so `{?f} UNION {?f}` keeps its
seed while `{?f} UNION {?e}` raises the barrier.

The barrier is also positional now. `required_vars` is a
variable-readiness test, so a sibling UNION branch binding the same
variable satisfied it and drained the VALUES before the OPTIONAL had been
placed at all — the plan was still wrong with the must-bind fix alone.
`after_indices` names the blocking patterns and `drain_ready_deferred`
holds the VALUES until they are placed.

Tests: UNION-in-front at both the planner and SPARQL levels (both go red
under a may-bind mutation), an OPTIONAL nested in a preceding GRAPH, a
subquery exposing an OPTIONAL-bound var, and a pin that a UNION with no
left join keeps its seed. `trailing_values_is_a_join_not_a_filter` now
asserts both answer sets — `assert_ne!` alone was satisfied by the wrong
VALUES answer too, so it passed with the fix reverted.

@bplatz bplatz 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.

Approving. The diagnosis is right, the instrument is right, and the second commit catches a real bug the first one had.

I verified the defect independently on pristine main (fe3c198c8) rather than taking either the issue or the PR at face value. On seed_values_dataset: in-group VALUES 5 rows including ["ex:alice","ex:alice"], post-query 4, FILTER 2. ex:alice's only ex:friend is ex:brian, so that row reports a binding the data never had, and two spellings of one query disagree. Real bug, correctly fixed — the branch returns 4. The must-bind carve-out also checks out algebraically: when V's vars are must-bound by P, the same P rows survive the restriction on either side of the left join, so keeping the seed is safe.

One thing worth correcting on #1690

The filed repro doesn't actually demonstrate the bug. I modelled its shape exactly — one subject has the OPTIONAL predicate, every other driving row leaves the variable unbound (ex:cool is only on ex:nikola):

main, in-group VALUES:   5 rows
main, post-query VALUES: 5 rows   <- identical

Buggy and correct paths agree. In #1690's numbers: 1 bound-compatible row + 1499 unbound-and-adopting rows = 1500, which is what the engine returned. Its "correct answer is 1" is the FILTER answer, and trailing_values_is_a_join_not_a_filter in this PR is the argument for why that isn't the equivalent rewrite.

So I'd put it differently than "the original report framed it as a cross-product: it is worse than that" — the original fixture was coincidentally clean, and the defect only surfaces once the OPTIONAL binds a different value. That makes this PR's fixture choice the load-bearing part rather than an incidental one, and it's worth saying on the issue so the repro doesn't teach the wrong diagnostic.

#1713 is wider than filed, and sharper

Same defect through a second door, confirmed pre-existing on main:

?s schema:name ?name .
VALUES (?s ?f) { (ex:alice UNDEF) (ex:cam UNDEF) (ex:liam UNDEF) (ex:brian UNDEF) (ex:nikola UNDEF) }
OPTIONAL { ?s ex:friend ?f }

8 rows, correct multiplicity, ?f Null on every one. The OPTIONAL matched the triples and then didn't write the binding, because ?f was already in the row as Unbound. That's stronger than #1713's current "the left join is a no-op" — worth adding there.

Before merge

Only the first inline comment. The other two are a comment edit and a typo.

}
// Triples, property paths, BIND/UNWIND/VALUES, search adapters: every
// solution they emit carries their produced vars.
other => other.produced_vars().into_iter().collect(),

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.

Pattern::Values lands here, and it is may-bind: lower_values_pattern (fluree-db-sparql/src/lower/pattern.rs:225) emits Binding::Unbound for UNDEF, so an UNDEF column doesn't bind its variable in every row. That leaves the one may-bind variant this arm still misclassifies as the pattern kind the barrier is actually about.

Reproduces the original fabrication on this branch:

?s schema:name ?name .
VALUES (?s ?f) { (ex:alice UNDEF) (ex:cam UNDEF) (ex:liam UNDEF) (ex:brian UNDEF) (ex:nikola UNDEF) }
OPTIONAL { ?s ex:friend ?f }
VALUES ?f { ex:alice }

5 rows including ["ex:alice","ex:alice"]; the reference answer is 4. The leading table records ?f as required-bound, the barrier doesn't fire, and the trailing VALUES seeds. This is exactly the suppressor class cde7d129a was written to close — and UNDEF-in-VALUES is the parameterized-query idiom #1690 names as the motivating usage, so it isn't a synthetic corner.

Honest caveat on the fix: I patched this arm to exclude columns containing an Unbound cell and the plan corrects, but the answer becomes 8 rows still carrying the fabricated one, because #1713 bites the same query. So it's a plan-shape fix only — the same posture you already took for the UNION shape, and the same treatment fits: an arm here plus a plan-shape test.

If you'd rather not widen, the alternative is fine too: say plainly in the doc comment above that Values (UNDEF) and Bind (see the other comment) are known may-bind exceptions this function deliberately counts. What I'd avoid is leaving the contract stated as unconditional, since the whole value of the helper is that a future reader trusts it.

Comment thread fluree-db-query/src/planner.rs Outdated
/// Shared with [`subquery_correlation_vars`], which needs the identical rule to
/// decide whether a shared SELECT-list variable is a join key or a correlation
/// input — it used to state it as an inline `matches!` allow-list. One relation
/// over one concept, so the two cannot drift.

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 claim is a bit stronger than the code supports, and in a direction that could bite later.

Pattern::Bind is may-bind too — bind.rs:10, evaluation errors produce Binding::Unbound — and a BIND-then-OPTIONAL lead reproduces the fabrication the same way a UNION lead does. But subquery_correlation_vars deliberately counts BIND, and its own comment says why (a var the subquery produces via a WITH-pipeline binder must not be mistaken for an external correlation). So for Bind the two call sites genuinely want different answers.

Not something to fix here — the current behaviour is right at both sites in practice. The risk is that "one relation over one concept, so the two cannot drift" reads as an invariant, and someone later tightens must_bind_vars for Bind on that basis and quietly breaks subquery correlation. A sentence noting that Bind is a deliberate exception, and which site depends on it, would inoculate that.

Comment thread fluree-db-query/src/planner.rs Outdated
/// question: it unions a `UNION`'s branches (`ir/pattern.rs`) and recurses
/// straight through a nested `Optional`. Reading it as "already bound" is what
/// let a variable bound in only ONE union branch suppress
/// [`values_needs_optional_barrier`] for a later sibling OPTIONAL that genuinely

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.

values_needs_optional_barrier doesn't exist — the function is values_optional_barrier_indices. Same stale name at planner.rs:1524. This one is inside a /// block so it's a broken intra-doc link; CI runs clippy + nextest, not cargo doc, so nothing will catch it.

`must_bind_vars` fell through to `produced_vars` for `Pattern::Values`,
which is its whole variable list. Both surfaces lower `UNDEF` to
`Binding::Unbound` (`lower_values_pattern`, `lower_values_cell`), so an
all-UNDEF column binds its variable in no row at all while reading as
required-bound — the last may-bind variant the arm misclassified, and the
same suppressor class the barrier's must-bind rule was written to close.

A leading `VALUES (?s ?f) { (ex:alice UNDEF) … }` therefore recorded `?f`
as required-bound, the barrier didn't fire for a following
`OPTIONAL { ?s ex:friend ?f }`, and the trailing `VALUES ?f` went back to
seed position — the #1690 fabrication, reachable one UNDEF away from the
shape the barrier was written for. UNDEF is the parameterized-query idiom
#1690 names as its motivating usage, not a corner.

The arm counts only the columns with no `Unbound` cell. A zero-row table
stays vacuously must-bind on every column (it emits no solutions at all —
the missing-predicate sentinels in `parse/lower.rs` and
`empty_path_result` are both this shape), and Cypher's WITH-pipeline
binders never emit `Unbound`, so the narrowing cannot reach the
`self_produced` set `subquery_correlation_vars` builds for them.

Plan-shape only, and deliberately: with the arm the trailing VALUES lands
above the left join as it must, but the answer becomes 8 rows still
carrying the fabricated one, because #1713 bites the same query. Same
posture as the UNION shape — `an_undef_values_column_does_not_suppress_the_barrier`
asserts the physical plan and says why. `an_undef_column_does_not_suppress_the_optional_barrier`
pins the planner ordering; `a_bound_values_column_still_seeds_past_an_optional`
pins the over-fire direction (a fully-bound column must keep the seed).
Each reddens under exactly one mutation and neither under the other.

Also documents `Bind` as a known may-bind exception the function
deliberately counts, and which call site depends on it: it is may-bind for
the same reason (`bind.rs` yields `Binding::Unbound` on an evaluation
error), but `subquery_correlation_vars` needs it counted or a variable the
subquery produces through a WITH-pipeline binder is read as an external
correlation. The two sites genuinely want different answers there, so the
contract is stated with the exception rather than as unconditional.

Fixes the stale `values_needs_optional_barrier` intra-doc link (the
function is `values_optional_barrier_indices`) at both sites.
@aaj3f

aaj3f commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @bplatz — the UNDEF case is a real hole and I've widened rather than documented around it, and both of your issue corrections were right.

The Values arm. must_bind_vars now counts only the columns with no Binding::Unbound cell instead of falling through to produced_vars and taking the whole variable list. I reproduced your query on the branch first and got your numbers exactly: pre-fix Project > Optional > NestedLoopJoin > Values > Values and 5 rows including the fabricated ["ex:alice","ex:alice"]; post-fix Project > Values > Optional > NestedLoopJoin > Values > Empty and 8 rows still carrying it, because #1713 bites the same query. So it's plan-shape only, same posture as the UNION shape, and the test says so in as many words.

Two edge cases the arm handles deliberately, both of which are ways it could have regressed something: a zero-row table stays vacuously must-bind on every column — the missing-predicate sentinel in parse/lower.rs and empty_path_result are both that shape and emit no solutions at all — and Cypher's WITH-pipeline binders never emit Unbound (literal_to_binding can't produce it), so the narrowing cannot reach the self_produced set subquery_correlation_vars builds. That was the one path where widening here could have quietly broken the other call site.

On Bind — you're right that the contract as written invites exactly the wrong fix. The doc now names Bind as a known may-bind variant this function deliberately counts, says why it's may-bind, names subquery_correlation_vars as the site depending on that, and says plainly that tightening it here on the strength of the stated contract would break subquery correlation — a fix at both sites or at neither. The "so the two cannot drift" line is gone; it now reads as one relation with a single knowing exception.

The stale name is fixed in both places (planner.rs:1277 and the seed_anchor_vars site); grep is clean. Good catch that cargo doc isn't in CI — that link would have stayed broken indefinitely.

Each direction of the new arm is pinned by exactly one test, and I checked they don't overlap: deleting the Values arm reddens only the two UNDEF tests; making Values contribute nothing must-bind reddens only a_bound_values_column_still_seeds_past_an_optional. The four perf/carve-out pins stay green under both mutations and under the barrier-disabled one.

Both of your issue corrections are posted. On #1690 — you're right that the filed repro doesn't demonstrate the bug, and I've said so there: where every non-matching driving row leaves the variable unbound, the fabricated and legitimately-adopting rows are indistinguishable by count, so buggy and correct paths agree at 5. I've also noted that its "correct answer is 1" is the FILTER answer, and reframed this PR's opening so the fixture choice reads as load-bearing rather than incidental.

On #1713 — confirmed on the branch and it's sharper than either of us put it. Your query returns 8 rows with ?f null on every one; deleting the VALUES line returns those same 8 rows with ?f bound on 6 of them. Same multiplicity either way, so the OPTIONAL matched the triples and then discarded the binding because the column was already present as Unbound — the join happened and the result was thrown away, which is a more precise statement than "the left join is a no-op." That's on the issue now, along with a note that #1724 should be checked against the UNDEF shape and not only the UNION one before its Fixes line stands.

W3C 36/36 with registers untouched, and the usual testsuite-sparql/Cargo.lock rand drift reverted rather than folded in.

@aaj3f aaj3f closed this Aug 28, 2026
@aaj3f aaj3f reopened this Aug 28, 2026
@aaj3f
aaj3f merged commit 146b663 into main Aug 28, 2026
37 of 40 checks passed
@aaj3f
aaj3f deleted the fix/values-after-optional-hoist branch August 28, 2026 16:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

VALUES after OPTIONAL cross-products instead of joining (silent wrong results)

2 participants