Skip to content

Commit 947b941

Browse files
committed
fix(unparser): unparse a stacked aggregate as a derived table
A SELECT expresses a single grouping, but the `LogicalPlan::Aggregate` arm of `select_to_sql_recursively` recursed straight into its input whenever the select list was already built, so a second aggregate underneath was skipped and its `GROUP BY` never reached the emitted SQL. `single_distinct_to_groupby` produces exactly that shape for `count(DISTINCT c)`: an outer `count(alias1)` over an inner `Aggregate` grouping by `c AS alias1`. With the inner aggregate dropped, `SELECT count(DISTINCT "UserID") FROM hits` unparses to `SELECT count(alias1) FROM hits` — `alias1` does not exist on the base table, so a consumer pushing the optimized plan down to a remote engine gets a binder error, and where a column of that name does exist it counts every row rather than the distinct values. Track on the `SelectBuilder` whether an aggregate has been folded into the current SELECT, and emit a `derived_aggregate` table for the next one, as the `Sort`, `Limit`, `Distinct` and `Projection` arms already do. The existing TPC-H and ClickBench roundtrip suites unparse the plan as the SQL planner produces it, before `single_distinct_to_groupby` runs, which is why they never caught this; the new core test unparses the optimized plan and executes both statements.
1 parent b8b9592 commit 947b941

4 files changed

Lines changed: 141 additions & 0 deletions

File tree

datafusion/core/tests/sql/unparser.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,3 +464,40 @@ async fn test_tpch_unparser_roundtrip() {
464464
set_stack_allocation_size(8 * 1024 * 1024);
465465
run_roundtrip_tests("TPC-H", tpch_queries(), tpch_test_context).await;
466466
}
467+
468+
/// The suites above unparse the plan as it comes out of the SQL planner. A consumer that
469+
/// pushes a query down to a remote engine unparses the *optimized* plan, where
470+
/// `single_distinct_to_groupby` has rewritten `count(DISTINCT c)` into an outer
471+
/// `count(alias1)` over an inner `GROUP BY c AS alias1`. Both aggregates have to reach the
472+
/// emitted SQL: with the inner one dropped, `count(alias1)` is left referencing the base
473+
/// table, which fails to bind — and where a column of that name does exist, counts every
474+
/// row instead of the distinct values.
475+
#[tokio::test]
476+
async fn test_optimized_plan_roundtrip_count_distinct() -> Result<()> {
477+
let ctx = clickbench_test_context().await?;
478+
let unparser = Unparser::new(&DefaultDialect {});
479+
480+
for sql in [
481+
r#"SELECT COUNT(DISTINCT "UserID") AS c FROM hits"#,
482+
r#"SELECT "RegionID", COUNT(DISTINCT "UserID") AS c FROM hits GROUP BY "RegionID""#,
483+
] {
484+
let optimized = ctx.sql(sql).await?.into_optimized_plan()?;
485+
let unparsed = format!("{:#}", unparser.plan_to_sql(&optimized)?);
486+
487+
let expected = sort_batches(&ctx, ctx.sql(sql).await?.collect().await?).await?;
488+
let actual_df = ctx.sql(&unparsed).await.map_err(|e| {
489+
datafusion_common::DataFusionError::Context(
490+
format!("unparsed SQL failed to plan: {unparsed}"),
491+
Box::new(e),
492+
)
493+
})?;
494+
let actual = sort_batches(&ctx, actual_df.collect().await?).await?;
495+
496+
assert_eq!(
497+
expected, actual,
498+
"unparsed optimized plan returned different rows.\nOriginal SQL:\n{sql}\n\nUnparsed SQL:\n{unparsed}"
499+
);
500+
}
501+
502+
Ok(())
503+
}

datafusion/sql/src/unparser/ast.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,12 @@ pub struct SelectBuilder {
170170
/// Table aliases that correspond to LATERAL FLATTEN relations.
171171
/// Column references into these aliases must use `VALUE` as the column name.
172172
flatten_table_aliases: Vec<String>,
173+
/// Whether a `LogicalPlan::Aggregate` has already been folded into this SELECT,
174+
/// as its select list and `GROUP BY`. A SELECT expresses at most one grouping, so
175+
/// a second aggregate below it belongs in a derived table.
176+
///
177+
/// Set with `mark_aggregated()` and read with `already_aggregated()`.
178+
aggregated: bool,
173179
}
174180

175181
/// Prefix used for auto-generated LATERAL FLATTEN table aliases.
@@ -198,6 +204,16 @@ impl SelectBuilder {
198204
self.flatten_table_aliases.iter().any(|a| a == alias)
199205
}
200206

207+
/// Record that an aggregate node is now expressed by this SELECT.
208+
pub fn mark_aggregated(&mut self) {
209+
self.aggregated = true;
210+
}
211+
212+
/// Returns true if an aggregate node has already been folded into this SELECT.
213+
pub fn already_aggregated(&self) -> bool {
214+
self.aggregated
215+
}
216+
201217
/// Returns the most recently generated flatten alias, or `None` if
202218
/// `next_flatten_alias` has not been called yet.
203219
pub fn current_flatten_alias(&self) -> Option<String> {
@@ -425,6 +441,7 @@ impl SelectBuilder {
425441
flavor: Some(SelectFlavor::Standard),
426442
flatten_alias_counter: 0,
427443
flatten_table_aliases: Vec::new(),
444+
aggregated: false,
428445
}
429446
}
430447
}

datafusion/sql/src/unparser/plan.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,6 +884,24 @@ impl Unparser<'_> {
884884
)
885885
}
886886
LogicalPlan::Aggregate(agg) => {
887+
// A SELECT expresses a single grouping, so an aggregate stacked below the
888+
// one this SELECT already carries has to become a derived table. Stacked
889+
// aggregates are what `single_distinct_to_groupby` produces for
890+
// `count(DISTINCT c)`: an outer `count(alias1)` over an inner
891+
// `GROUP BY c AS alias1`. Folding both into one SELECT would emit
892+
// `count(alias1)` against the base table — `alias1` does not exist there,
893+
// and where it happens to, the DISTINCT is silently gone.
894+
if select.already_aggregated() {
895+
return self.derive_with_dialect_alias(
896+
"derived_aggregate",
897+
plan,
898+
relation,
899+
false,
900+
vec![],
901+
);
902+
}
903+
select.mark_aggregated();
904+
887905
// Aggregation can be already handled in the projection case
888906
if !select.already_projected() {
889907
// The query returns aggregate and group expressions. If that weren't the case,

datafusion/sql/tests/cases/plan_to_sql.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4274,3 +4274,72 @@ fn test_unparse_chained_intersect_build_side_is_self_contained() -> Result<()> {
42744274
);
42754275
Ok(())
42764276
}
4277+
4278+
/// Builds `count(<col>)` with the aggregate-function stub used across these tests.
4279+
fn count_col(name: &str) -> Expr {
4280+
use datafusion_expr::expr::{AggregateFunction, AggregateFunctionParams};
4281+
Expr::AggregateFunction(AggregateFunction {
4282+
func: count_udaf(),
4283+
params: AggregateFunctionParams {
4284+
args: vec![col(name)],
4285+
distinct: false,
4286+
filter: None,
4287+
order_by: vec![],
4288+
null_treatment: None,
4289+
},
4290+
})
4291+
}
4292+
4293+
/// A SELECT carries a single grouping, so an aggregate stacked directly on top of
4294+
/// another has to be unparsed as a derived table. This is the plan
4295+
/// `single_distinct_to_groupby` produces for `count(DISTINCT b)`: an outer
4296+
/// `count(alias1)` over an inner `GROUP BY b AS alias1`. Folding both into one SELECT
4297+
/// emits `count(alias1)` against the base table, where `alias1` does not exist — and
4298+
/// where a column of that name happens to exist, the DISTINCT is silently dropped.
4299+
#[test]
4300+
fn stacked_aggregate_is_unparsed_as_a_derived_table() -> Result<()> {
4301+
let schema = Schema::new(vec![
4302+
Field::new("a", DataType::UInt32, false),
4303+
Field::new("b", DataType::UInt32, false),
4304+
]);
4305+
4306+
// count(DISTINCT b) — the outer aggregate groups by nothing.
4307+
let plan = table_scan(Some("test"), &schema, None)?
4308+
.aggregate(vec![col("test.b").alias("alias1")], Vec::<Expr>::new())?
4309+
.aggregate(Vec::<Expr>::new(), vec![count_col("alias1")])?
4310+
.project(vec![col("COUNT(alias1)").alias("count(DISTINCT test.b)")])?
4311+
.build()?;
4312+
assert_snapshot!(
4313+
plan_to_sql(&plan)?,
4314+
@r#"SELECT COUNT(alias1) AS "count(DISTINCT test.b)" FROM (SELECT test.b AS alias1 FROM test GROUP BY test.b)"#
4315+
);
4316+
4317+
// a, count(DISTINCT b) ... GROUP BY a — the outer aggregate keeps its own grouping,
4318+
// which must not absorb the inner one.
4319+
let plan = table_scan(Some("test"), &schema, None)?
4320+
.aggregate(
4321+
vec![col("test.a"), col("test.b").alias("alias1")],
4322+
Vec::<Expr>::new(),
4323+
)?
4324+
.aggregate(vec![col("test.a")], vec![count_col("alias1")])?
4325+
.project(vec![
4326+
col("test.a"),
4327+
col("COUNT(alias1)").alias("count(DISTINCT test.b)"),
4328+
])?
4329+
.build()?;
4330+
assert_snapshot!(
4331+
plan_to_sql(&plan)?,
4332+
@r#"SELECT a, COUNT(alias1) AS "count(DISTINCT test.b)" FROM (SELECT test.a, test.b AS alias1 FROM test GROUP BY test.a, test.b) GROUP BY test.a"#
4333+
);
4334+
4335+
// A lone aggregate is still folded into the SELECT it belongs to.
4336+
let plan = table_scan(Some("test"), &schema, None)?
4337+
.aggregate(vec![col("test.a")], vec![count_col("test.b")])?
4338+
.build()?;
4339+
assert_snapshot!(
4340+
plan_to_sql(&plan)?,
4341+
@"SELECT COUNT(test.b), test.a FROM test GROUP BY test.a"
4342+
);
4343+
4344+
Ok(())
4345+
}

0 commit comments

Comments
 (0)