This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion.git
The following commit(s) were added to refs/heads/main by this push:
new 95bb0a0dfa fix: correlated NOT IN with a non-equality correlation
returns wrong results (#25339)
95bb0a0dfa is described below
commit 95bb0a0dfa48ca20d017faa55b2fc96ee2f300fe
Author: Adrian Garcia Badaracco <[email protected]>
AuthorDate: Wed Sep 23 21:55:24 2026 +0000
fix: correlated NOT IN with a non-equality correlation returns wrong
results (#25339)
## Which issue does this PR close?
- Closes https://github.com/apache/datafusion/issues/25336.
> [!NOTE]
> **This PR now holds only the executor fix.** It used to hold the
executor fix, the optimizer fix, and their tests together. To make
review easier, I split it:
>
> 1. Tests and benchmarks:
https://github.com/apache/datafusion/pull/25558 (merged). They record
the wrong results on `main`.
> 2. Executor fix: this PR. It flips the expectations that it fixes.
> 3. Optimizer fix: https://github.com/apache/datafusion/pull/25560,
stacked on this PR.
>
> The executor code is the same as at 78b49ba214 (the last head that was
reviewed), less one dead line. The review threads on
`decorrelate_predicate_subquery.rs` ([plain
`IN`](https://github.com/apache/datafusion/pull/25339#discussion_r4046914195),
[`InSubquery`
value](https://github.com/apache/datafusion/pull/25339#discussion_r4056392706)),
the [`on[0]`
thread](https://github.com/apache/datafusion/pull/25339#discussion_r4027554354)
and the [constant-projection
comment](https://github.com/apache/datafusion/pull/25339#issuecomment-5738402844)
are fixed in #25560.
## Rationale for this change
A correlated `NOT IN` whose correlation cannot become an equi-join key
leaves a residual join filter. The null-aware hash join ignored that
filter when deciding whether a NULL on the subquery side makes `NOT IN`
UNKNOWN, so a NULL the filter excludes still poisoned every outer row:
```sql
CREATE TABLE oc(id INT, g INT) AS VALUES
(1,5),(2,5),(3,0),(4,NULL),(NULL,5),(NULL,0);
CREATE TABLE ic(id INT) AS VALUES (1),(NULL);
SELECT id, g FROM oc WHERE oc.id NOT IN (SELECT ic.id FROM ic WHERE oc.g >
0);
```
returns no rows; DuckDB and PostgreSQL return `3|0`, `4|NULL`, `NULL|0`.
`oc.g > 0` holds only for `id` 1, 2 and the NULL-id row, so only those
three see the subquery `{1, NULL}`; the rest see an empty subquery, and
`NOT IN` over an empty set is TRUE.
The plan was already right — `LeftAnti ... Filter: oc.g > Int32(0)
null_aware` — so this is purely an execution fix. No optimizer change is
involved.
## What changes are included in this PR?
A NULL now makes `NOT IN` UNKNOWN only for the build rows whose
correlation scope and residual filter keep that NULL, recorded per build
row in a null-indices bitmap. Candidates come from a scope-map lookup
when there are correlation keys and from a cross product otherwise, then
pass the filter. Cost is proportional to the number of NULLs and is zero
when the data has none; build rows already marked UNKNOWN are skipped.
Null-aware `LeftAnti` also accepts more than one join key, which the
equality-correlated shape needs. `RightAnti` still requires exactly one.
## What is the testing strategy for this PR?
The coverage landed in #25558. This PR flips the expectations it fixes —
the diff in `null_aware_anti_join.slt` and the Q05–Q07 canaries is the
behaviour change. A "pinned to today's behaviour" note is removed only
where the expectation below it changes; the notes on shapes that #25560
fixes stay. `datafusion/physical-plan/src/joins/hash_join/exec.rs` also
gains unit tests for the filter-only anti and mark paths at several
batch sizes.
## Are there any user-facing changes?
Correlated `NOT IN` with a residual filter returns correct results. Some
shapes that failed to plan now run.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Adrian Garcia Badaracco <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
---
.../null_aware_join/benchmarks/q05.benchmark | 4 +-
.../null_aware_join/benchmarks/q06.benchmark | 4 +-
.../null_aware_join/benchmarks/q07.benchmark | 4 +-
.../physical-optimizer/src/join_selection.rs | 4 +-
.../physical-plan/src/joins/hash_join/exec.rs | 340 ++++++++++++++-----
.../physical-plan/src/joins/hash_join/stream.rs | 376 +++++++++++++++------
.../test_files/null_aware_anti_join.slt | 54 ++-
7 files changed, 568 insertions(+), 218 deletions(-)
diff --git a/benchmarks/sql_benchmarks/null_aware_join/benchmarks/q05.benchmark
b/benchmarks/sql_benchmarks/null_aware_join/benchmarks/q05.benchmark
index bde1f3e565..54fdaba978 100644
--- a/benchmarks/sql_benchmarks/null_aware_join/benchmarks/q05.benchmark
+++ b/benchmarks/sql_benchmarks/null_aware_join/benchmarks/q05.benchmark
@@ -15,9 +15,7 @@ SELECT count(*) = (
FROM small_outer o
WHERE o.id_n1 NOT IN (SELECT i.id_n0 FROM small_inner i WHERE i.z < o.z);
----
-# Pinned to today's behaviour, which is incorrect. See
-# https://github.com/apache/datafusion/issues/25336 -- the fix flips this.
-false
+true
expect_plan HashJoinExec
expect_plan null_aware
diff --git a/benchmarks/sql_benchmarks/null_aware_join/benchmarks/q06.benchmark
b/benchmarks/sql_benchmarks/null_aware_join/benchmarks/q06.benchmark
index 25929198e7..e841f25df5 100644
--- a/benchmarks/sql_benchmarks/null_aware_join/benchmarks/q06.benchmark
+++ b/benchmarks/sql_benchmarks/null_aware_join/benchmarks/q06.benchmark
@@ -15,9 +15,7 @@ SELECT count(*) = (
FROM small_outer o
WHERE o.id_n50 NOT IN (SELECT i.id_n0 FROM small_inner i WHERE i.z < o.z);
----
-# Pinned to today's behaviour, which is incorrect. See
-# https://github.com/apache/datafusion/issues/25336 -- the fix flips this.
-false
+true
expect_plan HashJoinExec
expect_plan null_aware
diff --git a/benchmarks/sql_benchmarks/null_aware_join/benchmarks/q07.benchmark
b/benchmarks/sql_benchmarks/null_aware_join/benchmarks/q07.benchmark
index 3364be5513..7b92e60e40 100644
--- a/benchmarks/sql_benchmarks/null_aware_join/benchmarks/q07.benchmark
+++ b/benchmarks/sql_benchmarks/null_aware_join/benchmarks/q07.benchmark
@@ -17,9 +17,7 @@ SELECT count(*) = (
FROM small_outer o
WHERE o.id_n0 NOT IN (SELECT i.id_n50 FROM small_inner i WHERE i.z < o.z);
----
-# Pinned to today's behaviour, which is incorrect. See
-# https://github.com/apache/datafusion/issues/25336 -- the fix flips this.
-false
+true
expect_plan HashJoinExec
expect_plan null_aware
diff --git a/datafusion/physical-optimizer/src/join_selection.rs
b/datafusion/physical-optimizer/src/join_selection.rs
index f11e8612de..2b342bece7 100644
--- a/datafusion/physical-optimizer/src/join_selection.rs
+++ b/datafusion/physical-optimizer/src/join_selection.rs
@@ -172,11 +172,13 @@ impl PhysicalOptimizerRule for JoinSelection {
}
}
-/// Determines whether it is possible to swap inputs of a hash join - for
null-aware joins, we can only swap `LeftAnti` with no filters
+/// Determines whether it is possible to swap inputs of a hash join - for
null-aware joins, we can only swap an uncorrelated `LeftAnti`
+/// (a single join key and no filter), because the swapped `RightAnti` has no
per-row NULL handling
fn can_swap_hash_join(hash_join: &HashJoinExec) -> bool {
hash_join.join_type().supports_swap()
&& (!hash_join.null_aware
|| (*hash_join.join_type() == JoinType::LeftAnti
+ && hash_join.on().len() == 1
&& hash_join.filter().is_none()))
}
diff --git a/datafusion/physical-plan/src/joins/hash_join/exec.rs
b/datafusion/physical-plan/src/joins/hash_join/exec.rs
index 9858a4c06c..d2fcf5191d 100644
--- a/datafusion/physical-plan/src/joins/hash_join/exec.rs
+++ b/datafusion/physical-plan/src/joins/hash_join/exec.rs
@@ -192,19 +192,20 @@ fn try_create_array_map(
Ok(Some((array_map, batch, left_values)))
}
-/// Correlation-scope hash map over only the build rows whose scalar `NOT IN`
-/// value key is NULL, used by correlated null-aware `LeftMark` joins.
+/// The build rows whose scalar `NOT IN` value key is NULL, used by correlated
+/// null-aware joins (see [`NullAwareMode`]).
///
-/// Such rows produce a NULL (UNKNOWN) mark whenever *any* probe row shares
-/// their correlation scope, so every probe row must be tested against them.
-/// Restricting this map to the NULL-valued build rows keeps that lookup
+/// Such rows are UNKNOWN whenever *any* probe row in their correlation scope
+/// passes the join filter, so every probe row must be tested against them.
+/// Restricting this lookup to the NULL-valued build rows keeps it
/// proportional to the number of NULLs instead of enumerating every scope
/// match of every probe row.
-pub(super) struct NullValueScopeMap {
+pub(super) struct NullValueBuildRows {
/// Hash table keyed by the correlation scope values of the NULL-valued
/// build rows. Stored positions index into `scope_values`/`build_indices`,
- /// not the full build batch.
- pub(super) map: Box<dyn JoinHashMapType>,
+ /// not the full build batch. `None` when the join has no correlation
+ /// scope keys, so every probe row is in scope.
+ pub(super) scope_map: Option<Box<dyn JoinHashMapType>>,
/// Correlation scope key values of the NULL-valued build rows.
pub(super) scope_values: Vec<ArrayRef>,
/// Maps positions in `map`/`scope_values` back to row indices in the full
@@ -215,19 +216,23 @@ pub(super) struct NullValueScopeMap {
/// Null-aware (`NOT IN`) semantics of a hash join, derived from
/// [`HashJoinExec::null_aware`] and the join type.
///
-/// Only these three combinations are legal (see [`Self::try_new`]), so the
+/// Only these combinations are legal (see [`Self::try_new`]), so the
/// stream matches on this instead of re-checking `null_aware && join_type ==
..`.
+///
+/// A `correlated` join has correlation scope keys (`on[1..]`, see
+/// [`HashJoinExec::null_aware`]) or a join filter, or both. A NULL then makes
+/// `NOT IN` UNKNOWN only for the build rows whose scope and filter keep that
+/// NULL, so the join records the decision per build row in the null-indices
+/// bitmap instead of in shared probe-side flags.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum NullAwareMode {
- /// Uncorrelated `build.key NOT IN (probe.key)`: emits build rows, and
- /// none of them once any probe key is NULL.
- LeftAnti,
+ /// `build.key NOT IN (probe.key)`: emits build rows. When uncorrelated,
+ /// none of them are emitted once any probe key is NULL.
+ LeftAnti { correlated: bool },
/// Uncorrelated `probe.key NOT IN (build.key)`: emits probe rows, and
/// none of them once any build key is NULL.
RightAnti,
- /// `NOT IN` as a nullable mark column on the build rows. `correlated`
- /// means `on[1..]` are correlation scope keys (see
- /// [`HashJoinExec::null_aware`]).
+ /// `NOT IN` as a nullable mark column on the build rows.
LeftMark { correlated: bool },
}
@@ -239,13 +244,12 @@ impl NullAwareMode {
num_keys: usize,
has_filter: bool,
) -> Result<Self> {
+ let correlated = num_keys > 1 || has_filter;
let mode = match (join_type, partition_mode) {
- (JoinType::LeftAnti, _) => Self::LeftAnti,
+ (JoinType::LeftAnti, _) => Self::LeftAnti { correlated },
// `PartitionMode::CollectLeft` is safe because `RightAnti` is
probe-driven
(JoinType::RightAnti, PartitionMode::CollectLeft) =>
Self::RightAnti,
- (JoinType::LeftMark, _) => Self::LeftMark {
- correlated: num_keys > 1,
- },
+ (JoinType::LeftMark, _) => Self::LeftMark { correlated },
_ => {
return plan_err!(
"null_aware can only be true for LeftAnti joins and
RightAnti joins with `CollectLeft` `PartitionMode`, or LeftMark joins, got
{join_type} with {partition_mode}"
@@ -253,10 +257,14 @@ impl NullAwareMode {
}
};
match mode {
- Self::LeftAnti | Self::RightAnti if num_keys != 1 => plan_err!(
+ Self::RightAnti if num_keys != 1 => plan_err!(
"null_aware {join_type} joins only support single column join
key, got {num_keys} columns"
),
- Self::LeftMark { .. } if partition_mode ==
PartitionMode::Partitioned => {
+ // Correlated joins share the per-build-row null bitmap across all
+ // probe partitions.
+ Self::LeftMark { .. } | Self::LeftAnti { correlated: true }
+ if partition_mode == PartitionMode::Partitioned =>
+ {
plan_err!(
"null_aware joins require PartitionMode::CollectLeft, got
PartitionMode::Partitioned"
)
@@ -267,6 +275,14 @@ impl NullAwareMode {
_ => Ok(mode),
}
}
+
+ /// Whether this join decides UNKNOWN per build row (see
[`NullAwareMode`]).
+ pub(super) fn is_correlated(self) -> bool {
+ matches!(
+ self,
+ Self::LeftAnti { correlated: true } | Self::LeftMark { correlated:
true }
+ )
+ }
}
/// HashTable and input data for the left (build side) of a join
@@ -274,17 +290,16 @@ pub(super) struct JoinLeftData {
/// The hash table with indices into `batch`
/// Arc is used to allow sharing with SharedBuildAccumulator for hash map
pushdown
pub(super) map: Arc<Map>,
- /// Hash table over correlated scope keys for scalar null-aware mark joins.
+ /// Hash table over correlated scope keys for correlated null-aware joins.
///
- /// For null-aware `LeftMark`, key 0 is the scalar `NOT IN` value key and
- /// keys 1..N are correlated equality scope keys. This map covers all build
- /// rows and is probed only with NULL-valued probe rows; the complementary
- /// direction uses `null_value_scope_map`.
- null_aware_mark_scope_map: Option<Box<dyn JoinHashMapType>>,
- /// Scope map restricted to the build rows whose value key is NULL (see
- /// [`NullValueScopeMap`]). `None` when the build side has no NULL value
- /// keys.
- null_value_scope_map: Option<NullValueScopeMap>,
+ /// Key 0 is the scalar `NOT IN` value key and keys 1..N are correlated
+ /// equality scope keys. This map covers all build rows and is probed only
+ /// with NULL-valued probe rows; the complementary direction uses
+ /// `null_value_build_rows`. `None` when there are no scope keys.
+ null_aware_scope_map: Option<Box<dyn JoinHashMapType>>,
+ /// The build rows whose value key is NULL (see [`NullValueBuildRows`]).
+ /// `None` when the build side has no NULL value keys.
+ null_value_build_rows: Option<NullValueBuildRows>,
/// The input rows for the build side
batch: RecordBatch,
/// The build side on expressions values
@@ -318,12 +333,12 @@ impl JoinLeftData {
&self.map
}
- pub(super) fn null_aware_mark_scope_map(&self) -> Option<&dyn
JoinHashMapType> {
- self.null_aware_mark_scope_map.as_deref()
+ pub(super) fn null_aware_scope_map(&self) -> Option<&dyn JoinHashMapType> {
+ self.null_aware_scope_map.as_deref()
}
- pub(super) fn null_value_scope_map(&self) -> Option<&NullValueScopeMap> {
- self.null_value_scope_map.as_ref()
+ pub(super) fn null_value_build_rows(&self) -> Option<&NullValueBuildRows> {
+ self.null_value_build_rows.as_ref()
}
/// returns a reference to the build side batch
@@ -878,13 +893,16 @@ pub struct HashJoinExec {
/// Flag to indicate if this join uses null-aware equality semantics.
///
/// Set for the physical lowering of scalar `NOT IN` subqueries (producing
- /// `JoinType::LeftAnti` when uncorrelated or `JoinType::LeftMark` when
- /// correlated). When `true`, NULLs in the join keys follow SQL `NOT IN`
- /// three-valued logic rather than ordinary equi-join semantics.
+ /// `JoinType::LeftAnti` at the top level of a filter or
`JoinType::LeftMark`
+ /// inside a larger expression). When `true`, NULLs in the join keys follow
+ /// SQL `NOT IN` three-valued logic rather than ordinary equi-join
semantics.
+ /// A join filter holds the non-equality part of a correlated subquery, and
+ /// only the probe rows that pass it take part in the three-valued logic.
///
/// Key-ordering convention (relied on positionally, not enforced): for a
- /// null-aware `LeftMark` join with more than one key, `on[0]` is the
scalar
- /// `NOT IN` value key and `on[1..N]` are the correlated equality scope
keys.
+ /// null-aware `LeftAnti` or `LeftMark` join with more than one key,
`on[0]`
+ /// is the scalar `NOT IN` value key and `on[1..N]` are the correlated
+ /// equality scope keys.
/// Reordering these keys would silently produce wrong results, which is
why
/// such joins are pinned to `PartitionMode::CollectLeft` (the only key
/// reorderer acts solely on `PartitionMode::Partitioned`).
@@ -2872,11 +2890,8 @@ async fn collect_left_input(
let schema = left_stream.schema();
// The extra scope maps + null bitmap are only built for correlated
- // null-aware LeftMark joins (`on_left[1..]` are correlation scope keys).
- let with_null_aware_mark_state = matches!(
- null_aware,
- Some(NullAwareMode::LeftMark { correlated: true })
- );
+ // null-aware joins (see `NullAwareMode`).
+ let with_null_aware_row_state =
null_aware.is_some_and(NullAwareMode::is_correlated);
let is_phj_candidate = is_perfect_hash_join_candidate(&on_left, &schema)?;
@@ -3006,42 +3021,42 @@ async fn collect_left_input(
BooleanBufferBuilder::new(0)
};
- let null_indices_bitmap = if with_null_aware_mark_state {
+ let null_indices_bitmap = if with_null_aware_row_state {
allocate_bitmap()?
} else {
BooleanBufferBuilder::new(0)
};
- let (null_aware_mark_scope_map, null_value_scope_map) = if
with_null_aware_mark_state
- {
- // Null-aware `LeftMark` convention: `on_left[0]` is the value key and
- // `on_left[1..]` the scope keys, so the scope map needs more than one
key.
- debug_assert!(
- on_left.len() > 1,
- "null-aware LeftMark needs on_left[0]=value, on_left[1..]=scope,
got {} key(s)",
- on_left.len()
- );
- // Scope-only NULL marking uses a HashMap (the primary join map may use
- // ArrayMap for full-key matches, but scope keys have arbitrary shape).
- let mut scope_map = new_join_hashmap(num_rows, &mut reservation,
&metrics)?;
-
- let mut hashes_buffer = vec![0; batch.num_rows()];
- update_hash(
- &on_left[1..],
- &batch,
- &mut *scope_map,
- 0,
- &random_state,
- &mut hashes_buffer,
- 0,
- true,
- NullEquality::NullEqualsNothing,
- )?;
+ let (null_aware_scope_map, null_value_build_rows) = if
with_null_aware_row_state {
+ // Null-aware convention: `on_left[0]` is the value key and
+ // `on_left[1..]` the (possibly empty) correlation scope keys.
+ let scope_keys = &on_left[1..];
+ let scope_map = if scope_keys.is_empty() {
+ None
+ } else {
+ // Scope-only NULL marking uses a HashMap (the primary join map may
+ // use ArrayMap for full-key matches, but scope keys have arbitrary
+ // shape).
+ let mut scope_map = new_join_hashmap(num_rows, &mut reservation,
&metrics)?;
+
+ let mut hashes_buffer = vec![0; batch.num_rows()];
+ update_hash(
+ scope_keys,
+ &batch,
+ &mut *scope_map,
+ 0,
+ &random_state,
+ &mut hashes_buffer,
+ 0,
+ true,
+ NullEquality::NullEqualsNothing,
+ )?;
+ Some(scope_map)
+ };
- // Build the dedicated scope map over the NULL-valued build rows (see
- // `NullValueScopeMap`).
+ // Collect the NULL-valued build rows (see `NullValueBuildRows`).
let value_key = &left_values[0];
- let null_value_scope_map = if value_key.null_count() > 0 {
+ let null_value_build_rows = if value_key.logical_null_count() > 0 {
let null_mask = arrow::compute::is_null(value_key.as_ref())?;
let build_indices = UInt64Array::from_iter_values(
null_mask.values().set_indices().map(|i| i as u64),
@@ -3062,14 +3077,19 @@ async fn collect_left_input(
reservation.try_grow(retained_size)?;
metrics.build_mem_used.add(retained_size);
- let null_rows = build_indices.len();
- let mut map = new_join_hashmap(null_rows, &mut reservation,
&metrics)?;
- let mut hashes_buffer = vec![0; null_rows];
- create_hashes(&scope_values, &random_state, &mut hashes_buffer)?;
-
map.update_from_iter(Box::new(hashes_buffer.iter().enumerate().rev()), 0);
+ let scope_map = if scope_values.is_empty() {
+ None
+ } else {
+ let null_rows = build_indices.len();
+ let mut map = new_join_hashmap(null_rows, &mut reservation,
&metrics)?;
+ let mut hashes_buffer = vec![0; null_rows];
+ create_hashes(&scope_values, &random_state, &mut
hashes_buffer)?;
+
map.update_from_iter(Box::new(hashes_buffer.iter().enumerate().rev()), 0);
+ Some(map)
+ };
- Some(NullValueScopeMap {
- map,
+ Some(NullValueBuildRows {
+ scope_map,
scope_values,
build_indices,
})
@@ -3077,7 +3097,7 @@ async fn collect_left_input(
None
};
- (Some(scope_map), null_value_scope_map)
+ (scope_map, null_value_build_rows)
} else {
(None, None)
};
@@ -3120,8 +3140,8 @@ async fn collect_left_input(
let data = JoinLeftData {
map,
- null_aware_mark_scope_map,
- null_value_scope_map,
+ null_aware_scope_map,
+ null_value_build_rows,
batch,
values: left_values,
visited_indices_bitmap: Mutex::new(visited_indices_bitmap),
@@ -8871,13 +8891,14 @@ mod tests {
),
];
- // Try to create null-aware anti join with 2 columns (should fail)
+ // Try to create null-aware right anti join with 2 columns (should
fail).
+ // A multi-column `LeftAnti` is a correlated `NOT IN` and is accepted.
let result = HashJoinExec::try_new(
left,
right,
on,
None,
- &JoinType::LeftAnti,
+ &JoinType::RightAnti,
None,
PartitionMode::CollectLeft,
NullEquality::NullEqualsNothing,
@@ -8887,7 +8908,7 @@ mod tests {
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains(
- "null_aware LeftAnti joins only support single column join key"
+ "null_aware RightAnti joins only support single column join
key"
)
);
}
@@ -9183,6 +9204,151 @@ mod tests {
Ok(())
}
+ /// `left.z > right.z` over the second column of two-column tables: the
+ /// non-equality correlation of
+ /// `id NOT IN (SELECT r.id FROM r WHERE r.z < l.z)`.
+ fn prepare_second_column_gt_filter() -> JoinFilter {
+ let column_indices = vec![
+ ColumnIndex {
+ index: 1,
+ side: JoinSide::Left,
+ },
+ ColumnIndex {
+ index: 1,
+ side: JoinSide::Right,
+ },
+ ];
+ let intermediate_schema = Schema::new(vec![
+ Field::new("z", DataType::Int32, true),
+ Field::new("z", DataType::Int32, true),
+ ]);
+ let filter_expression = Arc::new(BinaryExpr::new(
+ Arc::new(Column::new("z", 0)),
+ Operator::Gt,
+ Arc::new(Column::new("z", 1)),
+ )) as Arc<dyn PhysicalExpr>;
+
+ JoinFilter::new(
+ filter_expression,
+ column_indices,
+ Arc::new(intermediate_schema),
+ )
+ }
+
+ /// Build and probe sides of a null-aware join whose only correlation is
+ /// the non-equality filter from [`prepare_second_column_gt_filter`].
+ ///
+ /// For each build row, the probe rows with a smaller `z` form its
+ /// subquery result:
+ /// - `(1, 10)` and `(2, 20)`: `{1, NULL}`
+ /// - `(NULL, 30)`: `{1, NULL}`
+ /// - `(4, 40)`: `{1, 4, NULL}`
+ /// - `(NULL, 1)` and `(5, 1)`: empty
+ ///
+ /// The probe row `(NULL, 50)` never passes the filter.
+ fn build_null_aware_filter_only_inputs()
+ -> (Arc<dyn ExecutionPlan>, Arc<dyn ExecutionPlan>, JoinOn) {
+ let left = build_table_two_cols(
+ ("id", &vec![Some(1), Some(2), None, Some(4), None, Some(5)]),
+ (
+ "z",
+ &vec![Some(10), Some(20), Some(30), Some(40), Some(1),
Some(1)],
+ ),
+ );
+ let right = build_table_two_cols(
+ ("id", &vec![Some(1), None, Some(4), None]),
+ ("z", &vec![Some(5), Some(50), Some(35), Some(2)]),
+ );
+ let on = vec![(
+ Arc::new(Column::new_with_schema("id", &left.schema()).unwrap())
as _,
+ Arc::new(Column::new_with_schema("id", &right.schema()).unwrap())
as _,
+ )];
+ (left, right, on)
+ }
+
+ /// Null-aware `LeftAnti` with a join filter and no correlation scope keys.
+ ///
+ /// A NULL on either side only makes `NOT IN` UNKNOWN for the build rows
+ /// where the filter keeps the NULL, so the NULLs must not remove every
row.
+ #[apply(hash_join_exec_configs)]
+ #[tokio::test]
+ async fn test_null_aware_left_anti_filter_only(batch_size: usize) ->
Result<()> {
+ let task_ctx = prepare_task_ctx(batch_size, false);
+ let (left, right, on) = build_null_aware_filter_only_inputs();
+
+ let join = HashJoinExec::try_new(
+ left,
+ right,
+ on,
+ Some(prepare_second_column_gt_filter()),
+ &JoinType::LeftAnti,
+ None,
+ PartitionMode::CollectLeft,
+ NullEquality::NullEqualsNothing,
+ true,
+ )?;
+
+ let stream = join.execute(0, task_ctx)?;
+ let batches = common::collect(stream).await?;
+
+ // Only the rows with an empty subquery result are TRUE.
+ allow_duplicates! {
+ assert_snapshot!(batches_to_sort_string(&batches), @r"
+ +----+---+
+ | id | z |
+ +----+---+
+ | | 1 |
+ | 5 | 1 |
+ +----+---+
+ ");
+ }
+
+ Ok(())
+ }
+
+ /// Null-aware `LeftMark` with a join filter and no correlation scope keys.
+ #[apply(hash_join_exec_configs)]
+ #[tokio::test]
+ async fn test_null_aware_left_mark_filter_only(batch_size: usize) ->
Result<()> {
+ let task_ctx = prepare_task_ctx(batch_size, false);
+ let (left, right, on) = build_null_aware_filter_only_inputs();
+
+ let join = HashJoinExec::try_new(
+ left,
+ right,
+ on,
+ Some(prepare_second_column_gt_filter()),
+ &JoinType::LeftMark,
+ None,
+ PartitionMode::CollectLeft,
+ NullEquality::NullEqualsNothing,
+ true,
+ )?;
+
+ let stream = join.execute(0, task_ctx)?;
+ let batches = common::collect(stream).await?;
+
+ // `(1, 10)` and `(4, 40)` match (true); `(2, 20)` and `(NULL, 30)`
+ // keep the NULL probe row (UNKNOWN); `(NULL, 1)` and `(5, 1)` have an
+ // empty subquery result (false).
+ allow_duplicates! {
+ assert_snapshot!(batches_to_sort_string(&batches), @r"
+ +----+----+-------+
+ | id | z | mark |
+ +----+----+-------+
+ | | 1 | false |
+ | | 30 | |
+ | 1 | 10 | true |
+ | 2 | 20 | |
+ | 4 | 40 | true |
+ | 5 | 1 | false |
+ +----+----+-------+
+ ");
+ }
+
+ Ok(())
+ }
+
#[test]
fn test_lr_is_preserved() {
assert_eq!(lr_is_preserved(JoinType::Inner), (true, true));
diff --git a/datafusion/physical-plan/src/joins/hash_join/stream.rs
b/datafusion/physical-plan/src/joins/hash_join/stream.rs
index 7b70b2bf3b..56c34534fb 100644
--- a/datafusion/physical-plan/src/joins/hash_join/stream.rs
+++ b/datafusion/physical-plan/src/joins/hash_join/stream.rs
@@ -37,7 +37,6 @@ use crate::stream::EmptyRecordBatchStream;
use crate::{
RecordBatchStream, SendableRecordBatchStream, handle_state,
hash_utils::create_hashes,
- joins::SharedBitmapBuilder,
joins::utils::{
BuildProbeJoinMetrics, ColumnIndex, JoinFilter, JoinHashMapType,
StatefulStreamResult, adjust_indices_by_join_type,
apply_join_filter_to_indices,
@@ -581,8 +580,8 @@ impl HashJoinStream {
hashes_buffer,
probe_indices_buffer: Vec::with_capacity(batch_size),
build_indices_buffer: Vec::with_capacity(batch_size),
- // Left unallocated: only correlated null-aware LeftMark joins ever
- // use these, and they grow them on first use.
+ // Left unallocated: only correlated null-aware joins ever use
+ // these, and they grow them on first use.
null_mark_hashes_buffer: Vec::new(),
null_mark_probe_indices_buffer: Vec::new(),
null_mark_build_indices_buffer: Vec::new(),
@@ -837,29 +836,24 @@ impl HashJoinStream {
let timer = self.join_metrics.join_time.timer();
if let Some(mode) = self.null_aware {
- if null_aware_skip_probe_batch(
- mode,
- state,
- &build_side.left_data,
- self.filter.is_some(),
- ) {
+ if null_aware_skip_probe_batch(mode, state, &build_side.left_data)
{
timer.done();
self.state = HashJoinStreamState::FetchProbeBatch;
return Ok(StatefulStreamResult::Continue);
}
- // For correlated null-aware LeftMark, record this batch's UNKNOWN
+ // For correlated null-aware joins, record this batch's UNKNOWN
// candidates once, before the first chunked lookup
// (offset == (0, None)).
//
// Must precede the empty-build-map return below: an all-NULL-key
// build side has an empty full-key map but still needs UNKNOWN
marks.
- if matches!(mode, NullAwareMode::LeftMark { correlated: true })
- && state.offset == (0, None)
- {
+ if mode.is_correlated() && state.offset == (0, None) {
mark_null_candidates_for_probe_batch(
build_side,
state,
+ self.filter.as_ref(),
+ self.join_type,
&self.random_state,
self.batch_size,
&mut self.null_mark_hashes_buffer,
@@ -1126,18 +1120,20 @@ impl HashJoinStream {
// Null-aware joins post-process the build rows under SQL three-valued
// logic; see the helpers for the rules.
let (left_side, right_side, mark_column) = match self.null_aware {
- Some(NullAwareMode::LeftAnti) => {
+ Some(NullAwareMode::LeftAnti { correlated }) => {
let (left_side, right_side) =
null_aware_left_anti_final_indices(
&build_side.left_data,
+ correlated,
probe_summary,
left_side,
right_side,
);
(left_side, right_side, None)
}
- Some(NullAwareMode::LeftMark { .. }) => {
+ Some(NullAwareMode::LeftMark { correlated }) => {
let mark_column = null_aware_left_mark_column(
&build_side.left_data,
+ correlated,
probe_summary,
&left_side,
&right_side,
@@ -1243,24 +1239,28 @@ fn null_aware_skip_probe_batch(
mode: NullAwareMode,
state: &ProcessProbeBatchState,
left_data: &JoinLeftData,
- has_filter: bool,
) -> bool {
match mode {
NullAwareMode::RightAnti => left_data.build_side_has_null,
- NullAwareMode::LeftAnti | NullAwareMode::LeftMark { .. } => {
+ // Correlated joins decide UNKNOWN per build row instead, in
+ // `mark_null_candidates_for_probe_batch`.
+ NullAwareMode::LeftAnti { correlated: true }
+ | NullAwareMode::LeftMark { correlated: true } => false,
+ NullAwareMode::LeftAnti { correlated: false }
+ | NullAwareMode::LeftMark { correlated: false } => {
// `on[0]` is the `NOT IN` value key for both modes.
let probe_key_column = &state.values[0];
- let probe_has_null = match mode {
- NullAwareMode::LeftAnti if !has_filter => {
- probe_key_column.logical_null_count() > 0
- }
- _ => probe_key_column.null_count() > 0,
+ let is_anti = matches!(mode, NullAwareMode::LeftAnti { .. });
+ let probe_has_null = if is_anti {
+ probe_key_column.logical_null_count() > 0
+ } else {
+ probe_key_column.null_count() > 0
};
// Only batches with rows count: `NULL NOT IN (empty)` is TRUE.
left_data.record_probe_batch(state.batch.num_rows() > 0,
probe_has_null);
// Best-effort early exit; the final stage re-checks the flag
// through `report_probe_completed`.
- mode == NullAwareMode::LeftAnti &&
left_data.probe_side_has_null_hint()
+ is_anti && left_data.probe_side_has_null_hint()
}
}
}
@@ -1291,7 +1291,12 @@ fn drop_null_probe_keys(
}
/// Final-stage rules of a null-aware `LeftAnti` join, evaluated by the last
-/// probe partition from what every partition together saw:
+/// probe partition.
+///
+/// A correlated join (see `NullAwareMode`) drops the unmatched build rows
+/// whose `NOT IN` was marked UNKNOWN in the null-indices bitmap.
+///
+/// Otherwise the rules use what every partition together saw:
/// - a NULL probe key seen by any partition makes `build.key NOT IN (probe)`
/// UNKNOWN for every build row, so nothing is emitted;
/// - otherwise a NULL build key means `NULL NOT IN (probe)`, which is UNKNOWN
@@ -1299,10 +1304,23 @@ fn drop_null_probe_keys(
/// kept).
fn null_aware_left_anti_final_indices(
left_data: &JoinLeftData,
+ correlated: bool,
probe_summary: ProbeSideSummary,
left_side: UInt64Array,
right_side: UInt32Array,
) -> (UInt64Array, UInt32Array) {
+ if correlated {
+ let null_indices_bitmap = left_data.null_indices_bitmap().lock();
+ let left_side = UInt64Array::from_iter_values(
+ left_side
+ .values()
+ .iter()
+ .copied()
+ .filter(|idx| !null_indices_bitmap.get_bit(*idx as usize)),
+ );
+ let right_side = UInt32Array::new_null(left_side.len());
+ return (left_side, right_side);
+ }
if probe_summary.has_null {
return (UInt64Array::new_null(0), UInt32Array::new_null(0));
}
@@ -1324,16 +1342,14 @@ fn null_aware_left_anti_final_indices(
/// final indices and what every probe partition together saw.
fn null_aware_left_mark_column(
left_data: &JoinLeftData,
+ correlated: bool,
probe_summary: ProbeSideSummary,
left_side: &UInt64Array,
right_side: &UInt32Array,
) -> ArrayRef {
let build_key_column = &left_data.values()[0];
// Correlated joins precomputed the UNKNOWN decision per build row.
- let null_indices_bitmap = left_data
- .null_aware_mark_scope_map()
- .is_some()
- .then(|| left_data.null_indices_bitmap().lock());
+ let null_indices_bitmap = correlated.then(||
left_data.null_indices_bitmap().lock());
build_null_aware_left_mark_column(
left_side,
right_side,
@@ -1344,104 +1360,192 @@ fn null_aware_left_mark_column(
)
}
-/// Records which build rows of a correlated null-aware `LeftMark` join are
-/// UNKNOWN candidates for this probe batch.
+/// Records which build rows of a correlated null-aware join are UNKNOWN
+/// candidates for this probe batch.
///
-/// Key layout: `on[0]` is the `NOT IN` value key, `on[1..]` the correlation
-/// scope keys (see `HashJoinExec::null_aware`). A build row's mark must be
-/// NULL (SQL UNKNOWN) instead of FALSE when it is unmatched and either:
-/// 1. its value key is NULL and any probe row shares its correlation scope, or
-/// 2. some probe row in its correlation scope has a NULL value key.
+/// Key layout: `on[0]` is the `NOT IN` value key, `on[1..]` the (possibly
+/// empty) correlation scope keys (see `HashJoinExec::null_aware`). An
+/// unmatched build row's `NOT IN` is UNKNOWN instead of TRUE (its mark is NULL
+/// instead of FALSE) when either:
+/// 1. its value key is NULL and any probe row in its correlation scope passes
+/// the join filter, or
+/// 2. some probe row in its correlation scope with a NULL value key passes the
+/// join filter.
///
-/// Case 1 probes the build-side NULL-value scope map with all probe rows;
-/// case 2 probes the full scope map with only the NULL-valued probe rows.
+/// Case 1 pairs the NULL-valued build rows with all probe rows; case 2 pairs
+/// all build rows with the NULL-valued probe rows. Scope keys narrow these
+/// pairs through a hash lookup; without scope keys every pair is a candidate.
+/// The join filter, if any, then decides which candidates count.
+///
+/// A build row stays UNKNOWN once it is marked, so candidates whose build row
+/// is already marked are skipped, and the join filter is not evaluated for
+/// them. Without scope keys this also ends the pairing as soon as no unmarked
+/// build row is left.
+#[expect(clippy::too_many_arguments)]
fn mark_null_candidates_for_probe_batch(
build_side: &BuildSideReadyState,
state: &ProcessProbeBatchState,
+ filter: Option<&JoinFilter>,
+ join_type: JoinType,
random_state: &RandomState,
batch_size: usize,
hashes_buffer: &mut Vec<u64>,
probe_indices_buffer: &mut Vec<u32>,
build_indices_buffer: &mut Vec<u64>,
) -> Result<()> {
- let Some(scope_map) = build_side.left_data.null_aware_mark_scope_map()
else {
+ let left_data = &build_side.left_data;
+ let null_value_build_rows = left_data.null_value_build_rows();
+ let probe_value_key = &state.values[0];
+ let probe_has_null_values = probe_value_key.logical_null_count() > 0;
+ if null_value_build_rows.is_none() && !probe_has_null_values {
return Ok(());
- };
+ }
debug_assert_eq!(
- build_side.left_data.values().len(),
+ left_data.values().len(),
state.values.len(),
"build/probe key counts must match"
);
- debug_assert!(state.values.len() > 1, "keys must be [value, scope..]");
-
- let probe_value_key = &state.values[0];
- let build_scope_values = &build_side.left_data.values()[1..];
+ let build_scope_values = &left_data.values()[1..];
let probe_scope_values = &state.values[1..];
- let null_value_scope_map = build_side.left_data.null_value_scope_map();
- let probe_has_null_values = probe_value_key.null_count() > 0;
- if null_value_scope_map.is_none() && !probe_has_null_values {
- return Ok(());
- }
+ // Keeps the candidate pairs that pass the join filter and marks their
+ // build rows as UNKNOWN.
+ let mut mark = |build_indices: UInt64Array, probe_indices: UInt32Array| {
+ let (build_indices, probe_indices) =
+ retain_unmarked(left_data, build_indices, probe_indices);
+ if build_indices.is_empty() {
+ return Ok(());
+ }
+ let build_indices = match filter {
+ Some(filter) => {
+ apply_join_filter_to_indices(
+ left_data.batch(),
+ &state.batch,
+ build_indices,
+ probe_indices,
+ filter,
+ JoinSide::Left,
+ None,
+ join_type,
+ )?
+ .0
+ }
+ None => build_indices,
+ };
+ if !build_indices.is_empty() {
+ let mut null_bitmap = left_data.null_indices_bitmap().lock();
+ for build_idx in build_indices.values() {
+ null_bitmap.set_bit(*build_idx as usize, true);
+ }
+ }
+ Ok(())
+ };
// Case 1: build rows with a NULL value key are UNKNOWN as soon as any
- // probe row shares their correlation scope.
- if let Some(null_value_scope_map) = null_value_scope_map {
- hashes_buffer.clear();
- hashes_buffer.resize(state.batch.num_rows(), 0);
- create_hashes(probe_scope_values, random_state, hashes_buffer)?;
-
- scan_scope_matches_into_bitmap(
- null_value_scope_map.map.as_ref(),
- &null_value_scope_map.scope_values,
- probe_scope_values,
- hashes_buffer,
- batch_size,
- probe_indices_buffer,
- build_indices_buffer,
- // The map indexes only the NULL-valued build rows; translate its
- // positions back to row indices in the full build batch.
- |position| null_value_scope_map.build_indices.value(position as
usize),
- build_side.left_data.null_indices_bitmap(),
- )?;
+ // probe row in their correlation scope passes the filter.
+ if let Some(null_rows) = null_value_build_rows {
+ match &null_rows.scope_map {
+ Some(scope_map) => {
+ hashes_buffer.clear();
+ hashes_buffer.resize(state.batch.num_rows(), 0);
+ create_hashes(probe_scope_values, random_state,
hashes_buffer)?;
+
+ for_each_scope_match(
+ scope_map.as_ref(),
+ &null_rows.scope_values,
+ probe_scope_values,
+ hashes_buffer,
+ batch_size,
+ probe_indices_buffer,
+ build_indices_buffer,
+ |positions, probe_indices| {
+ // The map indexes only the NULL-valued build rows;
+ // translate its positions back to build row indices.
+ let build_indices = UInt64Array::from_iter_values(
+ positions
+ .values()
+ .iter()
+ .map(|p| null_rows.build_indices.value(*p as
usize)),
+ );
+ mark(build_indices, probe_indices)
+ },
+ )?;
+ }
+ None => {
+ for_each_unmarked_cross_product(
+ left_data,
+ null_rows.build_indices.values().iter().copied(),
+ 0..state.batch.num_rows() as u32,
+ batch_size,
+ &mut mark,
+ )?;
+ }
+ }
}
// Case 2: NULL-valued probe rows make every build row in their correlation
- // scope an UNKNOWN candidate.
+ // scope that passes the filter an UNKNOWN candidate.
if probe_has_null_values {
let null_mask = arrow::compute::is_null(probe_value_key.as_ref())?;
- let probe_null_scope_values = probe_scope_values
- .iter()
- .map(|values| Ok(arrow::compute::filter(values.as_ref(),
&null_mask)?))
- .collect::<Result<Vec<_>>>()?;
-
- hashes_buffer.clear();
- hashes_buffer.resize(null_mask.true_count(), 0);
- create_hashes(&probe_null_scope_values, random_state, hashes_buffer)?;
+ let null_probe_rows = UInt32Array::from_iter_values(
+ null_mask.values().set_indices().map(|i| i as u32),
+ );
- scan_scope_matches_into_bitmap(
- scope_map,
- build_scope_values,
- &probe_null_scope_values,
- hashes_buffer,
- batch_size,
- probe_indices_buffer,
- build_indices_buffer,
- |position| position,
- build_side.left_data.null_indices_bitmap(),
- )?;
+ match left_data.null_aware_scope_map() {
+ Some(scope_map) => {
+ let probe_null_scope_values = probe_scope_values
+ .iter()
+ .map(|values| {
+ Ok(arrow::compute::filter(values.as_ref(),
&null_mask)?)
+ })
+ .collect::<Result<Vec<_>>>()?;
+
+ hashes_buffer.clear();
+ hashes_buffer.resize(null_probe_rows.len(), 0);
+ create_hashes(&probe_null_scope_values, random_state,
hashes_buffer)?;
+
+ for_each_scope_match(
+ scope_map,
+ build_scope_values,
+ &probe_null_scope_values,
+ hashes_buffer,
+ batch_size,
+ probe_indices_buffer,
+ build_indices_buffer,
+ |build_indices, positions| {
+ // The lookup ran over only the NULL-valued probe rows;
+ // translate its positions back to probe row indices.
+ let probe_indices = UInt32Array::from_iter_values(
+ positions
+ .values()
+ .iter()
+ .map(|p| null_probe_rows.value(*p as usize)),
+ );
+ mark(build_indices, probe_indices)
+ },
+ )?;
+ }
+ None => {
+ for_each_unmarked_cross_product(
+ left_data,
+ 0..left_data.batch().num_rows() as u64,
+ null_probe_rows.values().iter().copied(),
+ batch_size,
+ &mut mark,
+ )?;
+ }
+ }
}
Ok(())
}
-/// Scans all correlation-scope matches between `build_scope_values` and
-/// `probe_scope_values` and sets the bit of every matched build row in
-/// `null_bitmap`, translating matched map positions through
-/// `map_position_to_build_row`.
+/// Calls `f` with all correlation-scope matches between `build_scope_values`
+/// and `probe_scope_values`, as chunks of at most `batch_size` pairs of
+/// (position in `build_scope_values`, position in `probe_scope_values`).
#[expect(clippy::too_many_arguments)]
-fn scan_scope_matches_into_bitmap(
+fn for_each_scope_match(
scope_map: &dyn JoinHashMapType,
build_scope_values: &[ArrayRef],
probe_scope_values: &[ArrayRef],
@@ -1449,12 +1553,11 @@ fn scan_scope_matches_into_bitmap(
batch_size: usize,
probe_indices_buffer: &mut Vec<u32>,
build_indices_buffer: &mut Vec<u64>,
- map_position_to_build_row: impl Fn(u64) -> u64,
- null_bitmap: &SharedBitmapBuilder,
+ mut f: impl FnMut(UInt64Array, UInt32Array) -> Result<()>,
) -> Result<()> {
let mut offset = (0, None);
loop {
- let (build_indices, _probe_indices, next_offset) = lookup_join_hashmap(
+ let (build_indices, probe_indices, next_offset) = lookup_join_hashmap(
scope_map,
build_scope_values,
probe_scope_values,
@@ -1468,13 +1571,7 @@ fn scan_scope_matches_into_bitmap(
)?;
if !build_indices.is_empty() {
- let mut null_bitmap = null_bitmap.lock();
-
- for build_idx in build_indices.iter() {
- let build_idx = build_idx
- .expect("scope lookup should produce non-null build
indices");
- null_bitmap.set_bit(map_position_to_build_row(build_idx) as
usize, true);
- }
+ f(build_indices, probe_indices)?;
}
let Some(next_offset) = next_offset else {
@@ -1486,6 +1583,79 @@ fn scan_scope_matches_into_bitmap(
Ok(())
}
+/// Removes the candidate pairs whose build row is already marked UNKNOWN.
+fn retain_unmarked(
+ left_data: &JoinLeftData,
+ build_indices: UInt64Array,
+ probe_indices: UInt32Array,
+) -> (UInt64Array, UInt32Array) {
+ let bitmap = left_data.null_indices_bitmap().lock();
+ let is_unmarked = |build_idx: &u64| !bitmap.get_bit(*build_idx as usize);
+ if build_indices.values().iter().all(is_unmarked) {
+ return (build_indices, probe_indices);
+ }
+ let (build, probe): (Vec<u64>, Vec<u32>) = build_indices
+ .values()
+ .iter()
+ .zip(probe_indices.values().iter())
+ .filter(|(build_idx, _)| is_unmarked(build_idx))
+ .unzip();
+ (build.into(), probe.into())
+}
+
+/// Calls `f` with the pairs of `build_rows` x `probe_rows` whose build row is
+/// not marked UNKNOWN, as chunks of at most `batch_size` pairs.
+///
+/// `f` marks build rows, so the unmarked build rows are found again after each
+/// chunk. The pairing stops when no unmarked build row is left.
+fn for_each_unmarked_cross_product(
+ left_data: &JoinLeftData,
+ build_rows: impl Iterator<Item = u64>,
+ probe_rows: impl Iterator<Item = u32>,
+ batch_size: usize,
+ mut f: impl FnMut(UInt64Array, UInt32Array) -> Result<()>,
+) -> Result<()> {
+ let retain_unmarked_rows = |rows: &mut Vec<u64>| {
+ let bitmap = left_data.null_indices_bitmap().lock();
+ rows.retain(|idx| !bitmap.get_bit(*idx as usize));
+ };
+
+ let mut build_rows: Vec<u64> = build_rows.collect();
+ retain_unmarked_rows(&mut build_rows);
+
+ let mut build_chunk = Vec::with_capacity(batch_size);
+ let mut probe_chunk = Vec::with_capacity(batch_size);
+ let mut marks_since_refresh = false;
+ for probe_row in probe_rows {
+ // Refresh only after a chunk was sent, so the cost of the refresh
+ // stays proportional to the pairs already evaluated.
+ if marks_since_refresh {
+ retain_unmarked_rows(&mut build_rows);
+ marks_since_refresh = false;
+ }
+ if build_rows.is_empty() {
+ break;
+ }
+ for build_row in &build_rows {
+ build_chunk.push(*build_row);
+ probe_chunk.push(probe_row);
+ if build_chunk.len() == batch_size {
+ f(
+ std::mem::replace(&mut build_chunk,
Vec::with_capacity(batch_size))
+ .into(),
+ std::mem::replace(&mut probe_chunk,
Vec::with_capacity(batch_size))
+ .into(),
+ )?;
+ marks_since_refresh = true;
+ }
+ }
+ }
+ if !build_chunk.is_empty() {
+ f(build_chunk.into(), probe_chunk.into())?;
+ }
+ Ok(())
+}
+
impl Stream for HashJoinStream {
type Item = Result<RecordBatch>;
diff --git a/datafusion/sqllogictest/test_files/null_aware_anti_join.slt
b/datafusion/sqllogictest/test_files/null_aware_anti_join.slt
index 4feea136d9..050f223f9b 100644
--- a/datafusion/sqllogictest/test_files/null_aware_anti_join.slt
+++ b/datafusion/sqllogictest/test_files/null_aware_anti_join.slt
@@ -400,8 +400,10 @@ SELECT * FROM test_table WHERE (c1 NOT IN (SELECT c2 FROM
test_table)) = true;
# The correlated subquery version from issue #10583 creates a multi-column join
# (correlation + NOT IN condition). It is supported now; see the correlated
# sections below.
-query error DataFusion error: Error during planning: null_aware LeftAnti joins
only support single column join key, got 2 columns
+query II rowsort
SELECT * FROM test_table t1 WHERE c1 NOT IN (SELECT c2 FROM test_table t2
WHERE t1.c1 = t2.c1);
+----
+NULL 0
#############
## Cleanup
@@ -739,17 +741,17 @@ statement ok
CREATE TABLE nai_res_t2(id INT, z INT) AS VALUES (1,5), (NULL,50);
# The NULL row (z = 50) never passes `t2.z < t1.z`, so it does not affect the
result.
-# Pinned to today's behaviour, which is incorrect. See
-# https://github.com/apache/datafusion/issues/25336 -- the fix flips this.
query I
SELECT id FROM nai_res_t1 WHERE id NOT IN (SELECT nai_res_t2.id FROM
nai_res_t2 WHERE nai_res_t2.z < nai_res_t1.z) ORDER BY id;
----
+2
+4
-# Pinned to today's behaviour, which is incorrect. See
-# https://github.com/apache/datafusion/issues/25336 -- the fix flips this.
query I
SELECT id FROM nai_res_t1 WHERE NOT (id IN (SELECT nai_res_t2.id FROM
nai_res_t2 WHERE nai_res_t2.z < nai_res_t1.z)) ORDER BY id;
----
+2
+4
query I
SELECT id FROM nai_res_t1 WHERE id IN (SELECT nai_res_t2.id FROM nai_res_t2
WHERE nai_res_t2.z < nai_res_t1.z) ORDER BY id;
@@ -790,49 +792,58 @@ CREATE TABLE nai_res_inner(id INT, z INT, g INT) AS VALUES
# Per outer row, the residual decides which inner rows (NULL or not) are in
# the subquery. A NULL outer value is TRUE only when that set is empty.
-# Pinned to today's behaviour, which is incorrect. See
-# https://github.com/apache/datafusion/issues/25336 -- the fix flips this.
query II rowsort
SELECT id, z FROM nai_res_outer
WHERE id NOT IN (SELECT i.id FROM nai_res_inner i WHERE i.z < nai_res_outer.z);
----
+5 1
+NULL 1
query II rowsort
SELECT id, z FROM nai_res_outer
WHERE id NOT IN (SELECT i.id FROM nai_res_inner i WHERE i.z > nai_res_outer.z);
----
-# Pinned to today's behaviour, which is incorrect. See
-# https://github.com/apache/datafusion/issues/25336 -- the fix flips this.
query II rowsort
SELECT id, z FROM nai_res_outer
WHERE id NOT IN (SELECT i.id FROM nai_res_inner i WHERE i.z < nai_res_outer.z
AND i.z > 3);
----
+2 20
+5 1
+NULL 1
-# Pinned to today's behaviour, which is incorrect. See
-# https://github.com/apache/datafusion/issues/25336 -- the fix flips this.
query II rowsort
SELECT id, z FROM nai_res_outer
WHERE id + 0 NOT IN (SELECT i.id FROM nai_res_inner i WHERE i.z <
nai_res_outer.z);
----
+5 1
+NULL 1
# The residual references the subquery value itself.
-# Pinned to today's behaviour, which is incorrect. See
-# https://github.com/apache/datafusion/issues/25336 -- the fix flips this.
query II rowsort
SELECT id, z FROM nai_res_outer
WHERE id NOT IN (SELECT i.id FROM nai_res_inner i WHERE i.id + i.z >
nai_res_outer.z);
----
+1 10
+2 20
+4 40
+5 1
# Equality and non-equality correlation together.
-query error DataFusion error: Error during planning: null_aware LeftAnti joins
only support single column join key, got 2 columns
+query II rowsort
SELECT id, z FROM nai_res_outer
WHERE id NOT IN (SELECT i.id FROM nai_res_inner i WHERE i.g = nai_res_outer.g
AND i.z < nai_res_outer.z);
+----
+2 20
+5 1
+NULL 1
# Equality correlation only.
-query error DataFusion error: Error during planning: null_aware LeftAnti joins
only support single column join key, got 2 columns
+query II rowsort
SELECT id, z FROM nai_res_outer
WHERE id NOT IN (SELECT i.id FROM nai_res_inner i WHERE i.g = nai_res_outer.g);
+----
+5 1
# NOT EXISTS uses two-valued logic and must not change.
query II rowsort
@@ -849,16 +860,20 @@ NULL 30
statement ok
SET datafusion.execution.batch_size = 1;
-# Pinned to today's behaviour, which is incorrect. See
-# https://github.com/apache/datafusion/issues/25336 -- the fix flips this.
query II rowsort
SELECT id, z FROM nai_res_outer
WHERE id NOT IN (SELECT i.id FROM nai_res_inner i WHERE i.z < nai_res_outer.z);
----
+5 1
+NULL 1
-query error DataFusion error: Error during planning: null_aware LeftAnti joins
only support single column join key, got 2 columns
+query II rowsort
SELECT id, z FROM nai_res_outer
WHERE id NOT IN (SELECT i.id FROM nai_res_inner i WHERE i.g = nai_res_outer.g
AND i.z < nai_res_outer.z);
+----
+2 20
+5 1
+NULL 1
statement ok
RESET datafusion.execution.batch_size;
@@ -882,6 +897,9 @@ CREATE TABLE nai_res_ig(id INT) AS VALUES (1), (NULL);
query II
SELECT id, g FROM nai_res_og WHERE nai_res_og.id NOT IN (SELECT i.id FROM
nai_res_ig i WHERE nai_res_og.g > 0) ORDER BY id;
----
+3 0
+4 NULL
+NULL 0
# The shape must stay a single anti join that carries the correlation as a
# filter. The alternative lowering materializes the mark of three joins, which
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]