You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@datafusion.apache.org by gi...@apache.org on 2026/04/23 02:12:01 UTC

(datafusion) 01/01: Unparser drops ORDER BY alias when flattening Projection through SubqueryAlias (#21491)

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

github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-21491-643db7a937388d671cf697e7090ff25d5256afa0
in repository https://gitbox.apache.org/repos/asf/datafusion.git

commit 95157ef206685850a558c98408c925c8dd524a08
Author: Yonatan Striem Amit <15...@users.noreply.github.com>
AuthorDate: Wed Apr 22 22:11:38 2026 -0400

    Unparser drops ORDER BY alias when flattening Projection through SubqueryAlias (#21491)
    
    ## Which issue does this PR close?
    
      - Closes #21490.
    
      ## Rationale for this change
    
    When `plan_to_sql` encounters a `Projection → Sort → Projection →
    SubqueryAlias` plan where the outer Projection excludes a Sort
    column defined as an alias in the inner Projection (e.g. `Z AS c`), the
    function `rewrite_plan_for_sort_on_non_projected_fields`
    flattens the two Projections into one but only keeps the outer
    Projection's columns. This drops the alias definition, leaving
    `ORDER BY c` referencing a column that no longer exists in the generated
    SQL.
    
      ## What changes are included in this PR?
    
    In `rewrite_plan_for_sort_on_non_projected_fields`
    (`datafusion/sql/src/unparser/rewrite.rs`), when the inner Projection is
    trimmed to only the outer Projection's expressions, sort expressions
    that reference dropped aliases are now inlined to the
    underlying physical expression. For example, `ORDER BY c` becomes `ORDER
    BY t."Z"` when `c` was defined as `Z AS c` in the
      dropped inner Projection.
    
      ## Are these changes tested?
    
    Yes. A new regression test
    `test_sort_on_aliased_column_dropped_by_outer_projection` in
    `datafusion/sql/tests/cases/plan_to_sql.rs` constructs the exact plan
    shape that triggers the bug and asserts the correct SQL
      output:
    
      ```sql
    SELECT t."X" AS a, t."Y" AS b FROM phys_table AS t ORDER BY t."Z" DESC
    NULLS FIRST LIMIT 1
      ```
    
      All existing `plan_to_sql` tests (118 tests) continue to pass.
    
      ## Are there any user-facing changes?
    
      No API changes. Previously invalid SQL is now generated correctly.
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 (1M context) <no...@anthropic.com>
---
 datafusion/sql/src/unparser/rewrite.rs    | 42 ++++++++++++++++++++++++++
 datafusion/sql/tests/cases/plan_to_sql.rs | 49 +++++++++++++++++++++++++++++++
 2 files changed, 91 insertions(+)

diff --git a/datafusion/sql/src/unparser/rewrite.rs b/datafusion/sql/src/unparser/rewrite.rs
index 723cdcd687..a6bfba4cca 100644
--- a/datafusion/sql/src/unparser/rewrite.rs
+++ b/datafusion/sql/src/unparser/rewrite.rs
@@ -254,6 +254,48 @@ pub(super) fn rewrite_plan_for_sort_on_non_projected_fields(
             .map(|e| map.get(e).unwrap_or(e).clone())
             .collect::<Vec<_>>();
 
+        // The inner Projection may define aliases that the Sort references
+        // but the outer Projection does not include.  Since we are about to
+        // replace the inner Projection's expressions with `new_exprs` (which
+        // only contains the outer Projection's columns), those alias
+        // definitions will be lost.  To keep the Sort valid, rewrite any
+        // sort expression that references a dropped alias so that it uses
+        // the alias's underlying expression instead.
+        let projected_aliases: HashSet<&str> = new_exprs
+            .iter()
+            .filter_map(|e| match e {
+                Expr::Alias(alias) => Some(alias.name.as_str()),
+                _ => None,
+            })
+            .collect();
+
+        let dropped_aliases: HashMap<String, Expr> = inner_p
+            .expr
+            .iter()
+            .filter_map(|e| match e {
+                Expr::Alias(alias)
+                    if !projected_aliases.contains(alias.name.as_str()) =>
+                {
+                    Some((alias.name.clone(), (*alias.expr).clone()))
+                }
+                _ => None,
+            })
+            .collect();
+
+        if !dropped_aliases.is_empty() {
+            for sort_expr in &mut sort.expr {
+                let mut expr = sort_expr.expr.clone();
+                while let Expr::Alias(alias) = expr {
+                    expr = *alias.expr;
+                }
+                if let Expr::Column(ref col) = expr
+                    && let Some(underlying) = dropped_aliases.get(col.name())
+                {
+                    sort_expr.expr = underlying.clone();
+                }
+            }
+        }
+
         inner_p.expr.clone_from(&new_exprs);
         sort.input = Arc::new(LogicalPlan::Projection(inner_p));
 
diff --git a/datafusion/sql/tests/cases/plan_to_sql.rs b/datafusion/sql/tests/cases/plan_to_sql.rs
index 6234a7e37b..88c60c8c7b 100644
--- a/datafusion/sql/tests/cases/plan_to_sql.rs
+++ b/datafusion/sql/tests/cases/plan_to_sql.rs
@@ -3008,6 +3008,55 @@ fn test_unparse_manual_join_with_subquery_aggregate() -> Result<()> {
     Ok(())
 }
 
+/// Regression test for https://github.com/apache/datafusion/issues/21490
+///
+/// When the outer Projection excludes a Sort column whose definition only
+/// exists as an alias in the inner Projection, the Unparser must inline the
+/// underlying expression into ORDER BY rather than emitting the now-missing
+/// alias name.
+#[test]
+fn test_sort_on_aliased_column_dropped_by_outer_projection() -> Result<()> {
+    let schema = Schema::new(vec![
+        Field::new("X", DataType::Utf8, true),
+        Field::new("Y", DataType::Utf8, true),
+        Field::new("Z", DataType::Utf8, true),
+    ]);
+
+    // Build:
+    //   Projection: [a, b]                         -- outer: excludes sort column "c"
+    //     Sort: [c DESC, fetch=1]                   -- references alias "c"
+    //       Projection: [X AS a, Y AS b, Z AS c]    -- defines alias "c"
+    //         SubqueryAlias: t
+    //           TableScan: phys_table [X, Y, Z]
+    let plan = table_scan(Some("phys_table"), &schema, None)?
+        .alias("t")?
+        .project(vec![
+            Expr::Column(Column::new(Some(TableReference::bare("t")), "X")).alias("a"),
+            Expr::Column(Column::new(Some(TableReference::bare("t")), "Y")).alias("b"),
+            Expr::Column(Column::new(Some(TableReference::bare("t")), "Z")).alias("c"),
+        ])?
+        .sort_with_limit(
+            vec![Expr::Column(Column::new_unqualified("c")).sort(false, true)],
+            Some(1),
+        )?
+        .project(vec![
+            Expr::Column(Column::new_unqualified("a")),
+            Expr::Column(Column::new_unqualified("b")),
+        ])?
+        .build()?;
+
+    let unparser = Unparser::default();
+    let sql = unparser.plan_to_sql(&plan)?;
+
+    // ORDER BY must reference the physical column, not the dropped alias.
+    assert_snapshot!(
+        sql,
+        @r#"SELECT t."X" AS a, t."Y" AS b FROM phys_table AS t ORDER BY t."Z" DESC NULLS FIRST LIMIT 1"#
+    );
+
+    Ok(())
+}
+
 #[test]
 fn snowflake_unnest_to_lateral_flatten_simple() -> Result<(), DataFusionError> {
     let snowflake = SnowflakeDialect::new();


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@datafusion.apache.org
For additional commands, e-mail: commits-help@datafusion.apache.org