Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions datafusion/common/src/functional_dependencies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,11 @@ impl FunctionalDependencies {
let mut left_func_dependencies = self.clone();

match join_type {
JoinType::Inner | JoinType::Left | JoinType::Right => {
JoinType::Inner
| JoinType::Left
| JoinType::Right
| JoinType::LeftSingle
| JoinType::RightSingle => {
// Add offset to right schema:
right_func_dependencies.add_offset(left_cols_len);

Expand All @@ -351,10 +355,10 @@ impl FunctionalDependencies {
right_func_dependencies =
right_func_dependencies.with_dependency(Dependency::Multi);

if *join_type == JoinType::Left {
if matches!(join_type, JoinType::Left | JoinType::LeftSingle) {
// Downgrade the right side, since it may have additional NULL values:
right_func_dependencies.downgrade_dependencies();
} else if *join_type == JoinType::Right {
} else if matches!(join_type, JoinType::Right | JoinType::RightSingle) {
// Downgrade the left side, since it may have additional NULL values:
left_func_dependencies.downgrade_dependencies();
}
Expand Down
45 changes: 43 additions & 2 deletions datafusion/common/src/join_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,18 +65,48 @@ pub enum JoinType {
/// in [1] which will be needed if we and ANY subqueries. In our version the mark column will
/// only be true for had a match and false when no match was found, never null.
///
/// [1]: http://btw2017.informatik.uni-stuttgart.de/slidesandpapers/F1-10-37/paper_web.pdf
/// [1]: https://btw2017.informatik.uni-stuttgart.de/slidesandpapers/F1-10-37/paper_web.pdf
LeftMark,
/// Right Mark Join
///
/// Same logic as the LeftMark Join above, however it returns a record for each record from the
/// right input.
RightMark,
/// Left Single Join
///
/// Returns one record for each record from the left input, with the columns of the matching
/// record from the right input, or NULLs when there is no match. If a left record matches
/// more than one right record, the join returns an error.
///
/// This is the "single join" of [1]. It is used to decorrelate scalar subqueries that do not
/// have an aggregate to guarantee they return at most one row.
///
/// [1]: http://btw2017.informatik.uni-stuttgart.de/slidesandpapers/F1-10-37/paper_web.pdf
LeftSingle,
/// Right Single Join
///
/// Same logic as the LeftSingle Join above, but it returns a record for each record from the
/// right input.
RightSingle,
}

impl JoinType {
pub fn is_outer(self) -> bool {
self == JoinType::Left || self == JoinType::Right || self == JoinType::Full
matches!(
self,
JoinType::Left
| JoinType::Right
| JoinType::Full
| JoinType::LeftSingle
| JoinType::RightSingle
)
}

/// Returns true for the single join types, which return at most one row from
/// the other side per row of the preserved side, and error when more than
/// one row matches.
pub fn is_single(self) -> bool {
matches!(self, JoinType::LeftSingle | JoinType::RightSingle)
}

/// Returns the `JoinType` if the (2) inputs were swapped
Expand All @@ -94,6 +124,8 @@ impl JoinType {
JoinType::RightAnti => JoinType::LeftAnti,
JoinType::LeftMark => JoinType::RightMark,
JoinType::RightMark => JoinType::LeftMark,
JoinType::LeftSingle => JoinType::RightSingle,
JoinType::RightSingle => JoinType::LeftSingle,
}
}

Expand Down Expand Up @@ -123,6 +155,8 @@ impl JoinType {
JoinType::RightAnti => (true, false),
JoinType::LeftMark => (false, true),
JoinType::RightMark => (true, false),
JoinType::LeftSingle => (false, true),
JoinType::RightSingle => (true, false),
}
}

Expand All @@ -140,6 +174,8 @@ impl JoinType {
| JoinType::RightAnti
| JoinType::LeftMark
| JoinType::RightMark
| JoinType::LeftSingle
| JoinType::RightSingle
)
}

Expand All @@ -154,6 +190,7 @@ impl JoinType {
| JoinType::LeftAnti
| JoinType::LeftMark
| JoinType::RightSemi
| JoinType::LeftSingle
)
}

Expand Down Expand Up @@ -189,6 +226,8 @@ impl Display for JoinType {
JoinType::RightAnti => "RightAnti",
JoinType::LeftMark => "LeftMark",
JoinType::RightMark => "RightMark",
JoinType::LeftSingle => "LeftSingle",
JoinType::RightSingle => "RightSingle",
};
write!(f, "{join_type}")
}
Expand All @@ -210,6 +249,8 @@ impl FromStr for JoinType {
"RIGHTANTI" => Ok(JoinType::RightAnti),
"LEFTMARK" => Ok(JoinType::LeftMark),
"RIGHTMARK" => Ok(JoinType::RightMark),
"LEFTSINGLE" => Ok(JoinType::LeftSingle),
"RIGHTSINGLE" => Ok(JoinType::RightSingle),
_ => _not_impl_err!("The join type {s} does not exist or is not implemented"),
}
}
Expand Down
4 changes: 4 additions & 0 deletions datafusion/core/src/physical_planner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1610,6 +1610,10 @@ impl DefaultPhysicalPlanner {
| JoinType::RightAnti
| JoinType::LeftMark
| JoinType::RightMark
// Single joins are only implemented by hash and
// nested loop joins.
| JoinType::LeftSingle
| JoinType::RightSingle
)
&& session_state
.config_options()
Expand Down
3 changes: 3 additions & 0 deletions datafusion/core/tests/dataframe/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2601,6 +2601,9 @@ async fn verify_join_output_partitioning() -> Result<()> {
let join_schema = physical_plan.schema();

match join_type {
JoinType::LeftSingle | JoinType::RightSingle => {
unreachable!("single joins are not part of this test's join types")
}
JoinType::Left
| JoinType::LeftSemi
| JoinType::LeftAnti
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1493,11 +1493,13 @@ fn multi_hash_joins() -> Result<()> {
assert_plan!(plan_distrib, plan_sort);
}
JoinType::RightSemi | JoinType::RightAnti | JoinType::RightMark => {}
JoinType::LeftSingle | JoinType::RightSingle => {}
}



match join_type {
JoinType::LeftSingle | JoinType::RightSingle => {}
JoinType::Inner
| JoinType::Left
| JoinType::Right
Expand Down
4 changes: 2 additions & 2 deletions datafusion/expr/src/logical_plan/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1703,7 +1703,7 @@ pub fn build_join_schema(
.collect::<Vec<_>>();
left_fields.into_iter().chain(right_fields).collect()
}
JoinType::Left => {
JoinType::Left | JoinType::LeftSingle => {
// left then right, right set to nullable in case of not matched scenario
let left_fields = left_fields
.map(|(q, f)| (q.cloned(), Arc::clone(f)))
Expand All @@ -1713,7 +1713,7 @@ pub fn build_join_schema(
.chain(nullify_fields(right_fields))
.collect()
}
JoinType::Right => {
JoinType::Right | JoinType::RightSingle => {
// left then right, left set to nullable in case of not matched scenario
let right_fields = right_fields
.map(|(q, f)| (q.cloned(), Arc::clone(f)))
Expand Down
130 changes: 79 additions & 51 deletions datafusion/expr/src/logical_plan/invariants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,31 +169,22 @@ pub fn check_subquery_expr(
subquery.subquery.schema().field_names().join(", ")
);
}
// Correlated scalar subquery must be aggregated to return at most one row
// A correlated scalar subquery must return at most one row per set of
// outer values. Subqueries that do not guarantee this are still valid:
// `ScalarSubqueryToJoin` decorrelates them with a single join, which
// returns an error at run time if a second row matches.
//
// Decorrelation pulls the correlated predicate above the subquery,
// which is not correct if a LIMIT sits in between. Such a subquery is
// only usable if it returns a single row to begin with.
if !subquery.outer_ref_columns.is_empty() {
match strip_inner_query(inner_plan) {
LogicalPlan::Aggregate(agg) => {
check_aggregation_in_scalar_subquery(inner_plan, agg)
}
LogicalPlan::Filter(Filter { input, .. })
if matches!(input.as_ref(), LogicalPlan::Aggregate(_)) =>
{
if let LogicalPlan::Aggregate(agg) = input.as_ref() {
check_aggregation_in_scalar_subquery(inner_plan, agg)
} else {
Ok(())
}
}
_ => {
if inner_plan.max_rows().is_some_and(|max_row| max_row <= 1) {
Ok(())
} else {
plan_err!(
"Correlated scalar subquery must be aggregated to return at most one row"
)
}
}
}?;
if correlation_is_below_limit(inner_plan)
&& inner_plan.max_rows().is_none_or(|rows| rows > 1)
{
return plan_err!(
"Correlated scalar subquery with a LIMIT must be limited to a single row"
);
}
match outer_plan {
LogicalPlan::Projection(_) | LogicalPlan::Filter(_) => Ok(()),
LogicalPlan::Aggregate(Aggregate {
Expand Down Expand Up @@ -323,14 +314,16 @@ fn check_inner_plan(inner_plan: &LogicalPlan) -> Result<()> {
JoinType::Left
| JoinType::LeftSemi
| JoinType::LeftAnti
| JoinType::LeftMark => {
| JoinType::LeftMark
| JoinType::LeftSingle => {
check_inner_plan(left)?;
check_no_outer_references(right)
}
JoinType::Right
| JoinType::RightSemi
| JoinType::RightAnti
| JoinType::RightMark => {
| JoinType::RightMark
| JoinType::RightSingle => {
check_no_outer_references(left)?;
check_inner_plan(right)
}
Expand Down Expand Up @@ -358,35 +351,70 @@ fn check_no_outer_references(inner_plan: &LogicalPlan) -> Result<()> {
}
}

fn check_aggregation_in_scalar_subquery(
/// Returns true if a `LIMIT` sits above an outer reference in the subquery.
/// Decorrelation cannot pull the correlated predicate above such a `LIMIT`.
fn correlation_is_below_limit(inner_plan: &LogicalPlan) -> bool {
let mut found = false;
inner_plan
.apply(|plan| {
Ok(
if matches!(plan, LogicalPlan::Limit(_))
&& !plan.all_out_ref_exprs().is_empty()
{
found = true;
TreeNodeRecursion::Stop
} else {
TreeNodeRecursion::Continue
},
)
})
// the closure always returns Ok
.expect("infallible");
found
}

/// Returns true if a correlated scalar subquery already returns at most one row
/// per set of outer values.
///
/// This holds if the subquery aggregates and groups only by columns the
/// correlated predicate fixes, or if it cannot return more than one row at all.
/// Such a subquery can be decorrelated with a plain `LEFT JOIN`. The rest need
/// a single join, which checks the condition at run time.
pub fn correlated_scalar_subquery_yields_single_row(
inner_plan: &LogicalPlan,
agg: &Aggregate,
) -> Result<()> {
) -> Result<bool> {
let agg = match strip_inner_query(inner_plan) {
LogicalPlan::Aggregate(agg) => agg,
LogicalPlan::Filter(Filter { input, .. }) => match input.as_ref() {
LogicalPlan::Aggregate(agg) => agg,
_ => return Ok(inner_plan.max_rows().is_some_and(|rows| rows <= 1)),
},
_ => return Ok(inner_plan.max_rows().is_some_and(|rows| rows <= 1)),
};

// A GROUP BY with no aggregate is a DISTINCT, which can still return one
// row per distinct group.
if agg.aggr_expr.is_empty() {
return plan_err!(
"Correlated scalar subquery must be aggregated to return at most one row"
);
return Ok(false);
}
if !agg.group_expr.is_empty() {
let correlated_exprs = get_correlated_expressions(inner_plan)?;
let inner_subquery_cols =
collect_subquery_cols(&correlated_exprs, agg.input.schema())?;
let mut group_columns = agg
.group_expr
.iter()
.map(|group| Ok(group.column_refs().into_iter().cloned().collect::<Vec<_>>()))
.collect::<Result<Vec<_>>>()?
.into_iter()
.flatten();

if !group_columns.all(|group| inner_subquery_cols.contains(&group)) {
// Group BY columns must be a subset of columns in the correlated expressions
return plan_err!(
"A GROUP BY clause in a scalar correlated subquery cannot contain non-correlated columns"
);
}
if agg.group_expr.is_empty() {
return Ok(true);
}
Ok(())

// Grouping by a column the correlated predicate does not fix can produce
// more than one group, and so more than one row, per set of outer values.
let correlated_exprs = get_correlated_expressions(inner_plan)?;
let inner_subquery_cols =
collect_subquery_cols(&correlated_exprs, agg.input.schema())?;
let mut group_columns = agg
.group_expr
.iter()
.map(|group| Ok(group.column_refs().into_iter().cloned().collect::<Vec<_>>()))
.collect::<Result<Vec<_>>>()?
.into_iter()
.flatten();

Ok(group_columns.all(|group| inner_subquery_cols.contains(&group)))
}

fn strip_inner_query(inner_plan: &LogicalPlan) -> &LogicalPlan {
Expand Down
5 changes: 4 additions & 1 deletion datafusion/expr/src/logical_plan/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ pub mod display;
pub mod dml;
mod extension;
pub(crate) mod invariants;
pub use invariants::{InvariantLevel, assert_expected_schema, check_subquery_expr};
pub use invariants::{
InvariantLevel, assert_expected_schema, check_subquery_expr,
correlated_scalar_subquery_yields_single_row,
};
mod plan;
mod statement;
pub mod tree_node;
Expand Down
Loading