Skip to content

fix(optimizer): drop the rows where MATCH re-binds a node that OPTIONAL MATCH left NULL - #1362

Merged
adsharma merged 5 commits into
LadybugDB:mainfrom
zahariash:fix/optional-match-null-join-key
Oct 23, 2026
Merged

adsharma merged 5 commits into
LadybugDB:mainfrom
zahariash:fix/optional-match-null-join-key

Conversation

@zahariash

@zahariash zahariash commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

This work was produced with the help of language models.

Fixes #1298.

With the issue's data, MATCH (a:P) WHERE a.id < 3 OPTIONAL MATCH (a)-[:K]->(b:P) WITH a, b MATCH (b) RETURN a.id, b.id returned [0, None], [1, 5], [2, None] instead of [1, 5]. The second MATCH kept the rows where b was NULL. So did MATCH (b:P), an alias, a SET between the clauses, chained OPTIONAL MATCHes, and an OPTIONAL MATCH that is uncorrelated or comes first.

Fix. The planner joins the earlier rows with a scan of b, and that join is what drops a NULL b. RemoveUnnecessaryJoinOptimizer removed it because the scan reads no properties. The join now stays when an OPTIONAL MATCH below can leave its key NULL. MERGE's existence check doesn't count, since MERGE creates what it doesn't find. Re-matching a variable that can't be NULL still drops the join. The kept join reads b's whole node table, as with enable_plan_optimizer=false.

Tests. optional_match.test covers each form above, and all of them fail on main (8 rows instead of 3 for the issue's shape). RemoveUnnecessaryJoinNullableKeyTest checks that the join stays where the key can be NULL and goes where it can't, including after MERGE. Removing any one of the hasMark checks or the probe-side checks fails it.

Full ctest: 3164/3164 passed. LDBC SNB SF1: all 44 plans are unchanged, and planning costs 0.085% more instructions, within run-to-run noise.

zahariash and others added 2 commits October 22, 2026 19:11
…NULL

After OPTIONAL MATCH (a)-[:K]->(b), a later MATCH (b) is planned as an
inner hash join of the previous plan with a bare scan of b on b._ID.
RemoveUnnecessaryJoinOptimizer removed that join because the scan has no
properties, but the join is what drops the rows where b is NULL, so
persons without a neighbour came back with a NULL b.

The join now stays when a key on the kept side can be NULL: it comes from
the build side of a LEFT join or an optional cross product, or from under
an optional accumulate. MERGE's marked existence check is skipped, since
MERGE creates what it does not find.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After two chained OPTIONAL MATCHes, MATCH (c) must drop the rows where
either step found nothing, and MATCH (b) the rows where the first did.
Re-matching b needs the NULL check to look below the second OPTIONAL
MATCH, whose join has b on both sides; a check that stops at that join
keeps the rows where b is NULL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
// Joining with a full node scan drops the rows whose key is NULL, so the join is only
// redundant when the kept side's keys are never NULL.
static bool keptSideKeysCanBeNull(const LogicalHashJoin& hashJoin, uint32_t keptChildIdx) {
for (auto& [probeKey, buildKey] : hashJoin.getJoinConditions()) {

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.

Nit (non-blocking): hashJoin.getJoinConditions() returns by value, so this copies the condition vector on every candidate join. Consider hoisting to const auto joinConditions = hashJoin.getJoinConditions(); outside the loop.

@adsharma

adsharma commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Two non-blocking follow-ups from review (inline nit posted separately):

  1. Question: the linked issue (Bug: MATCH on a node that OPTIONAL MATCH left NULL keeps the row instead of dropping it #1298) explicitly names MATCH (b:P) as affected, but all new e2e cases use bare MATCH (b) / MATCH (c). The mechanism is label-agnostic (same bare-scan join path), so almost certainly covered — would you add one MATCH (b:organisation) row in optional_match.test to pin the exact reported shape?

zahariash and others added 2 commits October 23, 2026 21:28
The issue names MATCH (b:P) as affected; the new cases only used a bare
MATCH (b). MATCH (b:organisation) takes the same bare-scan join and
returns 8 rows on main instead of 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getJoinConditions() returned the vector by value, so each caller copied
it, including RemoveUnnecessaryJoinOptimizer once per candidate join.
Every caller only reads the conditions or copies them explicitly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zahariash

Copy link
Copy Markdown
Contributor Author

Added MatchDropsNullFromLeftJoinLabel with MATCH (b:organisation): 8 rows on main, 3 here.

On the nit: the range-for already calls getJoinConditions() once, so hoisting it keeps the one copy per candidate join. LogicalHashJoin::getJoinConditions() now returns a const reference instead; its other callers only read the conditions or copy them explicitly.

…rough

A comment on the default case of canBeNull's operator switch, and one on
why MARK and COUNT joins never expose a build-side key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zahariash
zahariash marked this pull request as ready for review October 23, 2026 20:33
@adsharma
adsharma merged commit a8739f5 into LadybugDB:main Oct 23, 2026
4 checks passed
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.

Bug: MATCH on a node that OPTIONAL MATCH left NULL keeps the row instead of dropping it

2 participants