From e5fcb6a27b0371958c67e5622c99f52eb1b3dae8 Mon Sep 17 00:00:00 2001 From: Daniel Reverri Date: Mon, 19 Oct 2026 13:44:32 -0400 Subject: [PATCH] fix(unparser): apply empty projection fallback to Projection nodes A `Projection` with no expressions (as `optimize_projections` leaves under `count(*)` when a filter column is not otherwise needed) was unparsed as `SELECT FROM ...` regardless of `Dialect::supports_empty_select_list`. Only the `TableScan` path consulted the dialect, so e.g. BigQuery received SELECT COUNT(1) FROM (SELECT FROM `t` WHERE ...) and rejected it with "SELECT list must not be empty". `reconstruct_select_statement` now uses `empty_projection_fallback` for an empty projection, the same as the table scan path: `SELECT 1` for dialects without empty select lists, unchanged for PostgreSQL. Co-Authored-By: Claude Opus 5.5 --- datafusion/core/tests/sql/unparser.rs | 20 ++++++++ datafusion/sql/src/unparser/plan.rs | 10 +++- datafusion/sql/tests/cases/plan_to_sql.rs | 57 +++++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/datafusion/core/tests/sql/unparser.rs b/datafusion/core/tests/sql/unparser.rs index e3674d8c387e1..ddc568c41b303 100644 --- a/datafusion/core/tests/sql/unparser.rs +++ b/datafusion/core/tests/sql/unparser.rs @@ -759,6 +759,26 @@ async fn optimized_filter_with_subquery_alias() -> Result<()> { Ok(()) } +/// `optimize_projections` prunes every column under `count(*)` when the +/// filter column isn't needed above the filter, leaving a `Projection` with +/// no expressions. Dialects without empty select list support must not +/// receive `SELECT FROM`. +#[tokio::test] +async fn optimized_count_over_filter_empty_projection() -> Result<()> { + let ctx = SessionContext::new(); + ctx.sql("create table t (id int)").await?.collect().await?; + let plan = ctx + .sql("select count(*) from t where id > 10") + .await? + .into_optimized_plan()?; + let sql = plan_to_sql(&plan)?.to_string(); + assert_eq!( + sql, + r#"SELECT count(1) AS "count(*)" FROM (SELECT 1 FROM t WHERE (t.id > 10))"# + ); + Ok(()) +} + #[tokio::test] async fn optimized_filter_after_projection() -> Result<()> { let ctx = SessionContext::new(); diff --git a/datafusion/sql/src/unparser/plan.rs b/datafusion/sql/src/unparser/plan.rs index 564de3a2816f6..755b592638aa3 100644 --- a/datafusion/sql/src/unparser/plan.rs +++ b/datafusion/sql/src/unparser/plan.rs @@ -311,7 +311,15 @@ impl Unparser<'_> { select: &mut SelectBuilder, fully_absorbed: bool, ) -> Result { - let mut exprs = p.expr.clone(); + // A projection of no columns (e.g. under `count(*)` once + // `optimize_projections` has pruned every input column) gets the + // same fallback as an empty table scan projection, so dialects + // that reject `SELECT FROM` receive `SELECT 1 FROM`. + let mut exprs = if p.expr.is_empty() { + self.empty_projection_fallback() + } else { + p.expr.clone() + }; // If an Unnest node is found within the select, find and unproject the unnest column let flatten_alias = select.current_flatten_alias(); diff --git a/datafusion/sql/tests/cases/plan_to_sql.rs b/datafusion/sql/tests/cases/plan_to_sql.rs index 2c08b5595ab74..b63a98e5dd6e4 100644 --- a/datafusion/sql/tests/cases/plan_to_sql.rs +++ b/datafusion/sql/tests/cases/plan_to_sql.rs @@ -1490,6 +1490,63 @@ fn test_table_scan_with_empty_projection_and_filter_default_dialect() { ); } +/// A `Projection` node with no expressions, as `optimize_projections` +/// leaves under `count(*)` when the filter column isn't otherwise needed. +/// +/// For `select count(*) from t where id > 10`, the optimized plan is: +/// +/// ```text +/// Projection: count(Int64(1)) AS count(*) +/// Aggregate: groupBy=[[]], aggr=[[count(Int64(1))]] +/// Projection: <-- empty +/// Filter: t.id > Int32(10) +/// TableScan: t projection=[id] +/// ``` +/// +/// See also `optimized_count_over_filter_empty_projection` in +/// `datafusion/core/tests/sql/unparser.rs`. +fn filter_with_empty_projection_helper() -> LogicalPlanBuilder { + table_scan(Some("table"), &test_schema(), None) + .unwrap() + .filter(col("id").gt(lit(10))) + .unwrap() + .project(Vec::::new()) + .unwrap() +} + +#[test] +fn test_empty_projection_node_default_dialect() { + let plan = filter_with_empty_projection_helper().build().unwrap(); + let unparser = Unparser::new(&UnparserDefaultDialect {}); + let sql = unparser.plan_to_sql(&plan).unwrap(); + assert_snapshot!( + sql, + @r#"SELECT 1 FROM "table" WHERE ("table".id > 10)"# + ); +} + +#[test] +fn test_empty_projection_node_postgres() { + let plan = filter_with_empty_projection_helper().build().unwrap(); + let unparser = Unparser::new(&UnparserPostgreSqlDialect {}); + let sql = unparser.plan_to_sql(&plan).unwrap(); + assert_snapshot!( + sql, + @r#"SELECT FROM "table" WHERE ("table"."id" > 10)"# + ); +} + +#[test] +fn test_empty_projection_node_bigquery() { + let plan = filter_with_empty_projection_helper().build().unwrap(); + let unparser = Unparser::new(&BigQueryDialect {}); + let sql = unparser.plan_to_sql(&plan).unwrap(); + assert_snapshot!( + sql, + @"SELECT 1 FROM `table` WHERE (`table`.`id` > 10)" + ); +} + fn table_scan_with_empty_projection_and_none_projection_helper( table_name: &str, table_schema: Schema,