@@ -33,7 +33,9 @@ use crate::joins::hash_join::partitioned_hash_eval::{
3333use arrow:: array:: ArrayRef ;
3434use arrow:: datatypes:: { DataType , Field , Schema } ;
3535use datafusion_common:: config:: ConfigOptions ;
36- use datafusion_common:: { DataFusionError , Result , ScalarValue , SharedResult } ;
36+ use datafusion_common:: {
37+ DataFusionError , NullEquality , Result , ScalarValue , SharedResult ,
38+ } ;
3739use datafusion_expr:: Operator ;
3840use datafusion_functions:: core:: r#struct as struct_func;
3941use datafusion_physical_expr:: expressions:: {
@@ -255,6 +257,9 @@ pub(crate) struct SharedBuildAccumulator {
255257 repartition_random_state : SeededRandomState ,
256258 /// Schema of the probe (right) side for evaluating filter expressions
257259 probe_schema : Arc < Schema > ,
260+ /// Null equality of the join. Under `NullEqualsNull` a probe-side NULL can match a
261+ /// build-side NULL, so the pushed filter must keep NULL rows here too.
262+ null_equality : NullEquality ,
258263 /// Null-aware anti join (`NOT IN`). A probe-side NULL must reach the join so its
259264 /// three-valued logic can collapse the result, so the pushed filter keeps NULL rows.
260265 null_aware : bool ,
@@ -361,6 +366,7 @@ impl SharedBuildAccumulator {
361366 dynamic_filter : Arc < DynamicFilterPhysicalExpr > ,
362367 on_right : Vec < PhysicalExprRef > ,
363368 repartition_random_state : SeededRandomState ,
369+ null_equality : NullEquality ,
364370 null_aware : bool ,
365371 ) -> Self {
366372 // Troubleshooting: If partition counts are incorrect, verify this logic matches
@@ -408,6 +414,7 @@ impl SharedBuildAccumulator {
408414 on_right,
409415 repartition_random_state,
410416 probe_schema : right_child. schema ( ) ,
417+ null_equality,
411418 null_aware,
412419 }
413420 }
@@ -585,7 +592,7 @@ impl SharedBuildAccumulator {
585592 combine_membership_and_bounds ( membership_expr, bounds_expr)
586593 {
587594 self . dynamic_filter
588- . update ( self . null_aware_filter ( filter_expr) ) ?;
595+ . update ( self . preserve_probe_nulls ( filter_expr) ) ?;
589596 }
590597 }
591598 PartitionStatus :: Pending => {
@@ -692,33 +699,47 @@ impl SharedBuildAccumulator {
692699 } ;
693700
694701 self . dynamic_filter
695- . update ( self . null_aware_filter ( filter_expr) ) ?;
702+ . update ( self . preserve_probe_nulls ( filter_expr) ) ?;
696703 }
697704 }
698705
699706 Ok ( ( ) )
700707 }
701708
702- /// Wraps a pushdown filter so a null-aware anti join keeps its probe-side NULL rows .
709+ /// Keeps probe rows with a NULL key when the join semantics need them .
703710 ///
704- /// The build-side predicate drops probe rows whose key is NULL, but `NOT IN` three-valued
705- /// logic needs that NULL to reach the join. OR-ing `probe_key IS NULL` preserves the dynamic
706- /// filter's selectivity for non-NULL rows while letting the NULL through.
707- fn null_aware_filter (
711+ /// The build-side predicate drops probe rows whose key is NULL. A null-aware anti join
712+ /// (`NOT IN`) needs that NULL to reach the join so three-valued logic can collapse the
713+ /// result, and a null-equal join needs it to match a build-side NULL. OR-ing `key IS NULL`
714+ /// keeps those rows while preserving the filter's selectivity for the rest; the join refines
715+ /// whatever the widened filter lets through.
716+ fn preserve_probe_nulls (
708717 & self ,
709718 filter_expr : Arc < dyn PhysicalExpr > ,
710719 ) -> Arc < dyn PhysicalExpr > {
711- if !self . null_aware {
720+ if self . null_equality != NullEquality :: NullEqualsNull && !self . null_aware {
712721 return filter_expr;
713722 }
714- // A null-aware anti join is validated to a single probe key.
715- let probe_key_is_null: Arc < dyn PhysicalExpr > =
716- Arc :: new ( IsNullExpr :: new ( Arc :: clone ( & self . on_right [ 0 ] ) ) ) ;
717- Arc :: new ( BinaryExpr :: new (
718- filter_expr,
719- Operator :: Or ,
720- probe_key_is_null,
721- ) )
723+ // Only a key that can actually be NULL needs the disjunct; a NOT NULL key never widens.
724+ // Null-aware joins are single-key; null-equal joins can be multi-key, so OR every nullable
725+ // key. If every key is NOT NULL the filter is left untouched, at full selectivity.
726+ let any_key_is_null = self
727+ . on_right
728+ . iter ( )
729+ . filter ( |key| key. nullable ( & self . probe_schema ) . unwrap_or ( true ) )
730+ . map ( |key| {
731+ Arc :: new ( IsNullExpr :: new ( Arc :: clone ( key) ) ) as Arc < dyn PhysicalExpr >
732+ } )
733+ . reduce ( |acc, is_null| {
734+ Arc :: new ( BinaryExpr :: new ( acc, Operator :: Or , is_null) )
735+ as Arc < dyn PhysicalExpr >
736+ } ) ;
737+ match any_key_is_null {
738+ Some ( any_key_is_null) => {
739+ Arc :: new ( BinaryExpr :: new ( filter_expr, Operator :: Or , any_key_is_null) )
740+ }
741+ None => filter_expr,
742+ }
722743 }
723744}
724745
@@ -751,6 +772,7 @@ pub(super) fn make_partitioned_accumulator_for_test(
751772 on_right : vec ! [ ] ,
752773 repartition_random_state : SeededRandomState :: with_seed ( 1 ) ,
753774 probe_schema,
775+ null_equality : NullEquality :: NullEqualsNothing ,
754776 null_aware : false ,
755777 }
756778}
@@ -771,6 +793,7 @@ pub(super) fn completed_partitions_for_test(acc: &SharedBuildAccumulator) -> usi
771793#[ cfg( test) ]
772794mod tests {
773795 use super :: * ;
796+ use datafusion_physical_expr:: expressions:: Column ;
774797
775798 fn partitioned_state ( acc : & SharedBuildAccumulator ) -> ( Vec < PartitionStatus > , usize ) {
776799 let guard = acc. inner . lock ( ) ;
@@ -840,4 +863,59 @@ mod tests {
840863 assert ! ( matches!( partitions[ 0 ] , PartitionStatus :: CanceledUnknown ) ) ;
841864 assert_eq ! ( completed, 1 ) ;
842865 }
866+
867+ fn null_equal_accumulator (
868+ probe_schema : Arc < Schema > ,
869+ on_right : Vec < PhysicalExprRef > ,
870+ ) -> SharedBuildAccumulator {
871+ SharedBuildAccumulator {
872+ inner : Mutex :: new ( AccumulatorState {
873+ data : AccumulatedBuildData :: Partitioned {
874+ partitions : vec ! [ PartitionStatus :: Pending ; 1 ] ,
875+ completed_partitions : 0 ,
876+ } ,
877+ completion : CompletionState :: Pending ,
878+ } ) ,
879+ completion_notify : Notify :: new ( ) ,
880+ dynamic_filter : Arc :: new ( DynamicFilterPhysicalExpr :: new ( vec ! [ ] , lit ( true ) ) ) ,
881+ on_right,
882+ repartition_random_state : SeededRandomState :: with_seed ( 1 ) ,
883+ probe_schema,
884+ null_equality : NullEquality :: NullEqualsNull ,
885+ null_aware : false ,
886+ }
887+ }
888+
889+ #[ test]
890+ fn preserve_probe_nulls_only_widens_nullable_keys ( ) {
891+ let probe_schema = Arc :: new ( Schema :: new ( vec ! [
892+ Field :: new( "k_nullable" , DataType :: Int32 , true ) ,
893+ Field :: new( "k_not_null" , DataType :: Int32 , false ) ,
894+ ] ) ) ;
895+ let on_right: Vec < PhysicalExprRef > = vec ! [
896+ Arc :: new( Column :: new( "k_nullable" , 0 ) ) ,
897+ Arc :: new( Column :: new( "k_not_null" , 1 ) ) ,
898+ ] ;
899+ let acc = null_equal_accumulator ( probe_schema, on_right) ;
900+
901+ // Only the nullable key earns an IS NULL disjunct; the NOT NULL key is left out.
902+ let widened = acc. preserve_probe_nulls ( lit ( true ) ) ;
903+ assert_eq ! ( format!( "{widened}" ) . matches( "IS NULL" ) . count( ) , 1 ) ;
904+ }
905+
906+ #[ test]
907+ fn preserve_probe_nulls_leaves_all_not_null_keys_untouched ( ) {
908+ let probe_schema = Arc :: new ( Schema :: new ( vec ! [
909+ Field :: new( "a" , DataType :: Int32 , false ) ,
910+ Field :: new( "b" , DataType :: Int32 , false ) ,
911+ ] ) ) ;
912+ let on_right: Vec < PhysicalExprRef > =
913+ vec ! [ Arc :: new( Column :: new( "a" , 0 ) ) , Arc :: new( Column :: new( "b" , 1 ) ) ] ;
914+ let acc = null_equal_accumulator ( probe_schema, on_right) ;
915+
916+ // Every key is NOT NULL, so there is nothing to OR in and the filter is returned as-is.
917+ let filter = lit ( true ) ;
918+ let result = acc. preserve_probe_nulls ( Arc :: clone ( & filter) ) ;
919+ assert_eq ! ( format!( "{result}" ) , format!( "{filter}" ) ) ;
920+ }
843921}
0 commit comments