Skip to content

Commit 26eec2d

Browse files
committed
codex help
Signed-off-by: Adam Gutglick <adamgsal@gmail.com>
1 parent c2248b5 commit 26eec2d

9 files changed

Lines changed: 426 additions & 35 deletions

File tree

datafusion/common/src/join_type.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,10 @@ pub enum JoinType {
6161
/// [1]. This join type is used to decorrelate EXISTS subqueries used inside disjunctive
6262
/// predicates.
6363
///
64-
/// Note: This we currently do not implement the full null semantics for the mark join described
65-
/// in [1] which will be needed if we and ANY subqueries. In our version the mark column will
66-
/// only be true for had a match and false when no match was found, never null.
64+
/// For scalar `NOT IN`, DataFusion can plan a null-aware hash mark join where the
65+
/// mark column is nullable: TRUE for a match, NULL for SQL UNKNOWN, and FALSE
66+
/// otherwise. Row-valued multi-column `NOT IN` and non-hash residual predicate
67+
/// null-aware mark semantics are not implemented.
6768
///
6869
/// [1]: http://btw2017.informatik.uni-stuttgart.de/slidesandpapers/F1-10-37/paper_web.pdf
6970
LeftMark,

datafusion/core/src/physical_planner.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3384,11 +3384,27 @@ mod tests {
33843384
ctx.sql(query).await?.create_physical_plan().await
33853385
}
33863386

3387+
async fn plan_sql_with_config(
3388+
query: &str,
3389+
config: SessionConfig,
3390+
) -> Result<Arc<dyn ExecutionPlan>> {
3391+
let ctx = SessionContext::new_with_config(config);
3392+
ctx.sql(query).await?.create_physical_plan().await
3393+
}
3394+
33873395
async fn collect_sql(query: &str) -> Result<Vec<RecordBatch>> {
33883396
let ctx = SessionContext::new();
33893397
ctx.sql(query).await?.collect().await
33903398
}
33913399

3400+
async fn collect_sql_with_config(
3401+
query: &str,
3402+
config: SessionConfig,
3403+
) -> Result<Vec<RecordBatch>> {
3404+
let ctx = SessionContext::new_with_config(config);
3405+
ctx.sql(query).await?.collect().await
3406+
}
3407+
33923408
#[tokio::test]
33933409
async fn test_all_operators() -> Result<()> {
33943410
let logical_plan = test_csv_scan()
@@ -3523,6 +3539,62 @@ mod tests {
35233539
Ok(())
35243540
}
35253541

3542+
#[tokio::test]
3543+
async fn correlated_not_in_is_null_uses_null_aware_hash_mark_join() -> Result<()> {
3544+
let query = "
3545+
SELECT value
3546+
FROM (
3547+
VALUES
3548+
(1, 1, 'a'),
3549+
(3, 1, 'b'),
3550+
(1, 2, 'c'),
3551+
(NULL, 1, 'd'),
3552+
(5, 3, 'e'),
3553+
(2, 1, 'f'),
3554+
(NULL, 2, 'g')
3555+
) AS outer_corr_table(id, grp, value)
3556+
WHERE (id NOT IN (
3557+
SELECT id
3558+
FROM (
3559+
VALUES
3560+
(2, 1),
3561+
(NULL, 1),
3562+
(1, 2)
3563+
) AS inner_corr_table(id, grp)
3564+
WHERE inner_corr_table.grp = outer_corr_table.grp
3565+
)) IS NULL
3566+
ORDER BY value";
3567+
3568+
let config = SessionConfig::new()
3569+
.with_target_partitions(4)
3570+
.set_bool("datafusion.optimizer.prefer_hash_join", false);
3571+
3572+
let plan = plan_sql_with_config(query, config.clone()).await?;
3573+
let formatted = displayable(plan.as_ref()).indent(true).to_string();
3574+
assert_contains!(
3575+
&formatted,
3576+
"HashJoinExec: mode=CollectLeft, join_type=LeftMark"
3577+
);
3578+
assert!(!formatted.contains("SortMergeJoinExec"), "{formatted}");
3579+
3580+
let batches = collect_sql_with_config(query, config).await?;
3581+
assert_batches_eq!(
3582+
&[
3583+
"+-------+",
3584+
"| value |",
3585+
"+-------+",
3586+
"| a |",
3587+
"| b |",
3588+
"| d |",
3589+
"| g |",
3590+
"+-------+",
3591+
],
3592+
&batches
3593+
);
3594+
3595+
Ok(())
3596+
}
3597+
35263598
#[tokio::test]
35273599
async fn scalar_subquery_in_projection_and_filter_plans() -> Result<()> {
35283600
let plan = plan_sql(

datafusion/optimizer/src/decorrelate_predicate_subquery.rs

Lines changed: 111 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use std::ops::Deref;
2121
use std::sync::Arc;
2222

2323
use crate::decorrelate::PullUpCorrelatedExpr;
24+
use crate::extract_equijoin_predicate::split_eq_and_noneq_join_predicate;
2425
use crate::optimizer::ApplyOrder;
2526
use crate::utils::replace_qualified_name;
2627
use 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()?;

datafusion/optimizer/src/extract_equijoin_predicate.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ impl OptimizerRule for ExtractEquijoinPredicate {
174174
///
175175
/// According to the above rule, `expr1` is the equijoin predicate, while `expr2` and `expr3` are not.
176176
/// The function returns Ok(\[expr1\], Some(expr2 AND expr3))
177-
fn split_eq_and_noneq_join_predicate(
177+
pub(crate) fn split_eq_and_noneq_join_predicate(
178178
filter: Expr,
179179
left_schema: &DFSchema,
180180
right_schema: &DFSchema,

0 commit comments

Comments
 (0)