目录

datafusion sql unparser 错误sql

datafusion sql unparser error 生成错误sql

问题描述

  • 从订单明细表取订单id,从订单表取订单id和折扣百分比,使用USING连接两个子查询,生成的优化后的SQL在DuckDB中无法执行。
  • 两个子查询join后,DataFusion 把优化后的 LogicalPlan 转回 SQL 时,生成的 SQL 里面 alias 作用域错了,所以 DuckDB 执行这个生成 SQL 会报错

复现


SELECT * FROM
(
    SELECT order_id
    FROM "warehouse"."main"."order_items"
) oi
JOIN (
    SELECT
        order_id,
        coalesce(discount_pct, 0) AS discount_pct_2
    FROM "warehouse"."main"."orders"
) o USING (order_id)

use std::sync::Arc;

use datafusion::arrow::datatypes::{DataType, Field, Schema};
use datafusion::catalog::{
    CatalogProvider, MemoryCatalogProvider, MemorySchemaProvider, SchemaProvider,
};
use datafusion::datasource::empty::EmptyTable;
use datafusion::prelude::*;
use datafusion::sql::unparser::dialect::DuckDBDialect;
use datafusion::sql::unparser::Unparser;
use duckdb::Connection;

const QUERY: &str = r#"
SELECT * FROM
(
SELECT
        order_id
    FROM
        "warehouse"."main"."order_items"
) oi
JOIN (
    SELECT
        order_id,
        coalesce(discount_pct, 0) AS discount_pct_2
    FROM
        "warehouse"."main"."orders"
) o USING (order_id)
"#;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let ctx = build_datafusion_context()?;
    let conn = Connection::open_in_memory()?;
    register_duckdb_tables(&conn)?;

    let dialect = DuckDBDialect::new();
    // Unparser 是用来把 DataFusion 的内部计划再转回 SQL 字符串 的
    let unparser = Unparser::new(&dialect);

    let unoptimized_plan = ctx.sql(QUERY).await?.into_unoptimized_plan();
    let unoptimized_sql = unparser.plan_to_sql(&unoptimized_plan)?;
    println!("=== Unoptimized SQL ===\n{unoptimized_sql}\n");

    match conn.prepare(&unoptimized_sql.to_string()) {
        Ok(_) => println!("unoptimized query is valid in DuckDB\n"),
        Err(e) => println!("unoptimized query is not valid in DuckDB: {e}\n"),
    }

    let optimized_plan = ctx.sql(QUERY).await?.into_optimized_plan()?;
    let optimized_sql = match unparser.plan_to_sql(&optimized_plan) {
        Ok(sql) => sql,
        Err(e) => {
            println!("reproduced #23138: optimized plan cannot be unparsed");
            println!("Unparser error: {e}");
            println!(
                "=== Optimized Plan ===\n{}",
                optimized_plan.display_indent()
            );
            return Ok(());
        }
    };

    println!("=== Optimized SQL ===\n{optimized_sql}\n");
    println!(
        "=== Optimized Plan ===\n{}\n",
        optimized_plan.display_indent()
    );

    match conn.prepare(&optimized_sql.to_string()) {
        Ok(_) => println!("unexpected: optimized query is valid in DuckDB"),
        Err(e) => {
            println!("reproduced #23138: optimized query is invalid in DuckDB");
            println!("DuckDB error: {e}");
        }
    }

    Ok(())
}

fn register_duckdb_tables(conn: &Connection) -> duckdb::Result<()> {
    conn.execute_batch(
        r#"
        ATTACH ':memory:' AS warehouse;
        CREATE TABLE warehouse.main.order_items(order_id INTEGER);
        CREATE TABLE warehouse.main.orders(
            order_id INTEGER NOT NULL,
            customer_id INTEGER,
            discount_pct DECIMAL(5, 2)
        );
        "#,
    )
}

/// catalog  ≈ 数据库实例 / 数据目录
/// schema   ≈ 库 / 命名空间
/// table    ≈ 表
fn build_datafusion_context() -> datafusion::common::Result<SessionContext> {
    // 构建订单项 schema
    let order_items_schema = Arc::new(Schema::new(vec![Field::new(
        "order_id",
        DataType::Int32,
        true,
    )]));

    // 构建订单 schema
    let orders_schema = Arc::new(Schema::new(vec![
        Field::new("order_id", DataType::Int32, false),
        Field::new("customer_id", DataType::Int32, true),
        Field::new("discount_pct", DataType::Decimal128(5, 2), true),
    ]));


    let schema_provider = Arc::new(MemorySchemaProvider::new());
    schema_provider.register_table(
        "order_items".to_string(),
        Arc::new(EmptyTable::new(order_items_schema)),
    )?;
    schema_provider.register_table(
        "orders".to_string(),
        Arc::new(EmptyTable::new(orders_schema)),
    )?;

    let catalog = Arc::new(MemoryCatalogProvider::new());
    catalog.register_schema("main", schema_provider)?;

    let ctx = SessionContext::new();
    ctx.register_catalog("warehouse", catalog);

    Ok(ctx)
}
  • 执行后报错了

=== Unoptimized SQL ===
SELECT "o"."order_id", "o"."discount_pct_2" FROM (SELECT "order_items"."order_id" FROM "warehouse"."main"."order_items") AS "oi" INNER JOIN (SELECT "orders"."order_id", coalesce("orders"."discount_pct", 0) AS "discount_pct_2" FROM "warehouse"."main"."orders") AS "o" USING("order_id")

unoptimized query is valid in DuckDB

=== Optimized SQL ===
SELECT "o"."order_id", "o"."discount_pct_2" FROM "warehouse"."main"."order_items" AS "oi" INNER JOIN (SELECT "o"."order_id", CASE WHEN "__common_expr_1" IS NOT NULL THEN "__common_expr_1" ELSE 0.00 END AS "discount_pct_2" FROM (SELECT CAST("o"."discount_pct" AS DECIMAL(22,2)) AS "__common_expr_1", "o"."order_id" FROM "warehouse"."main"."orders" AS "o")) AS "o" ON "oi"."order_id" = "o"."order_id"

=== Optimized Plan ===
Projection: o.order_id, o.discount_pct_2
  Inner Join: oi.order_id = o.order_id
    SubqueryAlias: oi
      TableScan: warehouse.main.order_items projection=[order_id]
    SubqueryAlias: o
      Projection: warehouse.main.orders.order_id, CASE WHEN __common_expr_1 IS NOT NULL THEN __common_expr_1 ELSE Decimal128(0.00,22,2) END AS discount_pct_2
        Projection: CAST(warehouse.main.orders.discount_pct AS Decimal128(22, 2)) AS __common_expr_1, warehouse.main.orders.order_id
          TableScan: warehouse.main.orders projection=[order_id, discount_pct]

reproduced #23138: optimized query is invalid in DuckDB
DuckDB error: Binder Error: Referenced table "o" not found!
Candidate tables: "oi"

LINE 1: ... "warehouse"."main"."order_items" AS "oi" INNER JOIN (SELECT "o"."order_id", CASE WHEN "__common_expr_1" IS NOT NULL...

ai 如何解决的

// datafusion/sql/src/unparser/plan.rs
//
// AI 本地试修思路:
// 当 Projection 下面还是 Projection 时,说明外层 Projection 面对的是 derived table,
// 不能继续把列重写成 "o"."order_id",而应该重写成 derived table 输出列 "order_id"。

use crate::unparser::rewrite::{
    ProjectionInputRewriter, TableAliasRewriter, inject_column_aliases_into_subquery,
};

// ...

LogicalPlan::Projection(projection) => {
    let input_is_projection =
        matches!(projection.input.as_ref(), LogicalPlan::Projection(_));

    if let Some(plan) = self.unparse_table_scan_pushdown(
        &projection.input,
        alias.clone(),
        already_projected,
    )? {
        let exprs = if input_is_projection {
            let mut input_rewriter = ProjectionInputRewriter {
                input_schema: plan.schema().as_ref(),
            };
            projection
                .expr
                .iter()
                .cloned()
                .map(|expr| expr.rewrite(&mut input_rewriter).data())
                .collect::<Result<Vec<_>>>()?
        } else if alias.is_some() {
            let mut alias_rewriter =
                alias.as_ref().map(|alias_name| TableAliasRewriter {
                    table_schema: plan.schema().as_ref(),
                    alias_name: alias_name.clone(),
                    rewrite_unqualified: false,
                });
            projection
                .expr
                .iter()
                .cloned()
                .map(|expr| {
                    if let Some(ref mut rewriter) = alias_rewriter {
                        expr.rewrite(rewriter).data()
                    } else {
                        Ok(expr)
                    }
                })
                .collect::<Result<Vec<_>>>()?
        } else {
            projection.expr.clone()
        };

        Ok(Some(LogicalPlan::Projection(
            Projection::try_new_with_schema(
                exprs,
                Arc::new(plan),
                Arc::clone(&projection.schema),
            )?,
        )))
    } else {
        Ok(None)
    }
}
// datafusion/sql/src/unparser/rewrite.rs
//
// AI 本地试修新增的 rewriter:
// 用 input projection 的 schema,把原始 qualified column 改成 derived table 输出列。

/// A `TreeNodeRewriter` implementation that rewrites `Expr::Column` expressions
/// to the field names produced by an input projection.
///
/// This is used when a projection is rebuilt on top of another projection that
/// will be unparsed as a derived subquery. In that SQL scope, the original table
/// qualifier is no longer visible, and the outer projection must refer to the
/// derived subquery's output columns by name.
pub struct ProjectionInputRewriter<'a> {
    pub input_schema: &'a DFSchema,
}

impl TreeNodeRewriter for ProjectionInputRewriter<'_> {
    type Node = Expr;

    fn f_down(&mut self, expr: Expr) -> Result<Transformed<Expr>> {
        match expr {
            Expr::Column(column) => {
                match self
                    .input_schema
                    .qualified_field_from_column(&column)
                    .or_else(|_| {
                        self.input_schema
                            .qualified_field_with_unqualified_name(&column.name)
                    }) {
                    Ok((_qualifier, field)) => {
                        let new_column = Column::new_unqualified(field.name().clone());
                        Ok(Transformed::yes(Expr::Column(new_column)))
                    }
                    Err(_) => Ok(Transformed::no(Expr::Column(column))),
                }
            }
            _ => Ok(Transformed::no(expr)),
        }
    }
}
// datafusion/core/tests/sql/unparser.rs
//
// AI 本地试修加的最小回归测试:
// 确认 optimized SQL 不再生成 out-of-scope alias: "o"."order_id"。

const ISSUE_23138_QUERY: &str = r#"
SELECT * FROM
(
SELECT
        order_id
    FROM
        "warehouse"."main"."order_items"
) oi
JOIN (
    SELECT
        order_id,
        coalesce(discount_pct, 0) AS discount_pct_2
    FROM
        "warehouse"."main"."orders"
) o USING (order_id)
"#;

#[tokio::test]
async fn optimized_duckdb_unparse_resolves_columns_in_outer_query() -> Result<()> {
    let ctx = issue_22961_context()?;
    let plan = ctx.sql(ISSUE_23138_QUERY).await?.into_optimized_plan()?;
    let dialect = DuckDBDialect::new();
    let unparser = Unparser::new(&dialect);
    let sql = unparser.plan_to_sql(&plan)?.to_string();

    assert!(
        !sql.contains(r#"INNER JOIN (SELECT "o"."order_id""#),
        "unparsed SQL references derived table alias before it is in scope: {sql}"
    );
    assert!(
        sql.contains(r#"INNER JOIN (SELECT "order_id", CASE"#),
        "unparsed SQL should resolve columns against the derived query output: {sql}"
    );

    Ok(())
}

官方解决

// datafusion/sql/src/unparser/plan.rs
//
// 官方 PR #23176:
// fix: unparse columns of stacked pushdown projections unqualified
//
// 核心:直接把 stacked pushdown projection 里的 column qualifier 去掉。

/// Strip the table qualifier from every column in a pushdown pass-through
/// projection expression, so it resolves against the unnamed derived table
/// rendered for the inner pushdown projection rather than a deeper table
/// alias that is out of scope at this nesting level.
fn strip_pushdown_column_qualifiers(expr: Expr) -> Result<Expr> {
    expr.transform(|e| match e {
        Expr::Column(mut column) => {
            column.relation = None;
            Ok(Transformed::yes(Expr::Column(column)))
        }
        other => Ok(Transformed::no(other)),
    })
    .data()
}
// datafusion/sql/src/unparser/plan.rs
//
// 官方 PR #23176 在 unparse_table_scan_pushdown 的 Projection 分支里加的逻辑。

LogicalPlan::Projection(projection) => {
    if let Some(plan) = self.unparse_table_scan_pushdown(
        &projection.input,
        alias.clone(),
        already_projected,
    )? {
        // The pushed-down scan alias is only in scope for the
        // projection directly above the aliased table scan. `plan`
        // is the result of pushing the alias further down: if it is
        // itself a `Projection`, the input was another projection
        // (e.g. common subexpression elimination stacked one), so
        // this projection sits over a derived table rather than
        // directly over the aliased scan, and the alias is out of
        // scope here. Its qualified pass-through columns must then
        // reference the derived table's output unqualified instead
        // of being rebased to the alias. Build it directly so the
        // unqualified columns are not re-normalized back to the
        // alias. (Otherwise `plan` is the scan-derived plan and we
        // fall through to rebase to the alias, correct one level
        // above the scan.)
        if alias.is_some() && matches!(plan, LogicalPlan::Projection(_)) {
            let exprs = projection
                .expr
                .iter()
                .cloned()
                .map(Self::strip_pushdown_column_qualifiers)
                .collect::<Result<Vec<_>>>()?;
            return Ok(Some(LogicalPlan::Projection(Projection::try_new(
                exprs,
                Arc::new(plan),
            )?)));
        }

        let exprs = if alias.is_some() {
            let mut alias_rewriter =
                alias.as_ref().map(|alias_name| TableAliasRewriter {
                    table_schema: plan.schema().as_ref(),
                    alias_name: alias_name.clone(),
                    rewrite_unqualified: false,
                });

            projection
                .expr
                .iter()
                .cloned()
                .map(|expr| {
                    if let Some(ref mut rewriter) = alias_rewriter {
                        expr.rewrite(rewriter).data()
                    } else {
                        Ok(expr)
                    }
                })
                .collect::<Result<Vec<_>>>()?
        } else {
            projection.expr.clone()
        };

        Ok(Some(LogicalPlan::Projection(Projection::try_new(
            exprs,
            Arc::new(plan),
        )?)))
    } else {
        Ok(None)
    }
}
// datafusion/core/tests/sql/unparser.rs
//
// 官方 PR #23176 加的回归测试。

// https://github.com/apache/datafusion/issues/23138
//
// CSE on `coalesce(discount_pct, 0)` factors a shared CAST into an extra inner
// projection, so `SubqueryAlias: o` ends up over two stacked projections. When
// the unparser renders that as nested derived tables it must qualify the
// pass-through `order_id` with a name in scope at each level -- it must not
// rebase it to the outer subquery alias `o`, which is not visible inside the
// inner derived table.
const ISSUE_23138_QUERY: &str = r#"
SELECT * FROM
(
    SELECT order_id FROM "warehouse"."main"."order_items"
) oi
JOIN (
    SELECT order_id, coalesce(discount_pct, 0) AS discount_pct_2
    FROM "warehouse"."main"."orders"
) o USING (order_id)
"#;

#[tokio::test]
async fn optimized_duckdb_unparse_qualifies_nested_passthrough_column() -> Result<()> {
    let ctx = issue_22961_context()?;
    let plan = ctx.sql(ISSUE_23138_QUERY).await?.into_optimized_plan()?;
    let dialect = DuckDBDialect::new();
    let unparser = Unparser::new(&dialect);
    let sql = unparser.plan_to_sql(&plan)?.to_string();

    // The intermediate derived table has no `o` in scope, so the pass-through
    // `order_id` must be unqualified there, not rebased to the subquery alias
    // `o` (which is only the base-table alias one level deeper). The bug emitted
    // `"o"."order_id"` inside that derived table; the fix emits a bare column.
    let expected = concat!(
        r#"SELECT "o"."order_id", "o"."discount_pct_2" "#,
        r#"FROM "warehouse"."main"."order_items" AS "oi" "#,
        r#"INNER JOIN (SELECT "order_id", "#,
        r#"CASE WHEN "__common_expr_1" IS NOT NULL "#,
        r#"THEN "__common_expr_1" ELSE 0.00 END AS "discount_pct_2" "#,
        r#"FROM (SELECT CAST("o"."discount_pct" AS DECIMAL(22,2)) "#,
        r#"AS "__common_expr_1", "o"."order_id" "#,
        r#"FROM "warehouse"."main"."orders" AS "o")) AS "o" "#,
        r#"ON "oi"."order_id" = "o"."order_id""#,
    );
    assert_eq!(sql, expected);

    assert!(
        sql.contains(r#"(SELECT "order_id", CASE WHEN"#),
        "pass-through order_id should be unqualified in derived table: {sql}"
    );
    assert!(
        !sql.contains(r#"(SELECT "o"."order_id", CASE WHEN"#),
        "derived table must not reference out-of-scope alias o: {sql}"
    );

    Ok(())
}

总结

// #23138 的本质可以用这两个 SQL 片段总结。

// 修复前:错误。
// 这一层 SELECT 里面,"o" 还不在作用域里。
let bad_sql = r#"
INNER JOIN (
  SELECT "o"."order_id",
         CASE WHEN "__common_expr_1" IS NOT NULL
              THEN "__common_expr_1"
              ELSE 0.00
         END AS "discount_pct_2"
  FROM (
    SELECT CAST("o"."discount_pct" AS DECIMAL(22,2)) AS "__common_expr_1",
           "o"."order_id"
    FROM "warehouse"."main"."orders" AS "o"
  )
) AS "o" ON ...
"#;

// 修复后:正确。
// 外层 derived table 只能引用自己 input 的输出列,所以这里应该是 "order_id"。
let good_sql = r#"
INNER JOIN (
  SELECT "order_id",
         CASE WHEN "__common_expr_1" IS NOT NULL
              THEN "__common_expr_1"
              ELSE 0.00
         END AS "discount_pct_2"
  FROM (
    SELECT CAST("o"."discount_pct" AS DECIMAL(22,2)) AS "__common_expr_1",
           "o"."order_id"
    FROM "warehouse"."main"."orders" AS "o"
  )
) AS "o" ON ...
"#;
// 最终结论:
//
// LogicalPlan 没坏。
// Optimizer 引入 __common_expr_1 也没坏。
// 坏在 SQL Unparser 把 stacked projection 转回 SQL 时,
// 把内层 table alias 错误带到了外层 derived table。

fn issue_23138_summary() {
    let problem = "SubqueryAlias(o) + Projection + Projection + TableScan";
    let wrong = r#""o"."order_id""#;
    let right = r#""order_id""#;

    println!("problem shape: {problem}");
    println!("wrong generated column: {wrong}");
    println!("right generated column: {right}");
}