@@ -37,7 +37,7 @@ use datafusion_common::{DataFusionError, Result, ScalarValue, SharedResult};
3737use datafusion_expr:: Operator ;
3838use datafusion_functions:: core:: r#struct as struct_func;
3939use datafusion_physical_expr:: expressions:: {
40- BinaryExpr , CaseExpr , DynamicFilterPhysicalExpr , InListExpr , lit,
40+ BinaryExpr , CaseExpr , DynamicFilterPhysicalExpr , InListExpr , IsNullExpr , lit,
4141} ;
4242use datafusion_physical_expr:: { PhysicalExpr , PhysicalExprRef , ScalarFunctionExpr } ;
4343
@@ -255,6 +255,9 @@ pub(crate) struct SharedBuildAccumulator {
255255 repartition_random_state : SeededRandomState ,
256256 /// Schema of the probe (right) side for evaluating filter expressions
257257 probe_schema : Arc < Schema > ,
258+ /// Null-aware anti join (`NOT IN`). A probe-side NULL must reach the join so its
259+ /// three-valued logic can collapse the result, so the pushed filter keeps NULL rows.
260+ null_aware : bool ,
258261}
259262
260263/// Strategy for filter pushdown (decided at collection time)
@@ -358,6 +361,7 @@ impl SharedBuildAccumulator {
358361 dynamic_filter : Arc < DynamicFilterPhysicalExpr > ,
359362 on_right : Vec < PhysicalExprRef > ,
360363 repartition_random_state : SeededRandomState ,
364+ null_aware : bool ,
361365 ) -> Self {
362366 // Troubleshooting: If partition counts are incorrect, verify this logic matches
363367 // the actual execution pattern in collect_build_side()
@@ -404,6 +408,7 @@ impl SharedBuildAccumulator {
404408 on_right,
405409 repartition_random_state,
406410 probe_schema : right_child. schema ( ) ,
411+ null_aware,
407412 }
408413 }
409414
@@ -579,7 +584,8 @@ impl SharedBuildAccumulator {
579584 if let Some ( filter_expr) =
580585 combine_membership_and_bounds ( membership_expr, bounds_expr)
581586 {
582- self . dynamic_filter . update ( filter_expr) ?;
587+ self . dynamic_filter
588+ . update ( self . null_aware_filter ( filter_expr) ) ?;
583589 }
584590 }
585591 PartitionStatus :: Pending => {
@@ -685,12 +691,35 @@ impl SharedBuildAccumulator {
685691 ) ?) as Arc < dyn PhysicalExpr >
686692 } ;
687693
688- self . dynamic_filter . update ( filter_expr) ?;
694+ self . dynamic_filter
695+ . update ( self . null_aware_filter ( filter_expr) ) ?;
689696 }
690697 }
691698
692699 Ok ( ( ) )
693700 }
701+
702+ /// Wraps a pushdown filter so a null-aware anti join keeps its probe-side NULL rows.
703+ ///
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 (
708+ & self ,
709+ filter_expr : Arc < dyn PhysicalExpr > ,
710+ ) -> Arc < dyn PhysicalExpr > {
711+ if !self . null_aware {
712+ return filter_expr;
713+ }
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+ ) )
722+ }
694723}
695724
696725impl fmt:: Debug for SharedBuildAccumulator {
0 commit comments