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}");
}