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

alamb 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 3ff0bfe35d feat: Support duplicate column names in Joins in Substrait 
consumer (#11049)
3ff0bfe35d is described below

commit 3ff0bfe35d423cf36e462863d67fdf94994b175a
Author: Arttu <[email protected]>
AuthorDate: Mon Jun 24 18:17:00 2024 +0200

    feat: Support duplicate column names in Joins in Substrait consumer (#11049)
    
    * add tests for joining tables with same name
    
    * alias tables just before joins instead, for a simpler solution
    
    * test cleanup
    
    * docstrings and some cleanup
    
    * Clippy
    
    ---------
    
    Co-authored-by: Andrew Lamb <[email protected]>
---
 datafusion/substrait/src/logical_plan/consumer.rs  | 78 +++++++++++++----
 .../tests/cases/roundtrip_logical_plan.rs          | 98 +++++++++++++---------
 2 files changed, 120 insertions(+), 56 deletions(-)

diff --git a/datafusion/substrait/src/logical_plan/consumer.rs 
b/datafusion/substrait/src/logical_plan/consumer.rs
index a48da7b9a1..fe5d981ece 100644
--- a/datafusion/substrait/src/logical_plan/consumer.rs
+++ b/datafusion/substrait/src/logical_plan/consumer.rs
@@ -250,14 +250,14 @@ pub async fn from_substrait_plan(
                         match plan {
                             // If the last node of the plan produces 
expressions, bake the renames into those expressions.
                             // This isn't necessary for correctness, but helps 
with roundtrip tests.
-                            LogicalPlan::Projection(p) => 
Ok(LogicalPlan::Projection(Projection::try_new(rename_expressions(p.expr, 
p.input.schema(), renamed_schema)?, p.input)?)),
+                            LogicalPlan::Projection(p) => 
Ok(LogicalPlan::Projection(Projection::try_new(rename_expressions(p.expr, 
p.input.schema(), &renamed_schema)?, p.input)?)),
                             LogicalPlan::Aggregate(a) => {
-                                let new_aggr_exprs = 
rename_expressions(a.aggr_expr, a.input.schema(), renamed_schema)?;
+                                let new_aggr_exprs = 
rename_expressions(a.aggr_expr, a.input.schema(), &renamed_schema)?;
                                 
Ok(LogicalPlan::Aggregate(Aggregate::try_new(a.input, a.group_expr, 
new_aggr_exprs)?))
                             },
                             // There are probably more plans where we could 
bake things in, can add them later as needed.
                             // Otherwise, add a new Project to handle the 
renaming.
-                            _ => 
Ok(LogicalPlan::Projection(Projection::try_new(rename_expressions(plan.schema().columns().iter().map(|c|
 col(c.to_owned())), plan.schema(), renamed_schema)?, Arc::new(plan))?))
+                            _ => 
Ok(LogicalPlan::Projection(Projection::try_new(rename_expressions(plan.schema().columns().iter().map(|c|
 col(c.to_owned())), plan.schema(), &renamed_schema)?, Arc::new(plan))?))
                         }
                     }
                 },
@@ -308,34 +308,46 @@ pub fn extract_projection(
     }
 }
 
+/// Ensure the expressions have the right name(s) according to the new schema.
+/// This includes the top-level (column) name, which will be renamed through 
aliasing if needed,
+/// as well as nested names (if the expression produces any struct types), 
which will be renamed
+/// through casting if needed.
 fn rename_expressions(
     exprs: impl IntoIterator<Item = Expr>,
     input_schema: &DFSchema,
-    new_schema: DFSchemaRef,
+    new_schema: &DFSchema,
 ) -> Result<Vec<Expr>> {
     exprs
         .into_iter()
         .zip(new_schema.fields())
         .map(|(old_expr, new_field)| {
-            if &old_expr.get_type(input_schema)? == new_field.data_type() {
-                // Alias column if needed
-                old_expr.alias_if_changed(new_field.name().into())
-            } else {
-                // Use Cast to rename inner struct fields + alias column if 
needed
+            // Check if type (i.e. nested struct field names) match, use Cast 
to rename if needed
+            let new_expr = if &old_expr.get_type(input_schema)? != 
new_field.data_type() {
                 Expr::Cast(Cast::new(
                     Box::new(old_expr),
                     new_field.data_type().to_owned(),
                 ))
-                .alias_if_changed(new_field.name().into())
+            } else {
+                old_expr
+            };
+            // Alias column if needed to fix the top-level name
+            match &new_expr {
+                // If expr is a column reference, alias_if_changed would cause 
an aliasing if the old expr has a qualifier
+                Expr::Column(c) if &c.name == new_field.name() => Ok(new_expr),
+                _ => new_expr.alias_if_changed(new_field.name().to_owned()),
             }
         })
         .collect()
 }
 
+/// Produce a version of the given schema with names matching the given list 
of names.
+/// Substrait doesn't deal with column (incl. nested struct field) names 
within the schema,
+/// but it does give us the list of expected names at the end of the plan, so 
we use this
+/// to rename the schema to match the expected names.
 fn make_renamed_schema(
     schema: &DFSchemaRef,
     dfs_names: &Vec<String>,
-) -> Result<DFSchemaRef> {
+) -> Result<DFSchema> {
     fn rename_inner_fields(
         dtype: &DataType,
         dfs_names: &Vec<String>,
@@ -401,10 +413,10 @@ fn make_renamed_schema(
             dfs_names.len());
     }
 
-    Ok(Arc::new(DFSchema::from_field_specific_qualified_schema(
+    DFSchema::from_field_specific_qualified_schema(
         qualifiers,
         &Arc::new(Schema::new(fields)),
-    )?))
+    )
 }
 
 /// Convert Substrait Rel to DataFusion DataFrame
@@ -594,6 +606,8 @@ pub async fn from_substrait_rel(
             let right = LogicalPlanBuilder::from(
                 from_substrait_rel(ctx, join.right.as_ref().unwrap(), 
extensions).await?,
             );
+            let (left, right) = requalify_sides_if_needed(left, right)?;
+
             let join_type = from_substrait_jointype(join.r#type)?;
             // The join condition expression needs full input schema and not 
the output schema from join since we lose columns from
             // certain join types such as semi and anti joins
@@ -627,13 +641,15 @@ pub async fn from_substrait_rel(
             }
         }
         Some(RelType::Cross(cross)) => {
-            let left: LogicalPlanBuilder = LogicalPlanBuilder::from(
+            let left = LogicalPlanBuilder::from(
                 from_substrait_rel(ctx, cross.left.as_ref().unwrap(), 
extensions).await?,
             );
-            let right =
+            let right = LogicalPlanBuilder::from(
                 from_substrait_rel(ctx, cross.right.as_ref().unwrap(), 
extensions)
-                    .await?;
-            left.cross_join(right)?.build()
+                    .await?,
+            );
+            let (left, right) = requalify_sides_if_needed(left, right)?;
+            left.cross_join(right.build()?)?.build()
         }
         Some(RelType::Read(read)) => match &read.as_ref().read_type {
             Some(ReadType::NamedTable(nt)) => {
@@ -846,6 +862,34 @@ pub async fn from_substrait_rel(
     }
 }
 
+/// (Re)qualify the sides of a join if needed, i.e. if the columns from one 
side would otherwise
+/// conflict with the columns from the other.  
+/// Substrait doesn't currently allow specifying aliases, neither for columns 
nor for tables. For
+/// Substrait the names don't matter since it only refers to columns by 
indices, however DataFusion
+/// requires columns to be uniquely identifiable, in some places (see e.g. 
DFSchema::check_names).
+fn requalify_sides_if_needed(
+    left: LogicalPlanBuilder,
+    right: LogicalPlanBuilder,
+) -> Result<(LogicalPlanBuilder, LogicalPlanBuilder)> {
+    let left_cols = left.schema().columns();
+    let right_cols = right.schema().columns();
+    if left_cols.iter().any(|l| {
+        right_cols.iter().any(|r| {
+            l == r || (l.name == r.name && (l.relation.is_none() || 
r.relation.is_none()))
+        })
+    }) {
+        // These names have no connection to the original plan, but they'll 
make the columns
+        // (mostly) unique. There may be cases where this still causes 
duplicates, if either left
+        // or right side itself contains duplicate names with different 
qualifiers.
+        Ok((
+            left.alias(TableReference::bare("left"))?,
+            right.alias(TableReference::bare("right"))?,
+        ))
+    } else {
+        Ok((left, right))
+    }
+}
+
 fn from_substrait_jointype(join_type: i32) -> Result<JoinType> {
     if let Ok(substrait_join_type) = join_rel::JoinType::try_from(join_type) {
         match substrait_join_type {
diff --git a/datafusion/substrait/tests/cases/roundtrip_logical_plan.rs 
b/datafusion/substrait/tests/cases/roundtrip_logical_plan.rs
index 922e3e16c8..7ed376f62b 100644
--- a/datafusion/substrait/tests/cases/roundtrip_logical_plan.rs
+++ b/datafusion/substrait/tests/cases/roundtrip_logical_plan.rs
@@ -491,6 +491,23 @@ async fn roundtrip_outer_join() -> Result<()> {
     roundtrip("SELECT data.a FROM data FULL OUTER JOIN data2 ON data.a = 
data2.a").await
 }
 
+#[tokio::test]
+async fn roundtrip_self_join() -> Result<()> {
+    // Substrait does currently NOT maintain the alias of the tables.
+    // Instead, when we consume Substrait, we add aliases before a join that'd 
otherwise collide.
+    // This roundtrip works because we set aliases to what the Substrait 
consumer will generate.
+    roundtrip("SELECT left.a as left_a, left.b, right.a as right_a, right.c 
FROM data AS left JOIN data AS right ON left.a = right.a").await?;
+    roundtrip("SELECT left.a as left_a, left.b, right.a as right_a, right.c 
FROM data AS left JOIN data AS right ON left.b = right.b").await
+}
+
+#[tokio::test]
+async fn roundtrip_self_implicit_cross_join() -> Result<()> {
+    // Substrait does currently NOT maintain the alias of the tables.
+    // Instead, when we consume Substrait, we add aliases before a join that'd 
otherwise collide.
+    // This roundtrip works because we set aliases to what the Substrait 
consumer will generate.
+    roundtrip("SELECT left.a left_a, left.b, right.a right_a, right.c FROM 
data AS left, data AS right").await
+}
+
 #[tokio::test]
 async fn roundtrip_arithmetic_ops() -> Result<()> {
     roundtrip("SELECT a - a FROM data").await?;
@@ -610,7 +627,22 @@ async fn simple_intersect() -> Result<()> {
 
 #[tokio::test]
 async fn simple_intersect_table_reuse() -> Result<()> {
-    roundtrip("SELECT count(1) FROM (SELECT data.a FROM data INTERSECT SELECT 
data.a FROM data);").await
+    // Substrait does currently NOT maintain the alias of the tables.
+    // Instead, when we consume Substrait, we add aliases before a join that'd 
otherwise collide.
+    // In this case the aliasing happens at a different point in the plan, so 
we cannot use roundtrip.
+    // Schema check works because we set aliases to what the Substrait 
consumer will generate.
+    assert_expected_plan(
+        "SELECT count(1) FROM (SELECT left.a FROM data AS left INTERSECT 
SELECT right.a FROM data AS right);",
+        "Aggregate: groupBy=[[]], aggr=[[count(Int64(1))]]\
+        \n  Projection: \
+        \n    LeftSemi Join: left.a = right.a\
+        \n      SubqueryAlias: left\
+        \n        Aggregate: groupBy=[[data.a]], aggr=[[]]\
+        \n          TableScan: data projection=[a]\
+        \n      SubqueryAlias: right\
+        \n        TableScan: data projection=[a]",
+        true
+    ).await
 }
 
 #[tokio::test]
@@ -628,32 +660,6 @@ async fn qualified_catalog_schema_table_reference() -> 
Result<()> {
     roundtrip("SELECT a,b,c,d,e FROM datafusion.public.data;").await
 }
 
-#[tokio::test]
-async fn roundtrip_inner_join_table_reuse_zero_index() -> Result<()> {
-    assert_expected_plan(
-        "SELECT d1.b, d2.c FROM data d1 JOIN data d2 ON d1.a = d2.a",
-        "Projection: data.b, data.c\
-         \n  Inner Join: data.a = data.a\
-         \n    TableScan: data projection=[a, b]\
-         \n    TableScan: data projection=[a, c]",
-        false, // "d1" vs "data" field qualifier
-    )
-    .await
-}
-
-#[tokio::test]
-async fn roundtrip_inner_join_table_reuse_non_zero_index() -> Result<()> {
-    assert_expected_plan(
-        "SELECT d1.b, d2.c FROM data d1 JOIN data d2 ON d1.b = d2.b",
-        "Projection: data.b, data.c\
-         \n  Inner Join: data.b = data.b\
-         \n    TableScan: data projection=[b]\
-         \n    TableScan: data projection=[b, c]",
-        false, // "d1" vs "data" field qualifier
-    )
-    .await
-}
-
 /// Construct a plan that contains several literals of types that are 
currently supported.
 /// This case ignores:
 /// - Date64, for this literal is not supported
@@ -707,20 +713,17 @@ async fn roundtrip_literal_struct() -> Result<()> {
 #[tokio::test]
 async fn roundtrip_values() -> Result<()> {
     // TODO: would be nice to have a struct inside the LargeList, but 
arrow_cast doesn't support that currently
-    let values = "(\
+    assert_expected_plan(
+        "VALUES \
+            (\
                 1, \
                 'a', \
                 [[-213.1, NULL, 5.5, 2.0, 1.0], []], \
                 arrow_cast([1,2,3], 'LargeList(Int64)'), \
                 STRUCT(true, 1 AS int_field, CAST(NULL AS STRING)), \
                 [STRUCT(STRUCT('a' AS string_field) AS struct_field)]\
-            )";
-
-    // Test LogicalPlan::Values
-    assert_expected_plan(
-        format!("VALUES \
-            {values}, \
-            (NULL, NULL, NULL, NULL, NULL, NULL)").as_str(),
+            ), \
+            (NULL, NULL, NULL, NULL, NULL, NULL)",
         "Values: \
             (\
                 Int64(1), \
@@ -731,11 +734,28 @@ async fn roundtrip_values() -> Result<()> {
                 List([{struct_field: {string_field: a}}])\
             ), \
             (Int64(NULL), Utf8(NULL), List(), LargeList(), 
Struct({c0:,int_field:,c2:}), List())",
-    true)
-        .await?;
+    true).await
+}
+
+#[tokio::test]
+async fn roundtrip_values_empty_relation() -> Result<()> {
+    roundtrip("SELECT * FROM (VALUES ('a')) LIMIT 0").await
+}
 
-    // Test LogicalPlan::EmptyRelation
-    roundtrip(format!("SELECT * FROM (VALUES {values}) LIMIT 
0").as_str()).await
+#[tokio::test]
+async fn roundtrip_values_duplicate_column_join() -> Result<()> {
+    // Substrait does currently NOT maintain the alias of the tables.
+    // Instead, when we consume Substrait, we add aliases before a join that'd 
otherwise collide.
+    // This roundtrip works because we set aliases to what the Substrait 
consumer will generate.
+    roundtrip(
+        "SELECT left.column1 as c1, right.column1 as c2 \
+    FROM \
+        (VALUES (1)) AS left \
+    JOIN \
+        (VALUES (2)) AS right \
+    ON left.column1 == right.column1",
+    )
+    .await
 }
 
 /// Construct a plan that cast columns. Only those SQL types are supported for 
now.


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

Reply via email to