Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,16 @@ fn resolve_classic_join(
buffer_idx = buffered_null_idx;
stream_idx = stream_null_idx;
batch_process_state.processed_null_count = true;

// The scan below starts past the streamed side's NULL-keyed rows, which
// sit at the front (`nulls_first`). A NULL join key never matches under
// `NullEqualsNothing`, so for `Right`/`Full` those rows are unmatched and
// must still be emitted; record them here since the scan will skip them.
if matches!(join_type, JoinType::Right | JoinType::Full) {
for row_idx in 0..stream_null_idx as u32 {
batch_process_state.unmatched_indices.append_value(row_idx);
}
}
}

// Our buffer_idx variable allows us to start probing on the buffered side where we last matched
Expand Down
45 changes: 45 additions & 0 deletions datafusion/sqllogictest/test_files/pwmj.slt
Original file line number Diff line number Diff line change
Expand Up @@ -342,5 +342,50 @@ ORDER BY 1,2;
1 3
2 3

# RIGHT JOIN: every right row must be emitted, including one whose join key is
# NULL. A NULL key never matches (NullEqualsNothing), so t2.id = NULL is
# unmatched and must appear as (NULL, NULL). Regression test: the streamed-side
# NULL rows are skipped by the match scan and were previously dropped instead of
# being reported as unmatched.
query II
SELECT t1.id AS left_id, t2.id AS right_id

@comphead comphead Aug 13, 2026

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.

is FULL join supposed to be tested as well?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good question — the fix path is Right | Full, so it covers FULL JOIN too. I've added a FULL JOIN test in the latest commit.

I couldn't reuse the null_join_* tables for it: on this branch's base, a FULL JOIN over those still drops the unmatched left NULL-keyed row (t1.id = NULL). That's a separate pre-existing bug — the classic Left/Full unmatched-row drop, which #23870 fixes but main doesn't have yet — unrelated to the right-side NULL fix here, so a FULL JOIN on null_join_* would fail on this base for that other reason.

To keep the test focused on what this PR fixes (the unmatched right-side NULL row), the added FULL JOIN case uses data where every left row matches, so only the right-NULL emission is exercised.

FROM null_join_t1 t1
RIGHT JOIN null_join_t2 t2
ON t1.id < t2.id
ORDER BY 1,2;
----
1 3
2 3
NULL 1
NULL NULL

# FULL JOIN: the same fix applies to the FULL path. An unmatched right row with
# a NULL key must still be emitted as (NULL, NULL). This uses data where every
# left row matches, so only the right-side NULL emission fixed here is
# exercised (the separate left-side unmatched-row drop, tracked elsewhere, is
# not).
statement ok
CREATE TABLE full_null_t1 (id INT);

statement ok
CREATE TABLE full_null_t2 (id INT);

statement ok
INSERT INTO full_null_t1 VALUES (10), (20);

statement ok
INSERT INTO full_null_t2 VALUES (100), (NULL);

query II
SELECT t1.id AS left_id, t2.id AS right_id
FROM full_null_t1 t1
FULL JOIN full_null_t2 t2
ON t1.id < t2.id
ORDER BY 1,2;
----
10 100
20 100
NULL NULL

statement ok
set datafusion.optimizer.enable_piecewise_merge_join = false;