@@ -21,6 +21,7 @@ use std::ops::Deref;
2121use std:: sync:: Arc ;
2222
2323use crate :: decorrelate:: PullUpCorrelatedExpr ;
24+ use crate :: extract_equijoin_predicate:: split_eq_and_noneq_join_predicate;
2425use crate :: optimizer:: ApplyOrder ;
2526use crate :: utils:: replace_qualified_name;
2627use crate :: { OptimizerConfig , OptimizerRule } ;
@@ -379,8 +380,6 @@ fn build_join(
379380 . values ( )
380381 . for_each ( |cols| all_correlated_cols. extend ( cols. clone ( ) ) ) ;
381382
382- let has_correlated_join_filter = !pull_up. join_filters . is_empty ( ) ;
383-
384383 // alias the join filter
385384 let join_filter_opt = conjunction ( pull_up. join_filters )
386385 . map_or ( Ok ( None ) , |filter| {
@@ -450,12 +449,25 @@ fn build_join(
450449 sub_query_alias. clone ( )
451450 } ;
452451
453- // For simple uncorrelated NOT IN disjunctions, propagate null-aware semantics into the
454- // nullable mark column. Correlated mark joins still use the legacy path because the
455- // runtime state is global to the probe side rather than per-left-row.
452+ let mark_filter_is_hashable_only =
453+ if join_type == JoinType :: LeftMark && in_predicate_opt. is_some ( ) {
454+ let ( _, residual_filter) = split_eq_and_noneq_join_predicate (
455+ join_filter. clone ( ) ,
456+ left. schema ( ) ,
457+ right_projected. schema ( ) ,
458+ ) ?;
459+ residual_filter. is_none ( )
460+ } else {
461+ false
462+ } ;
463+
464+ // For scalar NOT IN mark joins, propagate null-aware semantics into the
465+ // nullable mark column when the predicate can be implemented by hash keys.
466+ // Non-equality correlated filters stay on the legacy path because hash join
467+ // execution cannot mark UNKNOWN candidates for residual predicates.
456468 let null_aware = join_type == JoinType :: LeftMark
457469 && in_predicate_opt. is_some ( )
458- && !has_correlated_join_filter
470+ && mark_filter_is_hashable_only
459471 && join_keys_may_be_null (
460472 & join_filter,
461473 left. schema ( ) ,
@@ -591,6 +603,43 @@ mod tests {
591603 ) )
592604 }
593605
606+ fn nullable_scalar_mark_scan ( name : & str ) -> Result < LogicalPlan > {
607+ let schema = Schema :: new ( vec ! [
608+ Field :: new( "id" , DataType :: Int32 , true ) ,
609+ Field :: new( "grp" , DataType :: Int32 , true ) ,
610+ ] ) ;
611+ table_scan ( Some ( name) , & schema, None ) ?. build ( )
612+ }
613+
614+ fn has_null_aware_left_mark_join ( plan : & LogicalPlan ) -> bool {
615+ if let LogicalPlan :: Join ( join) = plan
616+ && join. join_type == JoinType :: LeftMark
617+ {
618+ return join. null_aware ;
619+ }
620+
621+ plan. inputs ( ) . into_iter ( ) . any ( has_null_aware_left_mark_join)
622+ }
623+
624+ fn has_non_null_aware_left_mark_join ( plan : & LogicalPlan ) -> bool {
625+ if let LogicalPlan :: Join ( join) = plan
626+ && join. join_type == JoinType :: LeftMark
627+ {
628+ return !join. null_aware ;
629+ }
630+
631+ plan. inputs ( )
632+ . into_iter ( )
633+ . any ( has_non_null_aware_left_mark_join)
634+ }
635+
636+ fn optimize_with_decorrelate ( plan : LogicalPlan ) -> Result < LogicalPlan > {
637+ let optimizer = crate :: Optimizer :: with_rules ( vec ! [ Arc :: new(
638+ DecorrelatePredicateSubquery :: new( ) ,
639+ ) ] ) ;
640+ optimizer. optimize ( plan, & crate :: OptimizerContext :: new ( ) , |_, _| { } )
641+ }
642+
594643 /// Test for several IN subquery expressions
595644 #[ test]
596645 fn in_subquery_multiple ( ) -> Result < ( ) > {
@@ -1221,6 +1270,62 @@ mod tests {
12211270 )
12221271 }
12231272
1273+ #[ test]
1274+ fn correlated_not_in_mark_join_is_null_aware_for_hashable_filter ( ) -> Result < ( ) > {
1275+ let outer_scan = nullable_scalar_mark_scan ( "outer_t" ) ?;
1276+ let inner_scan = nullable_scalar_mark_scan ( "inner_t" ) ?;
1277+
1278+ let subquery = Arc :: new (
1279+ LogicalPlanBuilder :: from ( inner_scan)
1280+ . filter (
1281+ out_ref_col ( DataType :: Int32 , "outer_t.grp" ) . eq ( col ( "inner_t.grp" ) ) ,
1282+ ) ?
1283+ . project ( vec ! [ col( "inner_t.id" ) ] ) ?
1284+ . build ( ) ?,
1285+ ) ;
1286+
1287+ let plan = LogicalPlanBuilder :: from ( outer_scan)
1288+ . filter ( not_in_subquery ( col ( "outer_t.id" ) , subquery) . is_null ( ) ) ?
1289+ . build ( ) ?;
1290+
1291+ let optimized = optimize_with_decorrelate ( plan) ?;
1292+ assert ! (
1293+ has_null_aware_left_mark_join( & optimized) ,
1294+ "{}" ,
1295+ optimized. display_indent_schema( )
1296+ ) ;
1297+
1298+ Ok ( ( ) )
1299+ }
1300+
1301+ #[ test]
1302+ fn correlated_not_in_mark_join_is_not_null_aware_for_residual_filter ( ) -> Result < ( ) > {
1303+ let outer_scan = nullable_scalar_mark_scan ( "outer_t" ) ?;
1304+ let inner_scan = nullable_scalar_mark_scan ( "inner_t" ) ?;
1305+
1306+ let subquery = Arc :: new (
1307+ LogicalPlanBuilder :: from ( inner_scan)
1308+ . filter (
1309+ out_ref_col ( DataType :: Int32 , "outer_t.grp" ) . lt ( col ( "inner_t.grp" ) ) ,
1310+ ) ?
1311+ . project ( vec ! [ col( "inner_t.id" ) ] ) ?
1312+ . build ( ) ?,
1313+ ) ;
1314+
1315+ let plan = LogicalPlanBuilder :: from ( outer_scan)
1316+ . filter ( not_in_subquery ( col ( "outer_t.id" ) , subquery) . is_null ( ) ) ?
1317+ . build ( ) ?;
1318+
1319+ let optimized = optimize_with_decorrelate ( plan) ?;
1320+ assert ! (
1321+ has_non_null_aware_left_mark_join( & optimized) ,
1322+ "{}" ,
1323+ optimized. display_indent_schema( )
1324+ ) ;
1325+
1326+ Ok ( ( ) )
1327+ }
1328+
12241329 #[ test]
12251330 fn in_subquery_both_side_expr ( ) -> Result < ( ) > {
12261331 let table_scan = test_table_scan ( ) ?;
0 commit comments