Skip to content

Commit 6a31aa8

Browse files
fix: keep a DISTINCT aggregate's arity in SingleDistinctToGroupBy (#25461)
## Which issue does this PR close? - Closes #25417. ## Rationale for this change `is_single_distinct_agg` requires that every distinct argument in the aggregation is the same expression. It checks that by collecting the arguments into a set and requiring the set to hold exactly one element: ```rust if *distinct { for e in args { fields_set.insert(e); } ``` A call that passes that expression more than once satisfies the check, because its arguments collapse to a single entry. `corr(DISTINCT x, x)` is such a call, and the rewrite below then assumed one argument per distinct aggregate and returned an internal error. This is also why the neighbouring cases in the issue are unaffected: `corr(DISTINCT x, y)` and `corr(DISTINCT x, x + 0.0)` put two entries in the set, so the rule bails out early and leaves the plan alone. ## What changes are included in this PR? The distinct branch keeps the call's arity, repeating the alias that the inner group by produces, so `corr(DISTINCT x, x)` becomes a group by `x` with `corr(alias1, alias1)` above it. That is sound because of the check above. Every distinct argument is the same expression, so the distinct argument tuples the call aggregates are exactly the distinct values of that expression, which is what grouping by it produces. The assertion stays, now stating the invariant the rewrite actually relies on rather than an arity the rule does not enforce. It also covers an empty argument list, which would otherwise panic in `args.swap_remove(0)` instead of reporting an internal error. ## What is the testing strategy for this PR? Rule tests in `single_distinct_to_groupby.rs`: - `single_distinct_repeated_arg_and_groupby`: `corr(DISTINCT b, b)` is rewritten, and the plan keeps both arguments and the original output column name. - `single_distinct_two_args_and_groupby`: `corr(DISTINCT b, c)` is still left alone. Two queries in `aggregate.slt` cover it end to end. One is `covar_samp`, chosen because its value depends on the de-duplication rather than only on the absence of the error: over the same input it returns `0.5` with `DISTINCT` and `0.333333333333` without, so the expected value pins the semantics. Measured, rather than assumed: | check | result | | --- | --- | | both halves of the fix reverted | the two `aggregate.slt` queries and `single_distinct_repeated_arg_and_groupby` fail with an `Assertion failed: args.len() == 1` internal error, which is the failure in the issue | | the rewrite against a reference the rule does not touch | the same aggregates over a manually de-duplicated input return the same values | `cargo test -p datafusion-optimizer` (915 tests), the sqllogictest suite (521 files), and `./ci/scripts/rust_clippy.sh` pass. ## Are there any user-facing changes? Queries that failed with an internal error now run. No plan that the rule rewrote before is rewritten differently: a single argument call produces the same outer aggregate as it did, and the only calls that reach this branch with more than one argument are the ones that previously returned the error.
1 parent 9fc84ae commit 6a31aa8

2 files changed

Lines changed: 93 additions & 6 deletions

File tree

‎datafusion/optimizer/src/single_distinct_to_groupby.rs‎

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ use crate::optimizer::ApplyOrder;
2323
use crate::{OptimizerConfig, OptimizerRule};
2424

2525
use datafusion_common::{
26-
DFSchema, DataFusionError, HashSet, Result, assert_eq_or_internal_err,
26+
DFSchema, DataFusionError, HashSet, Result, assert_or_internal_err,
2727
tree_node::Transformed,
2828
};
2929
use datafusion_expr::builder::project;
@@ -314,11 +314,18 @@ impl OptimizerRule for SingleDistinctToGroupBy {
314314
},
315315
}) => {
316316
if distinct {
317-
assert_eq_or_internal_err!(
318-
args.len(),
319-
1,
320-
"DISTINCT aggregate should have exactly one argument"
317+
// `is_single_distinct_agg` checked that every
318+
// distinct argument in the plan is the same
319+
// expression. A call may still take it more than
320+
// once, as `corr(DISTINCT x, x)` does, and keeps
321+
// its arity here: its distinct argument tuples
322+
// are the distinct values of that expression.
323+
assert_or_internal_err!(
324+
args.first()
325+
.is_some_and(|f| args.iter().all(|a| a == f)),
326+
"DISTINCT aggregate arguments should be the same expression"
321327
);
328+
let arity = args.len();
322329
let arg = args.swap_remove(0);
323330

324331
if group_fields_set.insert(arg.schema_name().to_string())
@@ -329,7 +336,7 @@ impl OptimizerRule for SingleDistinctToGroupBy {
329336
let outer =
330337
Expr::AggregateFunction(AggregateFunction::new_udf(
331338
func,
332-
vec![col(SINGLE_DISTINCT_ALIAS)],
339+
vec![col(SINGLE_DISTINCT_ALIAS); arity],
333340
false, // intentional to remove distinct here
334341
filter,
335342
order_by,
@@ -449,6 +456,7 @@ mod tests {
449456
lit,
450457
logical_plan::builder::{LogicalPlanBuilder, table_scan},
451458
};
459+
use datafusion_functions_aggregate::correlation::corr_udaf;
452460
use datafusion_functions_aggregate::count::count_udaf;
453461
use datafusion_functions_aggregate::expr_fn::{count, count_distinct, max, min, sum};
454462
use datafusion_functions_aggregate::min_max::max_udaf;
@@ -465,6 +473,17 @@ mod tests {
465473
))
466474
}
467475

476+
fn corr_distinct(y: Expr, x: Expr) -> Expr {
477+
Expr::AggregateFunction(AggregateFunction::new_udf(
478+
corr_udaf(),
479+
vec![y, x],
480+
true,
481+
None,
482+
vec![],
483+
None,
484+
))
485+
}
486+
468487
fn sum_distinct(expr: Expr) -> Expr {
469488
Expr::AggregateFunction(AggregateFunction::new_udf(
470489
sum_udaf(),
@@ -770,6 +789,46 @@ mod tests {
770789
)
771790
}
772791

792+
#[test]
793+
fn single_distinct_repeated_arg_and_groupby() -> Result<()> {
794+
let table_scan = test_table_scan()?;
795+
796+
let plan = LogicalPlanBuilder::from(table_scan)
797+
.aggregate(vec![col("a")], vec![corr_distinct(col("b"), col("b"))])?
798+
.build()?;
799+
800+
// Should work: the call takes the distinct argument twice, so it keeps
801+
// both arguments after the rewrite.
802+
assert_optimized_plan_equal!(
803+
plan,
804+
@r"
805+
Projection: test.a, corr(alias1,alias1) AS corr(DISTINCT test.b,test.b) [a:UInt32, corr(DISTINCT test.b,test.b):Float64;N]
806+
Aggregate: groupBy=[[test.a]], aggr=[[corr(alias1, alias1)]] [a:UInt32, corr(alias1,alias1):Float64;N]
807+
Aggregate: groupBy=[[test.a, test.b AS alias1]], aggr=[[]] [a:UInt32, alias1:UInt32]
808+
TableScan: test [a:UInt32, b:UInt32, c:UInt32]
809+
"
810+
)
811+
}
812+
813+
#[test]
814+
fn single_distinct_two_args_and_groupby() -> Result<()> {
815+
let table_scan = test_table_scan()?;
816+
817+
let plan = LogicalPlanBuilder::from(table_scan)
818+
.aggregate(vec![col("a")], vec![corr_distinct(col("b"), col("c"))])?
819+
.build()?;
820+
821+
// Do nothing: the arguments are different expressions, so there is no
822+
// single field to group by.
823+
assert_optimized_plan_equal!(
824+
plan,
825+
@r"
826+
Aggregate: groupBy=[[test.a]], aggr=[[corr(DISTINCT test.b, test.c)]] [a:UInt32, corr(DISTINCT test.b,test.c):Float64;N]
827+
TableScan: test [a:UInt32, b:UInt32, c:UInt32]
828+
"
829+
)
830+
}
831+
773832
#[test]
774833
fn one_field_two_distinct_and_groupby() -> Result<()> {
775834
let table_scan = test_table_scan()?;

‎datafusion/sqllogictest/test_files/aggregate.slt‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,34 @@ GROUP BY g
404404
1 1
405405
2 NULL
406406

407+
# A DISTINCT aggregate may take the distinct argument more than once.
408+
# Group 1 aggregates over the distinct values 1.0 and 2.0, group 2 over the
409+
# single distinct value 3.0, which leaves the sample covariance undefined.
410+
# Group 3 repeats a NULL row alongside the distinct value 2.0: DISTINCT
411+
# collapses the repeated NULLs into one, and the aggregate then excludes
412+
# that NULL the same way it would without the rewrite, leaving a single
413+
# distinct point - again an undefined sample covariance. Group 4 is all
414+
# NULL, so the aggregate has zero non-null distinct points to work with.
415+
query IR rowsort
416+
SELECT g, corr(DISTINCT x, x)
417+
FROM (VALUES (1, 1.0), (1, 1.0), (1, 2.0), (2, 3.0), (2, 3.0), (3, NULL), (3, NULL), (3, 2.0), (4, NULL), (4, NULL)) AS t(g, x)
418+
GROUP BY g
419+
----
420+
1 1
421+
2 NULL
422+
3 NULL
423+
4 NULL
424+
425+
query IR rowsort
426+
SELECT g, covar_samp(DISTINCT x, x)
427+
FROM (VALUES (1, 1.0), (1, 1.0), (1, 2.0), (2, 3.0), (2, 3.0), (3, NULL), (3, NULL), (3, 2.0), (4, NULL), (4, NULL)) AS t(g, x)
428+
GROUP BY g
429+
----
430+
1 0.5
431+
2 NULL
432+
3 NULL
433+
4 NULL
434+
407435
# all_nulls_query_correlation
408436
query R
409437
with data as (

0 commit comments

Comments
 (0)