This is an automated email from the ASF dual-hosted git repository.

github-bot pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion.git


The following commit(s) were added to refs/heads/main by this push:
     new 0bf9defb1d fix: Fix three bugs in query decorrelation (#21208)
0bf9defb1d is described below

commit 0bf9defb1d9587ab2810e6e8adff512f4db58037
Author: Neil Conway <[email protected]>
AuthorDate: Tue Mar 31 09:15:15 2026 -0400

    fix: Fix three bugs in query decorrelation (#21208)
    
    ## Which issue does this PR close?
    
    - Closes #21205.
    - Closes #21206.
    - Closes #20315.
    
    ## Rationale for this change
    
    This PR fixes three separate bugs in query decorrelation:
    
    1. When removing duplicate filters as part of pulling up `IN`
    subqueries, an operator precedence error meant that we would consider
    two filters to be duplicates even if they involved different operators
    (e.g., `=` and `>`).
    2. When generating the `CASE` used to implement "count bug" handling, we
    referenced the subquery output column without qualifying it by the
    subquery alias. This could result in name-collisions with unrelated
    identifiers in the parent query.
    3. After generating the `CASE` used for "count bug" handling, we rewrote
    the parent query to replace references to the subquery output column
    with the generated `CASE` expression. This rewrite only matched on
    unqualified column name only, which meant that unrelated parent query
    identifiers that happened to share the same column name as subquery
    aggregate aliases could been rewritten by mistake.
    
    The first and third issues could result in incorrect query results; the
    second would only cause spurious errors, as far as I can see.
    
    ## What changes are included in this PR?
    
    * Fix all three bugs
    * Add SLT tests
    * Add a `debug_assert!` to document/check that
    `remove_duplicated_filter` is called with a commutative operator
    
    ## Are these changes tested?
    
    Yes.
    
    ## Are there any user-facing changes?
    
    No, except in the sense that they fix user-visible bugs.
---
 datafusion/optimizer/src/decorrelate.rs            | 34 ++++++++---
 .../optimizer/src/scalar_subquery_to_join.rs       | 31 +++++-----
 datafusion/sqllogictest/test_files/subquery.slt    | 68 ++++++++++++++++++++++
 3 files changed, 108 insertions(+), 25 deletions(-)

diff --git a/datafusion/optimizer/src/decorrelate.rs 
b/datafusion/optimizer/src/decorrelate.rs
index e7bc62e8da..08839b49ef 100644
--- a/datafusion/optimizer/src/decorrelate.rs
+++ b/datafusion/optimizer/src/decorrelate.rs
@@ -26,7 +26,9 @@ use crate::simplify_expressions::ExprSimplifier;
 use datafusion_common::tree_node::{
     Transformed, TransformedResult, TreeNode, TreeNodeRecursion, 
TreeNodeRewriter,
 };
-use datafusion_common::{Column, DFSchemaRef, HashMap, Result, ScalarValue, 
plan_err};
+use datafusion_common::{
+    Column, DFSchemaRef, HashMap, Result, ScalarValue, assert_or_internal_err, 
plan_err,
+};
 use datafusion_expr::expr::Alias;
 use datafusion_expr::simplify::SimplifyContext;
 use datafusion_expr::utils::{
@@ -179,7 +181,7 @@ impl TreeNodeRewriter for PullUpCorrelatedExpr {
                     find_join_exprs(subquery_filter_exprs)?;
                 if let Some(in_predicate) = &self.in_predicate_opt {
                     // in_predicate may be already included in the join 
filters, remove it from the join filters first.
-                    join_filters = remove_duplicated_filter(join_filters, 
in_predicate);
+                    join_filters = remove_duplicated_filter(join_filters, 
in_predicate)?;
                 }
                 let correlated_subquery_cols =
                     collect_subquery_cols(&join_filters, subquery_schema)?;
@@ -460,25 +462,39 @@ fn collect_local_correlated_cols(
     }
 }
 
-fn remove_duplicated_filter(filters: Vec<Expr>, in_predicate: &Expr) -> 
Vec<Expr> {
-    filters
+fn remove_duplicated_filter(
+    filters: Vec<Expr>,
+    in_predicate: &Expr,
+) -> Result<Vec<Expr>> {
+    // We assume below that swapping the order of operands to an operator does
+    // not change behavior, which is only true if the operator is commutative.
+    assert_or_internal_err!(
+        match in_predicate {
+            Expr::BinaryExpr(b) => b.op.swap() == Some(b.op),
+            _ => true,
+        },
+        "remove_duplicated_filter: in_predicate must use a commutative 
operator"
+    );
+
+    Ok(filters
         .into_iter()
         .filter(|filter| {
             if filter == in_predicate {
                 return false;
             }
 
-            // ignore the binary order
+            // Treat swapped operand order to a binary operator as equivalent
             !match (filter, in_predicate) {
                 (Expr::BinaryExpr(a_expr), Expr::BinaryExpr(b_expr)) => {
-                    (a_expr.op == b_expr.op)
-                        && (a_expr.left == b_expr.left && a_expr.right == 
b_expr.right)
-                        || (a_expr.left == b_expr.right && a_expr.right == 
b_expr.left)
+                    a_expr.op == b_expr.op
+                        && ((a_expr.left == b_expr.left && a_expr.right == 
b_expr.right)
+                            || (a_expr.left == b_expr.right
+                                && a_expr.right == b_expr.left))
                 }
                 _ => false,
             }
         })
-        .collect::<Vec<_>>()
+        .collect::<Vec<_>>())
 }
 
 fn agg_exprs_evaluation_result_on_empty_batch(
diff --git a/datafusion/optimizer/src/scalar_subquery_to_join.rs 
b/datafusion/optimizer/src/scalar_subquery_to_join.rs
index 975c234b38..590b00098b 100644
--- a/datafusion/optimizer/src/scalar_subquery_to_join.rs
+++ b/datafusion/optimizer/src/scalar_subquery_to_join.rs
@@ -111,7 +111,7 @@ impl OptimizerRule for ScalarSubqueryToJoin {
                                     // replace column references with entry in 
map, if it exists
                                     if let Some(map_expr) = expr
                                         .try_as_col()
-                                        .and_then(|col| 
expr_check_map.get(&col.name))
+                                        .and_then(|col| 
expr_check_map.get(col))
                                     {
                                         Ok(Transformed::yes(map_expr.clone()))
                                     } else {
@@ -176,7 +176,7 @@ impl OptimizerRule for ScalarSubqueryToJoin {
                                     // replace column references with entry in 
map, if it exists
                                     if let Some(map_expr) = expr
                                         .try_as_col()
-                                        .and_then(|col| 
expr_check_map.get(&col.name))
+                                        .and_then(|col| 
expr_check_map.get(col))
                                     {
                                         Ok(Transformed::yes(map_expr.clone()))
                                     } else {
@@ -301,7 +301,7 @@ fn build_join(
     subquery: &Subquery,
     filter_input: &LogicalPlan,
     subquery_alias: &str,
-) -> Result<Option<(LogicalPlan, HashMap<String, Expr>)>> {
+) -> Result<Option<(LogicalPlan, HashMap<Column, Expr>)>> {
     let subquery_plan = subquery.subquery.as_ref();
     let mut pull_up = 
PullUpCorrelatedExpr::new().with_need_handle_count_bug(true);
     let new_plan = subquery_plan.clone().rewrite(&mut pull_up).data()?;
@@ -358,14 +358,19 @@ fn build_join(
                 // If expr always returns null when column is null, skip 
processing
                 continue;
             }
+
+            let indicator_col =
+                Column::new(Some(subquery_alias), UN_MATCHED_ROW_INDICATOR);
+            // Qualify with the subquery alias to avoid ambiguity when the
+            // outer table has a column with the same name as the aggregate.
+            let value_col = Column::new(Some(subquery_alias), name.clone());
+
             let computer_expr = if let Some(filter) = 
&pull_up.pull_up_having_expr {
                 Expr::Case(expr::Case {
                     expr: None,
                     when_then_expr: vec![
                         (
-                            Box::new(Expr::IsNull(Box::new(Expr::Column(
-                                
Column::new_unqualified(UN_MATCHED_ROW_INDICATOR),
-                            )))),
+                            
Box::new(Expr::IsNull(Box::new(Expr::Column(indicator_col)))),
                             Box::new(result),
                         ),
                         (
@@ -373,29 +378,23 @@ fn build_join(
                             Box::new(Expr::Literal(ScalarValue::Null, None)),
                         ),
                     ],
-                    else_expr: 
Some(Box::new(Expr::Column(Column::new_unqualified(
-                        name.clone(),
-                    )))),
+                    else_expr: Some(Box::new(Expr::Column(value_col.clone()))),
                 })
             } else {
                 Expr::Case(expr::Case {
                     expr: None,
                     when_then_expr: vec![(
-                        Box::new(Expr::IsNull(Box::new(Expr::Column(
-                            Column::new_unqualified(UN_MATCHED_ROW_INDICATOR),
-                        )))),
+                        
Box::new(Expr::IsNull(Box::new(Expr::Column(indicator_col)))),
                         Box::new(result),
                     )],
-                    else_expr: 
Some(Box::new(Expr::Column(Column::new_unqualified(
-                        name.clone(),
-                    )))),
+                    else_expr: Some(Box::new(Expr::Column(value_col.clone()))),
                 })
             };
             let mut expr_rewrite = TypeCoercionRewriter {
                 schema: new_plan.schema(),
             };
             computation_project_expr
-                .insert(name, computer_expr.rewrite(&mut 
expr_rewrite).data()?);
+                .insert(value_col, computer_expr.rewrite(&mut 
expr_rewrite).data()?);
         }
     }
 
diff --git a/datafusion/sqllogictest/test_files/subquery.slt 
b/datafusion/sqllogictest/test_files/subquery.slt
index e5ca9d674e..7f88199b3c 100644
--- a/datafusion/sqllogictest/test_files/subquery.slt
+++ b/datafusion/sqllogictest/test_files/subquery.slt
@@ -1671,3 +1671,71 @@ drop table employees;
 
 statement count 0
 drop table project_assignments;
+
+# https://github.com/apache/datafusion/issues/21205
+statement ok
+CREATE TABLE dup_filter_t1(id INTEGER) AS VALUES (1), (2), (3);
+
+statement ok
+CREATE TABLE dup_filter_t2(id INTEGER) AS VALUES (1), (2), (3);
+
+query I
+SELECT * FROM dup_filter_t1 WHERE dup_filter_t1.id IN (
+    SELECT dup_filter_t2.id FROM dup_filter_t2 WHERE dup_filter_t2.id > 
dup_filter_t1.id
+);
+----
+
+statement ok
+DROP TABLE dup_filter_t1;
+
+statement ok
+DROP TABLE dup_filter_t2;
+
+# https://github.com/apache/datafusion/issues/21206
+statement ok
+CREATE TABLE sq_name_t1(id INTEGER) AS VALUES (1), (2), (3);
+
+statement ok
+CREATE TABLE sq_name_t2(id INTEGER, outer_id INTEGER) AS VALUES (10, 1), (20, 
1), (30, 2);
+
+query II
+SELECT sq_name_t1.id,
+  (SELECT count(*) AS id FROM sq_name_t2 WHERE sq_name_t2.outer_id = 
sq_name_t1.id) AS cnt
+FROM sq_name_t1
+ORDER BY sq_name_t1.id;
+----
+1 2
+2 1
+3 0
+
+query I
+SELECT sq_name_t1.id
+FROM sq_name_t1
+WHERE sq_name_t1.id > (
+  SELECT count(*) AS id
+  FROM sq_name_t2
+  WHERE sq_name_t2.outer_id = sq_name_t1.id
+)
+ORDER BY sq_name_t1.id;
+----
+2
+3
+
+query I
+SELECT sq_name_t1.id * 10 + (
+  SELECT count(*) AS id
+  FROM sq_name_t2
+  WHERE sq_name_t2.outer_id = sq_name_t1.id
+) AS total
+FROM sq_name_t1
+ORDER BY sq_name_t1.id;
+----
+12
+21
+30
+
+statement ok
+DROP TABLE sq_name_t1;
+
+statement ok
+DROP TABLE sq_name_t2;


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to