adriangb commented on code in PR #25288:
URL: https://github.com/apache/datafusion/pull/25288#discussion_r4006040186


##########
datafusion/functions-aggregate/src/approx_median.rs:
##########
@@ -147,4 +147,10 @@ impl AggregateUDFImpl for ApproxMedian {
     fn documentation(&self) -> Option<&Documentation> {
         self.doc()
     }
+
+    // Left at the default `Honored`. The accumulator rejects `DISTINCT` with
+    // `not_impl_err!`, so `f(DISTINCT x)` only works when
+    // `SingleDistinctToGroupBy` rewrites the node and deduplicates the input
+    // first. When it cannot, for example beside an `avg`, the query errors
+    // rather than returning the non-distinct answer.

Review Comment:
   This accumulator rejects `DISTINCT` with `not_impl_err!`, the same as 
`approx_percentile_cont_with_weight`, which is tagged `Unsupported`. With the 
variant definitions suggested in `udaf.rs`, this one is `Unsupported` too. The 
full path avoids a new import outside the diff; a `use` line is better.
   
   ```suggestion
       fn distinct_handling(&self) -> datafusion_expr::DistinctHandling {
           // The accumulator rejects `DISTINCT` with `not_impl_err!`, so the
           // planner has to deduplicate the input first.
           datafusion_expr::DistinctHandling::Unsupported
       }
   ```



##########
datafusion/sqllogictest/test_files/aggregates_simplify.slt:
##########
@@ -356,3 +356,224 @@ DROP TABLE IF EXISTS tbl;
 
 statement ok
 DROP TABLE sum_simplify_t;
+
+#######
+# EliminateAggregateDistinct: DISTINCT is dropped from duplicate-insensitive
+# aggregates, so no inner group by is planned to deduplicate the input.
+#######
+
+statement ok
+CREATE TABLE distinct_simplify_t (g INT, v INT, b BOOLEAN) AS VALUES
+  (1, 3, true),
+  (1, 3, true),
+  (1, 5, false),
+  (2, 7, true),
+  (2, 7, true),
+  (2, NULL, NULL);
+
+# min: DISTINCT cannot change the minimum
+query II
+SELECT g, min(DISTINCT v) FROM distinct_simplify_t GROUP BY g ORDER BY g;
+----
+1 3
+2 7
+
+query TT
+EXPLAIN SELECT g, min(DISTINCT v) FROM distinct_simplify_t GROUP BY g;
+----
+logical_plan
+01)Aggregate: groupBy=[[distinct_simplify_t.g]], 
aggr=[[min(distinct_simplify_t.v) AS min(DISTINCT distinct_simplify_t.v)]]
+02)--TableScan: distinct_simplify_t projection=[g, v]
+physical_plan
+01)AggregateExec: mode=FinalPartitioned, gby=[g@0 as g], 
aggr=[min(distinct_simplify_t.v) as min(DISTINCT distinct_simplify_t.v)]
+02)--RepartitionExec: partitioning=Hash([g@0], 4), input_partitions=1
+03)----AggregateExec: mode=Partial, gby=[g@0 as g], 
aggr=[min(distinct_simplify_t.v) as min(DISTINCT distinct_simplify_t.v)]
+04)------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# max
+query II
+SELECT g, max(DISTINCT v) FROM distinct_simplify_t GROUP BY g ORDER BY g;
+----
+1 5
+2 7
+
+query TT
+EXPLAIN SELECT g, max(DISTINCT v) FROM distinct_simplify_t GROUP BY g;
+----
+logical_plan
+01)Aggregate: groupBy=[[distinct_simplify_t.g]], 
aggr=[[max(distinct_simplify_t.v) AS max(DISTINCT distinct_simplify_t.v)]]
+02)--TableScan: distinct_simplify_t projection=[g, v]
+physical_plan
+01)AggregateExec: mode=FinalPartitioned, gby=[g@0 as g], 
aggr=[max(distinct_simplify_t.v) as max(DISTINCT distinct_simplify_t.v)]
+02)--RepartitionExec: partitioning=Hash([g@0], 4), input_partitions=1
+03)----AggregateExec: mode=Partial, gby=[g@0 as g], 
aggr=[max(distinct_simplify_t.v) as max(DISTINCT distinct_simplify_t.v)]
+04)------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# bool_and
+query IB
+SELECT g, bool_and(DISTINCT b) FROM distinct_simplify_t GROUP BY g ORDER BY g;
+----
+1 false
+2 true
+
+query TT
+EXPLAIN SELECT g, bool_and(DISTINCT b) FROM distinct_simplify_t GROUP BY g;
+----
+logical_plan
+01)Aggregate: groupBy=[[distinct_simplify_t.g]], 
aggr=[[bool_and(distinct_simplify_t.b) AS bool_and(DISTINCT 
distinct_simplify_t.b)]]
+02)--TableScan: distinct_simplify_t projection=[g, b]
+physical_plan
+01)AggregateExec: mode=FinalPartitioned, gby=[g@0 as g], 
aggr=[bool_and(distinct_simplify_t.b) as bool_and(DISTINCT 
distinct_simplify_t.b)]
+02)--RepartitionExec: partitioning=Hash([g@0], 4), input_partitions=1
+03)----AggregateExec: mode=Partial, gby=[g@0 as g], 
aggr=[bool_and(distinct_simplify_t.b) as bool_and(DISTINCT 
distinct_simplify_t.b)]
+04)------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# bool_or
+query IB
+SELECT g, bool_or(DISTINCT b) FROM distinct_simplify_t GROUP BY g ORDER BY g;
+----
+1 true
+2 true
+
+query TT
+EXPLAIN SELECT g, bool_or(DISTINCT b) FROM distinct_simplify_t GROUP BY g;
+----
+logical_plan
+01)Aggregate: groupBy=[[distinct_simplify_t.g]], 
aggr=[[bool_or(distinct_simplify_t.b) AS bool_or(DISTINCT 
distinct_simplify_t.b)]]
+02)--TableScan: distinct_simplify_t projection=[g, b]
+physical_plan
+01)AggregateExec: mode=FinalPartitioned, gby=[g@0 as g], 
aggr=[bool_or(distinct_simplify_t.b) as bool_or(DISTINCT distinct_simplify_t.b)]
+02)--RepartitionExec: partitioning=Hash([g@0], 4), input_partitions=1
+03)----AggregateExec: mode=Partial, gby=[g@0 as g], 
aggr=[bool_or(distinct_simplify_t.b) as bool_or(DISTINCT distinct_simplify_t.b)]
+04)------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# bit_and
+query II
+SELECT g, bit_and(DISTINCT v) FROM distinct_simplify_t GROUP BY g ORDER BY g;
+----
+1 1
+2 7
+
+query TT
+EXPLAIN SELECT g, bit_and(DISTINCT v) FROM distinct_simplify_t GROUP BY g;
+----
+logical_plan
+01)Aggregate: groupBy=[[distinct_simplify_t.g]], 
aggr=[[bit_and(distinct_simplify_t.v) AS bit_and(DISTINCT 
distinct_simplify_t.v)]]
+02)--TableScan: distinct_simplify_t projection=[g, v]
+physical_plan
+01)AggregateExec: mode=FinalPartitioned, gby=[g@0 as g], 
aggr=[bit_and(distinct_simplify_t.v) as bit_and(DISTINCT distinct_simplify_t.v)]
+02)--RepartitionExec: partitioning=Hash([g@0], 4), input_partitions=1
+03)----AggregateExec: mode=Partial, gby=[g@0 as g], 
aggr=[bit_and(distinct_simplify_t.v) as bit_and(DISTINCT distinct_simplify_t.v)]
+04)------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# bit_or
+query II
+SELECT g, bit_or(DISTINCT v) FROM distinct_simplify_t GROUP BY g ORDER BY g;
+----
+1 7
+2 7
+
+query TT
+EXPLAIN SELECT g, bit_or(DISTINCT v) FROM distinct_simplify_t GROUP BY g;
+----
+logical_plan
+01)Aggregate: groupBy=[[distinct_simplify_t.g]], 
aggr=[[bit_or(distinct_simplify_t.v) AS bit_or(DISTINCT distinct_simplify_t.v)]]
+02)--TableScan: distinct_simplify_t projection=[g, v]
+physical_plan
+01)AggregateExec: mode=FinalPartitioned, gby=[g@0 as g], 
aggr=[bit_or(distinct_simplify_t.v) as bit_or(DISTINCT distinct_simplify_t.v)]
+02)--RepartitionExec: partitioning=Hash([g@0], 4), input_partitions=1
+03)----AggregateExec: mode=Partial, gby=[g@0 as g], 
aggr=[bit_or(distinct_simplify_t.v) as bit_or(DISTINCT distinct_simplify_t.v)]
+04)------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# Negative case: bit_xor cancels duplicate pairs, so DISTINCT is kept and
+# SingleDistinctToGroupBy still rewrites the plan.
+query II
+SELECT g, bit_xor(DISTINCT v) FROM distinct_simplify_t GROUP BY g ORDER BY g;
+----
+1 6
+2 7
+
+query TT
+EXPLAIN SELECT g, bit_xor(DISTINCT v) FROM distinct_simplify_t GROUP BY g;
+----
+logical_plan
+01)Projection: distinct_simplify_t.g, bit_xor(alias1) AS bit_xor(DISTINCT 
distinct_simplify_t.v)
+02)--Aggregate: groupBy=[[distinct_simplify_t.g]], aggr=[[bit_xor(alias1)]]
+03)----Aggregate: groupBy=[[distinct_simplify_t.g, distinct_simplify_t.v AS 
alias1]], aggr=[[]]
+04)------TableScan: distinct_simplify_t projection=[g, v]
+physical_plan
+01)ProjectionExec: expr=[g@0 as g, bit_xor(alias1)@1 as bit_xor(DISTINCT 
distinct_simplify_t.v)]
+02)--AggregateExec: mode=FinalPartitioned, gby=[g@0 as g], 
aggr=[bit_xor(alias1)]
+03)----RepartitionExec: partitioning=Hash([g@0], 4), input_partitions=4
+04)------AggregateExec: mode=Partial, gby=[g@0 as g], aggr=[bit_xor(alias1)]
+05)--------AggregateExec: mode=FinalPartitioned, gby=[g@0 as g, alias1@1 as 
alias1], aggr=[]
+06)----------RepartitionExec: partitioning=Hash([g@0, alias1@1], 4), 
input_partitions=1
+07)------------AggregateExec: mode=Partial, gby=[g@0 as g, v@1 as alias1], 
aggr=[]
+08)--------------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# Negative case: count deduplicates for real
+query II
+SELECT g, count(DISTINCT v) FROM distinct_simplify_t GROUP BY g ORDER BY g;
+----
+1 2
+2 1
+
+query TT
+EXPLAIN SELECT g, count(DISTINCT v) FROM distinct_simplify_t GROUP BY g;
+----
+logical_plan
+01)Projection: distinct_simplify_t.g, count(alias1) AS count(DISTINCT 
distinct_simplify_t.v)
+02)--Aggregate: groupBy=[[distinct_simplify_t.g]], aggr=[[count(alias1)]]
+03)----Aggregate: groupBy=[[distinct_simplify_t.g, distinct_simplify_t.v AS 
alias1]], aggr=[[]]
+04)------TableScan: distinct_simplify_t projection=[g, v]
+physical_plan
+01)ProjectionExec: expr=[g@0 as g, count(alias1)@1 as count(DISTINCT 
distinct_simplify_t.v)]
+02)--AggregateExec: mode=FinalPartitioned, gby=[g@0 as g], aggr=[count(alias1)]
+03)----RepartitionExec: partitioning=Hash([g@0], 4), input_partitions=4
+04)------AggregateExec: mode=Partial, gby=[g@0 as g], aggr=[count(alias1)]
+05)--------AggregateExec: mode=FinalPartitioned, gby=[g@0 as g, alias1@1 as 
alias1], aggr=[]
+06)----------RepartitionExec: partitioning=Hash([g@0, alias1@1], 4), 
input_partitions=1
+07)------------AggregateExec: mode=Partial, gby=[g@0 as g, v@1 as alias1], 
aggr=[]
+08)--------------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# Mixed: a node that still needs one DISTINCT keeps all of them, so this rule
+# cannot change which plans SingleDistinctToGroupBy rewrites
+query III
+SELECT g, min(DISTINCT v), sum(DISTINCT v) FROM distinct_simplify_t GROUP BY g 
ORDER BY g;
+----
+1 3 8
+2 7 7
+
+query TT
+EXPLAIN SELECT g, min(DISTINCT v), sum(DISTINCT v) FROM distinct_simplify_t 
GROUP BY g;
+----
+logical_plan
+01)Aggregate: groupBy=[[distinct_simplify_t.g]], aggr=[[min(DISTINCT 
distinct_simplify_t.v), sum(DISTINCT CAST(distinct_simplify_t.v AS Int64))]]
+02)--TableScan: distinct_simplify_t projection=[g, v]
+physical_plan
+01)AggregateExec: mode=FinalPartitioned, gby=[g@0 as g], aggr=[min(DISTINCT 
distinct_simplify_t.v), sum(DISTINCT distinct_simplify_t.v)]
+02)--RepartitionExec: partitioning=Hash([g@0], 4), input_partitions=1
+03)----AggregateExec: mode=Partial, gby=[g@0 as g], aggr=[min(DISTINCT 
distinct_simplify_t.v), sum(DISTINCT distinct_simplify_t.v)]
+04)------DataSourceExec: partitions=1, partition_sizes=[1]

Review Comment:
   This case does not test the gate. `v` is `INT`, so type coercion makes the 
`sum` argument `CAST(v AS Int64)` while the `min` argument stays `v`. The two 
aggregates then have different arguments, and `SingleDistinctToGroupBy` does 
not rewrite the node on `main` either. The expected plan is the same on `main` 
and on this branch. If the gate is removed later, this test still passes.
   
   `count(DISTINCT v)` needs no cast. With it, `main` rewrites the node, and 
this branch must keep that rewrite. The expected output below is from this 
branch:
   
   ```suggestion
   # Mixed: a node that still needs one DISTINCT keeps all of them, so this rule
   # cannot change which plans SingleDistinctToGroupBy rewrites. `count` needs 
the
   # rewrite, and the plan below shows that it still happens.
   query III
   SELECT g, min(DISTINCT v), count(DISTINCT v) FROM distinct_simplify_t GROUP 
BY g ORDER BY g;
   ----
   1 3 2
   2 7 1
   
   query TT
   EXPLAIN SELECT g, min(DISTINCT v), count(DISTINCT v) FROM 
distinct_simplify_t GROUP BY g;
   ----
   logical_plan
   01)Projection: distinct_simplify_t.g, min(alias1) AS min(DISTINCT 
distinct_simplify_t.v), count(alias1) AS count(DISTINCT distinct_simplify_t.v)
   02)--Aggregate: groupBy=[[distinct_simplify_t.g]], aggr=[[min(alias1), 
count(alias1)]]
   03)----Aggregate: groupBy=[[distinct_simplify_t.g, distinct_simplify_t.v AS 
alias1]], aggr=[[]]
   04)------TableScan: distinct_simplify_t projection=[g, v]
   physical_plan
   01)ProjectionExec: expr=[g@0 as g, min(alias1)@1 as min(DISTINCT 
distinct_simplify_t.v), count(alias1)@2 as count(DISTINCT 
distinct_simplify_t.v)]
   02)--AggregateExec: mode=FinalPartitioned, gby=[g@0 as g], 
aggr=[min(alias1), count(alias1)]
   03)----RepartitionExec: partitioning=Hash([g@0], 4), input_partitions=4
   04)------AggregateExec: mode=Partial, gby=[g@0 as g], aggr=[min(alias1), 
count(alias1)]
   05)--------AggregateExec: mode=FinalPartitioned, gby=[g@0 as g, alias1@1 as 
alias1], aggr=[]
   06)----------RepartitionExec: partitioning=Hash([g@0, alias1@1], 4), 
input_partitions=1
   07)------------AggregateExec: mode=Partial, gby=[g@0 as g, v@1 as alias1], 
aggr=[]
   08)--------------DataSourceExec: partitions=1, partition_sizes=[1]
   ```



##########
datafusion/expr/src/udaf.rs:
##########
@@ -940,6 +945,20 @@ pub trait AggregateUDFImpl: Debug + DynEq + DynHash + Send 
+ Sync + Any {
         false
     }
 
+    /// How this function treats the `DISTINCT` modifier.
+    ///
+    /// Return [`DistinctHandling::Ignored`] for duplicate-insensitive
+    /// functions so that `f(DISTINCT x)` is planned as `f(x)`.
+    ///
+    /// Return [`DistinctHandling::Unsupported`] if the accumulator neither
+    /// reads `is_distinct` nor is reached only after the planner has already
+    /// deduplicated the input. Nothing reads this variant yet: rejecting such
+    /// queries at planning time, rather than silently returning the
+    /// non-distinct answer, is a follow-up change.

Review Comment:
   The criterion here depends on the optimizer. "Reached only after the planner 
has already deduplicated the input" is true for `stddev` and false for 
`approx_percentile_cont_with_weight`, although both reject `DISTINCT` with 
`not_impl_err!`. The only difference is the number of arguments, which decides 
whether `SingleDistinctToGroupBy` can rewrite the node. A UDAF author cannot 
evaluate that, and the meaning of the tag changes when that rule improves. 
Please make the variants describe the function only:
   
   ```suggestion
       /// Return [`DistinctHandling::Unsupported`] if the accumulator does not
       /// implement `DISTINCT`, that is, it does not read `is_distinct`, or it
       /// rejects `DISTINCT` with an error. The planner then has to deduplicate
       /// the input or reject the query. Nothing reads this variant yet:
       /// rejecting such queries at planning time is a follow-up change.
   ```



##########
datafusion/optimizer/src/eliminate_aggregate_distinct.rs:
##########
@@ -0,0 +1,368 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! [`EliminateAggregateDistinct`] drops the `DISTINCT` modifier from aggregate
+//! functions that report [`DistinctHandling::Ignored`]
+
+use crate::optimizer::ApplyOrder;
+use crate::{OptimizerConfig, OptimizerRule};
+
+use datafusion_common::Result;
+use datafusion_common::tree_node::{Transformed, TreeNode, TreeNodeRecursion};
+use datafusion_expr::expr::AggregateFunction;
+use datafusion_expr::expr_rewriter::NamePreserver;
+use datafusion_expr::{DistinctHandling, Expr, LogicalPlan};
+
+/// Optimizer rule that removes a `DISTINCT` modifier that cannot change the
+/// result of the aggregate it is attached to.
+///
+/// `min`, `max`, `bool_and`, `bit_or` and friends have an idempotent merge, so
+/// `min(DISTINCT x)` and `min(x)` return the same value. Removing the flag 
here
+/// keeps [`crate::single_distinct_to_groupby::SingleDistinctToGroupBy`] from
+/// rewriting the plan into an inner group by that only exists to deduplicate.
+///
+/// An aggregate states how it treats duplicates through
+/// [`datafusion_expr::AggregateUDFImpl::distinct_handling`].
+///
+/// ```text
+/// Aggregate: groupBy=[[g]], aggr=[[min(DISTINCT x)]]
+/// ```
+///
+/// becomes
+///
+/// ```text
+/// Aggregate: groupBy=[[g]], aggr=[[min(x) AS "min(DISTINCT x)"]]
+/// ```
+///
+/// The alias keeps the output schema unchanged so the parent projection still
+/// resolves.
+#[derive(Default, Debug)]
+pub struct EliminateAggregateDistinct {}
+
+impl EliminateAggregateDistinct {
+    pub fn new() -> Self {
+        Self {}
+    }
+}
+
+impl OptimizerRule for EliminateAggregateDistinct {
+    fn name(&self) -> &str {
+        "eliminate_aggregate_distinct"
+    }
+
+    fn apply_order(&self) -> Option<ApplyOrder> {
+        Some(ApplyOrder::BottomUp)
+    }
+
+    fn supports_rewrite(&self) -> bool {
+        true
+    }
+
+    fn rewrite(
+        &self,
+        plan: LogicalPlan,
+        _config: &dyn OptimizerConfig,
+    ) -> Result<Transformed<LogicalPlan>> {
+        // Aggregate expressions only appear on Aggregate nodes, so every other
+        // node is a cheap no-op. Window functions carry their own `distinct`
+        // flag and are out of scope.
+        let LogicalPlan::Aggregate(aggregate) = &plan else {
+            return Ok(Transformed::no(plan));
+        };
+        if !can_strip_every_distinct(&aggregate.aggr_expr)? {
+            return Ok(Transformed::no(plan));
+        }
+
+        // Dropping `DISTINCT` changes `Expr::schema_name`, and with it the
+        // output schema of the Aggregate, so restore the original name. The
+        // aggregate may sit under an alias that type coercion added, so walk
+        // the expression rather than matching only its root.
+        let name_preserver = NamePreserver::new(&plan);
+        plan.map_expressions(|expr| {
+            let saved_name = name_preserver.save(&expr);
+            expr.transform_down(strip_ignored_distinct)
+                .map(|t| t.update_data(|e| saved_name.restore(e)))
+        })
+    }
+}
+
+/// Whether the node has at least one `DISTINCT` and every one is `Ignored`.
+///
+/// Stripping only some of them would change which plans
+/// [`crate::single_distinct_to_groupby::SingleDistinctToGroupBy`] rewrites,
+/// since that rule keys off how many distinct aggregates a node has and
+/// whether they share one argument. This is conservative: `min(DISTINCT x),
+/// count(DISTINCT y)` keeps the `min` flag though no rewrite is possible.

Review Comment:
   The reason given here is not the one that applies. After a partial strip, 
the number of distinct aggregates and their arguments can still satisfy 
`is_single_distinct_agg`. What stops the rewrite is the alias that this rule 
adds: `is_single_distinct_agg` returns `false` for any `aggr_expr` element that 
is not a bare `AggregateFunction`. Suggested wording:
   
   ```suggestion
   /// If only some of them were stripped, an `Honored` `DISTINCT` could stay
   /// beside a stripped aggregate. A stripped aggregate carries an alias, and
   /// [`crate::single_distinct_to_groupby::SingleDistinctToGroupBy`] does not
   /// rewrite a node whose `aggr_expr` contains an alias. So `min(DISTINCT x),
   /// count(DISTINCT x)` would lose the rewrite that `count` needs. When every
   /// `DISTINCT` is `Ignored`, none is left after stripping, so that rule has
   /// nothing to rewrite. This is conservative: `min(DISTINCT x),
   /// count(DISTINCT y)` keeps the `min` flag. That flag costs nothing at run
   /// time, because `min` ignores `is_distinct` when it selects its 
accumulator.
   ```



##########
datafusion/expr/src/udaf.rs:
##########
@@ -1713,6 +1736,31 @@ pub enum SetMonotonicity {
     NotMonotonic,
 }
 
+/// How an aggregate function treats the `DISTINCT` modifier.
+///
+/// Mathematically, `Ignored` means the function's merge operation is
+/// idempotent (its state forms a semilattice): f(S ⊎ S) = f(S), so
+/// removing duplicates from the input cannot change the result.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[non_exhaustive]
+pub enum DistinctHandling {
+    /// The result is the same with or without `DISTINCT`, so the planner
+    /// is free to drop it. `min`, `max`, `bool_and`, `bit_or`, ...
+    Ignored,
+    /// `DISTINCT` is applied, so the planner must leave it alone. Either the
+    /// accumulator reads `AccumulatorArgs::is_distinct` and deduplicates its
+    /// input (`count`, `sum`, `avg`, `var_samp`, `array_agg`, ...), or it
+    /// rejects `DISTINCT` and relies on `SingleDistinctToGroupBy` to
+    /// deduplicate the input first (`stddev`, `approx_median`, ...). The 
latter
+    /// works only when that rewrite applies; otherwise the query errors. This
+    /// is the default.
+    Honored,
+    /// The accumulator does not implement `DISTINCT` and nothing deduplicates
+    /// the input for it, so `f(DISTINCT ...)` either errors or silently
+    /// returns the non-distinct answer. `corr`, `regr_*`, `nth_value`, ...
+    Unsupported,

Review Comment:
   Same point as on the trait method: `Honored` should mean "the accumulator 
deduplicates its own input", and nothing else. Whether a rewrite can rescue a 
function that does not is a planner decision, and it belongs in the planner, 
not in the tag. With this wording `stddev`, `stddev_pop` and `approx_median` 
move to `Unsupported`, next to `approx_percentile_cont_with_weight`, which 
already is. No behavior changes, because nothing reads `Unsupported` yet.
   
   ```suggestion
       /// The accumulator reads `AccumulatorArgs::is_distinct` and deduplicates
       /// its input, so the planner must leave the flag alone. `count`, `sum`,
       /// `avg`, `var_samp`, `array_agg`, ... This is the default.
       Honored,
       /// The accumulator does not implement `DISTINCT`: it does not read
       /// `is_distinct`, or it rejects `DISTINCT` with an error. The planner 
has
       /// to deduplicate the input first (today `SingleDistinctToGroupBy` does
       /// that for single-argument functions) or reject the query. `stddev`,
       /// `approx_median`, `corr`, `regr_*`, `nth_value`, ...
       Unsupported,
   ```



##########
docs/source/library-user-guide/functions/adding-udfs.md:
##########
@@ -1116,6 +1116,25 @@ impl Accumulator for GeometricMean {
 }
 ```
 
+### Declaring how an Aggregate UDF treats `DISTINCT`
+
+By default DataFusion assumes an aggregate honours the `DISTINCT` modifier, 
which means the accumulator is expected to

Review Comment:
   Nit: the rest of this file uses American spelling, and the variant is 
spelled `Honored`.
   
   ```suggestion
   By default DataFusion assumes an aggregate honors the `DISTINCT` modifier, 
which means the accumulator is expected to
   ```



##########
datafusion/optimizer/src/eliminate_aggregate_distinct.rs:
##########
@@ -0,0 +1,368 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! [`EliminateAggregateDistinct`] drops the `DISTINCT` modifier from aggregate
+//! functions that report [`DistinctHandling::Ignored`]
+
+use crate::optimizer::ApplyOrder;
+use crate::{OptimizerConfig, OptimizerRule};
+
+use datafusion_common::Result;
+use datafusion_common::tree_node::{Transformed, TreeNode, TreeNodeRecursion};
+use datafusion_expr::expr::AggregateFunction;
+use datafusion_expr::expr_rewriter::NamePreserver;
+use datafusion_expr::{DistinctHandling, Expr, LogicalPlan};
+
+/// Optimizer rule that removes a `DISTINCT` modifier that cannot change the
+/// result of the aggregate it is attached to.
+///
+/// `min`, `max`, `bool_and`, `bit_or` and friends have an idempotent merge, so
+/// `min(DISTINCT x)` and `min(x)` return the same value. Removing the flag 
here
+/// keeps [`crate::single_distinct_to_groupby::SingleDistinctToGroupBy`] from
+/// rewriting the plan into an inner group by that only exists to deduplicate.
+///
+/// An aggregate states how it treats duplicates through
+/// [`datafusion_expr::AggregateUDFImpl::distinct_handling`].
+///
+/// ```text
+/// Aggregate: groupBy=[[g]], aggr=[[min(DISTINCT x)]]
+/// ```
+///
+/// becomes
+///
+/// ```text
+/// Aggregate: groupBy=[[g]], aggr=[[min(x) AS "min(DISTINCT x)"]]
+/// ```
+///
+/// The alias keeps the output schema unchanged so the parent projection still
+/// resolves.
+#[derive(Default, Debug)]
+pub struct EliminateAggregateDistinct {}
+
+impl EliminateAggregateDistinct {
+    pub fn new() -> Self {
+        Self {}
+    }
+}
+
+impl OptimizerRule for EliminateAggregateDistinct {
+    fn name(&self) -> &str {
+        "eliminate_aggregate_distinct"
+    }
+
+    fn apply_order(&self) -> Option<ApplyOrder> {
+        Some(ApplyOrder::BottomUp)
+    }
+
+    fn supports_rewrite(&self) -> bool {
+        true
+    }
+
+    fn rewrite(
+        &self,
+        plan: LogicalPlan,
+        _config: &dyn OptimizerConfig,
+    ) -> Result<Transformed<LogicalPlan>> {
+        // Aggregate expressions only appear on Aggregate nodes, so every other
+        // node is a cheap no-op. Window functions carry their own `distinct`
+        // flag and are out of scope.
+        let LogicalPlan::Aggregate(aggregate) = &plan else {
+            return Ok(Transformed::no(plan));
+        };
+        if !can_strip_every_distinct(&aggregate.aggr_expr)? {
+            return Ok(Transformed::no(plan));
+        }
+
+        // Dropping `DISTINCT` changes `Expr::schema_name`, and with it the
+        // output schema of the Aggregate, so restore the original name. The
+        // aggregate may sit under an alias that type coercion added, so walk
+        // the expression rather than matching only its root.
+        let name_preserver = NamePreserver::new(&plan);
+        plan.map_expressions(|expr| {
+            let saved_name = name_preserver.save(&expr);
+            expr.transform_down(strip_ignored_distinct)
+                .map(|t| t.update_data(|e| saved_name.restore(e)))
+        })
+    }
+}
+
+/// Whether the node has at least one `DISTINCT` and every one is `Ignored`.
+///
+/// Stripping only some of them would change which plans
+/// [`crate::single_distinct_to_groupby::SingleDistinctToGroupBy`] rewrites,
+/// since that rule keys off how many distinct aggregates a node has and
+/// whether they share one argument. This is conservative: `min(DISTINCT x),
+/// count(DISTINCT y)` keeps the `min` flag though no rewrite is possible.
+fn can_strip_every_distinct(aggr_expr: &[Expr]) -> Result<bool> {
+    let mut handlings = vec![];
+    for expr in aggr_expr {
+        expr.apply(|e| {
+            if let Expr::AggregateFunction(AggregateFunction { func, params }) 
= e
+                && params.distinct
+            {
+                handlings.push(func.distinct_handling());
+            }
+            Ok(TreeNodeRecursion::Continue)
+        })?;
+    }
+    Ok(
+        !handlings.is_empty()
+            && handlings.iter().all(|h| *h == DistinctHandling::Ignored),
+    )
+}

Review Comment:
   Nit: this runs on every `Aggregate` node of every query. It allocates a 
`Vec` and walks all `aggr_expr` trees, and it does not stop at the first 
`Honored` distinct. The note in `optimizer.rs` asks new rules for an aggressive 
no-op path. One option:
   
   ```suggestion
   fn can_strip_every_distinct(aggr_expr: &[Expr]) -> Result<bool> {
       let mut found_distinct = false;
       let mut all_ignored = true;
       for expr in aggr_expr {
           expr.apply(|e| {
               if let Expr::AggregateFunction(AggregateFunction { func, params 
}) = e
                   && params.distinct
               {
                   found_distinct = true;
                   if func.distinct_handling() != DistinctHandling::Ignored {
                       all_ignored = false;
                       return Ok(TreeNodeRecursion::Stop);
                   }
               }
               Ok(TreeNodeRecursion::Continue)
           })?;
           if !all_ignored {
               break;
           }
       }
       Ok(found_distinct && all_ignored)
   }
   ```



##########
datafusion/functions-aggregate/src/bit_and_or_xor.rs:
##########
@@ -318,6 +319,18 @@ impl AggregateUDFImpl for BitwiseOperation {
     fn documentation(&self) -> Option<&Documentation> {
         Some(self.documentation)
     }
+
+    fn distinct_handling(&self) -> DistinctHandling {
+        match self.operation {
+            // Bitwise AND/OR are idempotent: duplicates cannot change the
+            // result, so building a per-group `HashSet` buys nothing.

Review Comment:
   Nit: AND and OR never build a `HashSet`. Only XOR has a distinct accumulator 
(`DistinctBitXorAccumulator`), and `groups_accumulator_supported` already says 
so.
   
   ```suggestion
               // Bitwise AND/OR are idempotent: duplicates cannot change the
               // result. Only XOR has a distinct accumulator.
   ```



##########
datafusion/functions-aggregate/src/first_last.rs:
##########
@@ -1294,6 +1300,12 @@ impl AggregateUDFImpl for LastValue {
     ) -> Result<Box<dyn GroupsAccumulator>> {
         create_groups_accumulator(&args, false, self.is_input_pre_ordered, 
self.name())
     }
+
+    // TODO: whether this is `DistinctHandling::Ignored` depends on `ORDER BY`.
+    // `last_value(DISTINCT x ORDER BY y)` deduplicates `x` and leaves the `y`
+    // ordering meaningless, while `last_value(DISTINCT x ORDER BY x)` is just
+    // `max(x)`. Left at the default `Honored` until that is settled, even

Review Comment:
   Nit: `last_value(DISTINCT x ORDER BY x)` is `max(x)` only when `x` has no 
NULL. NULL sorts last, so `last_value` returns NULL where `max` skips it.
   
   ```suggestion
       // ordering meaningless, while `last_value(DISTINCT x ORDER BY x)` is
       // `max(x)` when `x` has no NULL. Left at the default `Honored` until 
that is settled, even
   ```



##########
datafusion/functions-aggregate/src/stddev.rs:
##########
@@ -240,6 +246,12 @@ impl AggregateUDFImpl for StddevPop {
     fn documentation(&self) -> Option<&Documentation> {
         self.doc()
     }
+
+    // Left at the default `Honored`. The accumulator rejects `DISTINCT` with
+    // `not_impl_err!`, so `f(DISTINCT x)` only works when
+    // `SingleDistinctToGroupBy` rewrites the node and deduplicates the input
+    // first. When it cannot, for example beside an `avg`, the query errors
+    // rather than returning the non-distinct answer.

Review Comment:
   This accumulator rejects `DISTINCT` with `not_impl_err!`, the same as 
`approx_percentile_cont_with_weight`, which is tagged `Unsupported`. With the 
variant definitions suggested in `udaf.rs`, this one is `Unsupported` too. The 
full path avoids a new import outside the diff; a `use` line is better.
   
   ```suggestion
       fn distinct_handling(&self) -> datafusion_expr::DistinctHandling {
           // The accumulator rejects `DISTINCT` with `not_impl_err!`, so the
           // planner has to deduplicate the input first.
           datafusion_expr::DistinctHandling::Unsupported
       }
   ```



##########
datafusion/functions-aggregate/src/grouping.rs:
##########
@@ -110,4 +111,12 @@ impl AggregateUDFImpl for Grouping {
     fn documentation(&self) -> Option<&Documentation> {
         self.doc()
     }
+
+    fn distinct_handling(&self) -> DistinctHandling {
+        // The result depends only on which grouping set a row belongs to, not
+        // on how many rows share a value, so duplicates cannot change it.
+        // `ResolveGroupingFunction` replaces the call before execution either
+        // way, which is why the accumulator above is never built.

Review Comment:
   Nit: `ResolveGroupingFunction` is an analyzer rule. It replaces the call 
before the optimizer runs, so this tag is not reachable from SQL. The tag is 
still correct. Suggested wording:
   
   ```suggestion
           // The result depends only on which grouping set a row belongs to, 
not
           // on how many rows share a value, so duplicates cannot change it.
           // `ResolveGroupingFunction` replaces the call before the optimizer
           // runs, so this tag is not reachable from SQL and the accumulator
           // above is never built.
   ```



##########
datafusion/optimizer/src/eliminate_aggregate_distinct.rs:
##########
@@ -0,0 +1,368 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! [`EliminateAggregateDistinct`] drops the `DISTINCT` modifier from aggregate
+//! functions that report [`DistinctHandling::Ignored`]
+
+use crate::optimizer::ApplyOrder;
+use crate::{OptimizerConfig, OptimizerRule};
+
+use datafusion_common::Result;
+use datafusion_common::tree_node::{Transformed, TreeNode, TreeNodeRecursion};
+use datafusion_expr::expr::AggregateFunction;
+use datafusion_expr::expr_rewriter::NamePreserver;
+use datafusion_expr::{DistinctHandling, Expr, LogicalPlan};
+
+/// Optimizer rule that removes a `DISTINCT` modifier that cannot change the
+/// result of the aggregate it is attached to.
+///
+/// `min`, `max`, `bool_and`, `bit_or` and friends have an idempotent merge, so
+/// `min(DISTINCT x)` and `min(x)` return the same value. Removing the flag 
here
+/// keeps [`crate::single_distinct_to_groupby::SingleDistinctToGroupBy`] from
+/// rewriting the plan into an inner group by that only exists to deduplicate.
+///
+/// An aggregate states how it treats duplicates through
+/// [`datafusion_expr::AggregateUDFImpl::distinct_handling`].
+///
+/// ```text
+/// Aggregate: groupBy=[[g]], aggr=[[min(DISTINCT x)]]
+/// ```
+///
+/// becomes
+///
+/// ```text
+/// Aggregate: groupBy=[[g]], aggr=[[min(x) AS "min(DISTINCT x)"]]
+/// ```
+///
+/// The alias keeps the output schema unchanged so the parent projection still
+/// resolves.
+#[derive(Default, Debug)]
+pub struct EliminateAggregateDistinct {}
+
+impl EliminateAggregateDistinct {
+    pub fn new() -> Self {
+        Self {}
+    }
+}
+
+impl OptimizerRule for EliminateAggregateDistinct {
+    fn name(&self) -> &str {
+        "eliminate_aggregate_distinct"
+    }
+
+    fn apply_order(&self) -> Option<ApplyOrder> {
+        Some(ApplyOrder::BottomUp)
+    }
+
+    fn supports_rewrite(&self) -> bool {
+        true
+    }
+
+    fn rewrite(
+        &self,
+        plan: LogicalPlan,
+        _config: &dyn OptimizerConfig,
+    ) -> Result<Transformed<LogicalPlan>> {
+        // Aggregate expressions only appear on Aggregate nodes, so every other
+        // node is a cheap no-op. Window functions carry their own `distinct`
+        // flag and are out of scope.
+        let LogicalPlan::Aggregate(aggregate) = &plan else {
+            return Ok(Transformed::no(plan));
+        };
+        if !can_strip_every_distinct(&aggregate.aggr_expr)? {
+            return Ok(Transformed::no(plan));
+        }
+
+        // Dropping `DISTINCT` changes `Expr::schema_name`, and with it the
+        // output schema of the Aggregate, so restore the original name. The
+        // aggregate may sit under an alias that type coercion added, so walk
+        // the expression rather than matching only its root.
+        let name_preserver = NamePreserver::new(&plan);
+        plan.map_expressions(|expr| {
+            let saved_name = name_preserver.save(&expr);
+            expr.transform_down(strip_ignored_distinct)
+                .map(|t| t.update_data(|e| saved_name.restore(e)))
+        })
+    }
+}
+
+/// Whether the node has at least one `DISTINCT` and every one is `Ignored`.
+///
+/// Stripping only some of them would change which plans
+/// [`crate::single_distinct_to_groupby::SingleDistinctToGroupBy`] rewrites,
+/// since that rule keys off how many distinct aggregates a node has and
+/// whether they share one argument. This is conservative: `min(DISTINCT x),
+/// count(DISTINCT y)` keeps the `min` flag though no rewrite is possible.
+fn can_strip_every_distinct(aggr_expr: &[Expr]) -> Result<bool> {
+    let mut handlings = vec![];
+    for expr in aggr_expr {
+        expr.apply(|e| {
+            if let Expr::AggregateFunction(AggregateFunction { func, params }) 
= e
+                && params.distinct
+            {
+                handlings.push(func.distinct_handling());
+            }
+            Ok(TreeNodeRecursion::Continue)
+        })?;
+    }
+    Ok(
+        !handlings.is_empty()
+            && handlings.iter().all(|h| *h == DistinctHandling::Ignored),
+    )
+}
+
+/// Drops `DISTINCT` from `expr` if it is an aggregate that ignores duplicates.
+///
+/// The handling is checked again here rather than trusted to
+/// [`can_strip_every_distinct`], which only inspects `aggr_expr`, while
+/// `map_expressions` also visits the group expressions.
+///
+/// An idempotent merge is also commutative, so an `Ignored` function is
+/// insensitive to input order and `order_by` needs no extra guard. `filter` is
+/// applied before deduplication either way, so it is carried over untouched.

Review Comment:
   Idempotence does not imply commutativity. `first_value` is the 
counterexample the PR itself uses. Also, `bit_and`, `bit_or` and 
`approx_distinct` keep the default `HardRequirement` order sensitivity, so 
"insensitive to input order" is not what they declare. The rule is still 
correct, for a different reason. Suggested wording:
   
   ```suggestion
   /// An idempotent merge is not always commutative: `first_value` is
   /// idempotent but order-sensitive. The rule does not need commutativity.
   /// `Ignored` means that the result does not change when duplicates are
   /// removed, and stripping `DISTINCT` only stops that removal. `order_by` and
   /// `filter` are carried over untouched, so the function sees the same rows 
in
   /// the same order, plus the duplicates that it ignores.
   ```



##########
datafusion/sqllogictest/test_files/aggregates_simplify.slt:
##########
@@ -356,3 +356,224 @@ DROP TABLE IF EXISTS tbl;
 
 statement ok
 DROP TABLE sum_simplify_t;
+
+#######
+# EliminateAggregateDistinct: DISTINCT is dropped from duplicate-insensitive
+# aggregates, so no inner group by is planned to deduplicate the input.
+#######
+
+statement ok
+CREATE TABLE distinct_simplify_t (g INT, v INT, b BOOLEAN) AS VALUES
+  (1, 3, true),
+  (1, 3, true),
+  (1, 5, false),
+  (2, 7, true),
+  (2, 7, true),
+  (2, NULL, NULL);
+
+# min: DISTINCT cannot change the minimum
+query II
+SELECT g, min(DISTINCT v) FROM distinct_simplify_t GROUP BY g ORDER BY g;
+----
+1 3
+2 7
+
+query TT
+EXPLAIN SELECT g, min(DISTINCT v) FROM distinct_simplify_t GROUP BY g;
+----
+logical_plan
+01)Aggregate: groupBy=[[distinct_simplify_t.g]], 
aggr=[[min(distinct_simplify_t.v) AS min(DISTINCT distinct_simplify_t.v)]]
+02)--TableScan: distinct_simplify_t projection=[g, v]
+physical_plan
+01)AggregateExec: mode=FinalPartitioned, gby=[g@0 as g], 
aggr=[min(distinct_simplify_t.v) as min(DISTINCT distinct_simplify_t.v)]
+02)--RepartitionExec: partitioning=Hash([g@0], 4), input_partitions=1
+03)----AggregateExec: mode=Partial, gby=[g@0 as g], 
aggr=[min(distinct_simplify_t.v) as min(DISTINCT distinct_simplify_t.v)]
+04)------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# max
+query II
+SELECT g, max(DISTINCT v) FROM distinct_simplify_t GROUP BY g ORDER BY g;
+----
+1 5
+2 7
+
+query TT
+EXPLAIN SELECT g, max(DISTINCT v) FROM distinct_simplify_t GROUP BY g;
+----
+logical_plan
+01)Aggregate: groupBy=[[distinct_simplify_t.g]], 
aggr=[[max(distinct_simplify_t.v) AS max(DISTINCT distinct_simplify_t.v)]]
+02)--TableScan: distinct_simplify_t projection=[g, v]
+physical_plan
+01)AggregateExec: mode=FinalPartitioned, gby=[g@0 as g], 
aggr=[max(distinct_simplify_t.v) as max(DISTINCT distinct_simplify_t.v)]
+02)--RepartitionExec: partitioning=Hash([g@0], 4), input_partitions=1
+03)----AggregateExec: mode=Partial, gby=[g@0 as g], 
aggr=[max(distinct_simplify_t.v) as max(DISTINCT distinct_simplify_t.v)]
+04)------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# bool_and
+query IB
+SELECT g, bool_and(DISTINCT b) FROM distinct_simplify_t GROUP BY g ORDER BY g;
+----
+1 false
+2 true
+
+query TT
+EXPLAIN SELECT g, bool_and(DISTINCT b) FROM distinct_simplify_t GROUP BY g;
+----
+logical_plan
+01)Aggregate: groupBy=[[distinct_simplify_t.g]], 
aggr=[[bool_and(distinct_simplify_t.b) AS bool_and(DISTINCT 
distinct_simplify_t.b)]]
+02)--TableScan: distinct_simplify_t projection=[g, b]
+physical_plan
+01)AggregateExec: mode=FinalPartitioned, gby=[g@0 as g], 
aggr=[bool_and(distinct_simplify_t.b) as bool_and(DISTINCT 
distinct_simplify_t.b)]
+02)--RepartitionExec: partitioning=Hash([g@0], 4), input_partitions=1
+03)----AggregateExec: mode=Partial, gby=[g@0 as g], 
aggr=[bool_and(distinct_simplify_t.b) as bool_and(DISTINCT 
distinct_simplify_t.b)]
+04)------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# bool_or
+query IB
+SELECT g, bool_or(DISTINCT b) FROM distinct_simplify_t GROUP BY g ORDER BY g;
+----
+1 true
+2 true
+
+query TT
+EXPLAIN SELECT g, bool_or(DISTINCT b) FROM distinct_simplify_t GROUP BY g;
+----
+logical_plan
+01)Aggregate: groupBy=[[distinct_simplify_t.g]], 
aggr=[[bool_or(distinct_simplify_t.b) AS bool_or(DISTINCT 
distinct_simplify_t.b)]]
+02)--TableScan: distinct_simplify_t projection=[g, b]
+physical_plan
+01)AggregateExec: mode=FinalPartitioned, gby=[g@0 as g], 
aggr=[bool_or(distinct_simplify_t.b) as bool_or(DISTINCT distinct_simplify_t.b)]
+02)--RepartitionExec: partitioning=Hash([g@0], 4), input_partitions=1
+03)----AggregateExec: mode=Partial, gby=[g@0 as g], 
aggr=[bool_or(distinct_simplify_t.b) as bool_or(DISTINCT distinct_simplify_t.b)]
+04)------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# bit_and
+query II
+SELECT g, bit_and(DISTINCT v) FROM distinct_simplify_t GROUP BY g ORDER BY g;
+----
+1 1
+2 7
+
+query TT
+EXPLAIN SELECT g, bit_and(DISTINCT v) FROM distinct_simplify_t GROUP BY g;
+----
+logical_plan
+01)Aggregate: groupBy=[[distinct_simplify_t.g]], 
aggr=[[bit_and(distinct_simplify_t.v) AS bit_and(DISTINCT 
distinct_simplify_t.v)]]
+02)--TableScan: distinct_simplify_t projection=[g, v]
+physical_plan
+01)AggregateExec: mode=FinalPartitioned, gby=[g@0 as g], 
aggr=[bit_and(distinct_simplify_t.v) as bit_and(DISTINCT distinct_simplify_t.v)]
+02)--RepartitionExec: partitioning=Hash([g@0], 4), input_partitions=1
+03)----AggregateExec: mode=Partial, gby=[g@0 as g], 
aggr=[bit_and(distinct_simplify_t.v) as bit_and(DISTINCT distinct_simplify_t.v)]
+04)------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# bit_or
+query II
+SELECT g, bit_or(DISTINCT v) FROM distinct_simplify_t GROUP BY g ORDER BY g;
+----
+1 7
+2 7
+
+query TT
+EXPLAIN SELECT g, bit_or(DISTINCT v) FROM distinct_simplify_t GROUP BY g;
+----
+logical_plan
+01)Aggregate: groupBy=[[distinct_simplify_t.g]], 
aggr=[[bit_or(distinct_simplify_t.v) AS bit_or(DISTINCT distinct_simplify_t.v)]]
+02)--TableScan: distinct_simplify_t projection=[g, v]
+physical_plan
+01)AggregateExec: mode=FinalPartitioned, gby=[g@0 as g], 
aggr=[bit_or(distinct_simplify_t.v) as bit_or(DISTINCT distinct_simplify_t.v)]
+02)--RepartitionExec: partitioning=Hash([g@0], 4), input_partitions=1
+03)----AggregateExec: mode=Partial, gby=[g@0 as g], 
aggr=[bit_or(distinct_simplify_t.v) as bit_or(DISTINCT distinct_simplify_t.v)]
+04)------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# Negative case: bit_xor cancels duplicate pairs, so DISTINCT is kept and
+# SingleDistinctToGroupBy still rewrites the plan.
+query II
+SELECT g, bit_xor(DISTINCT v) FROM distinct_simplify_t GROUP BY g ORDER BY g;
+----
+1 6
+2 7
+
+query TT
+EXPLAIN SELECT g, bit_xor(DISTINCT v) FROM distinct_simplify_t GROUP BY g;
+----
+logical_plan
+01)Projection: distinct_simplify_t.g, bit_xor(alias1) AS bit_xor(DISTINCT 
distinct_simplify_t.v)
+02)--Aggregate: groupBy=[[distinct_simplify_t.g]], aggr=[[bit_xor(alias1)]]
+03)----Aggregate: groupBy=[[distinct_simplify_t.g, distinct_simplify_t.v AS 
alias1]], aggr=[[]]
+04)------TableScan: distinct_simplify_t projection=[g, v]
+physical_plan
+01)ProjectionExec: expr=[g@0 as g, bit_xor(alias1)@1 as bit_xor(DISTINCT 
distinct_simplify_t.v)]
+02)--AggregateExec: mode=FinalPartitioned, gby=[g@0 as g], 
aggr=[bit_xor(alias1)]
+03)----RepartitionExec: partitioning=Hash([g@0], 4), input_partitions=4
+04)------AggregateExec: mode=Partial, gby=[g@0 as g], aggr=[bit_xor(alias1)]
+05)--------AggregateExec: mode=FinalPartitioned, gby=[g@0 as g, alias1@1 as 
alias1], aggr=[]
+06)----------RepartitionExec: partitioning=Hash([g@0, alias1@1], 4), 
input_partitions=1
+07)------------AggregateExec: mode=Partial, gby=[g@0 as g, v@1 as alias1], 
aggr=[]
+08)--------------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# Negative case: count deduplicates for real
+query II
+SELECT g, count(DISTINCT v) FROM distinct_simplify_t GROUP BY g ORDER BY g;
+----
+1 2
+2 1
+
+query TT
+EXPLAIN SELECT g, count(DISTINCT v) FROM distinct_simplify_t GROUP BY g;
+----
+logical_plan
+01)Projection: distinct_simplify_t.g, count(alias1) AS count(DISTINCT 
distinct_simplify_t.v)
+02)--Aggregate: groupBy=[[distinct_simplify_t.g]], aggr=[[count(alias1)]]
+03)----Aggregate: groupBy=[[distinct_simplify_t.g, distinct_simplify_t.v AS 
alias1]], aggr=[[]]
+04)------TableScan: distinct_simplify_t projection=[g, v]
+physical_plan
+01)ProjectionExec: expr=[g@0 as g, count(alias1)@1 as count(DISTINCT 
distinct_simplify_t.v)]
+02)--AggregateExec: mode=FinalPartitioned, gby=[g@0 as g], aggr=[count(alias1)]
+03)----RepartitionExec: partitioning=Hash([g@0], 4), input_partitions=4
+04)------AggregateExec: mode=Partial, gby=[g@0 as g], aggr=[count(alias1)]
+05)--------AggregateExec: mode=FinalPartitioned, gby=[g@0 as g, alias1@1 as 
alias1], aggr=[]
+06)----------RepartitionExec: partitioning=Hash([g@0, alias1@1], 4), 
input_partitions=1
+07)------------AggregateExec: mode=Partial, gby=[g@0 as g, v@1 as alias1], 
aggr=[]
+08)--------------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# Mixed: a node that still needs one DISTINCT keeps all of them, so this rule
+# cannot change which plans SingleDistinctToGroupBy rewrites
+query III
+SELECT g, min(DISTINCT v), sum(DISTINCT v) FROM distinct_simplify_t GROUP BY g 
ORDER BY g;
+----
+1 3 8
+2 7 7
+
+query TT
+EXPLAIN SELECT g, min(DISTINCT v), sum(DISTINCT v) FROM distinct_simplify_t 
GROUP BY g;
+----
+logical_plan
+01)Aggregate: groupBy=[[distinct_simplify_t.g]], aggr=[[min(DISTINCT 
distinct_simplify_t.v), sum(DISTINCT CAST(distinct_simplify_t.v AS Int64))]]
+02)--TableScan: distinct_simplify_t projection=[g, v]
+physical_plan
+01)AggregateExec: mode=FinalPartitioned, gby=[g@0 as g], aggr=[min(DISTINCT 
distinct_simplify_t.v), sum(DISTINCT distinct_simplify_t.v)]
+02)--RepartitionExec: partitioning=Hash([g@0], 4), input_partitions=1
+03)----AggregateExec: mode=Partial, gby=[g@0 as g], aggr=[min(DISTINCT 
distinct_simplify_t.v), sum(DISTINCT distinct_simplify_t.v)]
+04)------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# FILTER is applied before deduplication, so it survives the rewrite
+query II
+SELECT g, min(DISTINCT v) FILTER (WHERE v > 3) FROM distinct_simplify_t GROUP 
BY g ORDER BY g;
+----
+1 5
+2 7
+
+query TT
+EXPLAIN SELECT g, min(DISTINCT v) FILTER (WHERE v > 3) FROM 
distinct_simplify_t GROUP BY g;
+----
+logical_plan
+01)Aggregate: groupBy=[[distinct_simplify_t.g]], 
aggr=[[min(distinct_simplify_t.v) FILTER (WHERE distinct_simplify_t.v > 
Int32(3)) AS min(DISTINCT distinct_simplify_t.v) FILTER (WHERE 
distinct_simplify_t.v > Int64(3))]]
+02)--TableScan: distinct_simplify_t projection=[g, v]
+physical_plan
+01)AggregateExec: mode=FinalPartitioned, gby=[g@0 as g], 
aggr=[min(distinct_simplify_t.v) FILTER (WHERE distinct_simplify_t.v > 
Int32(3)) as min(DISTINCT distinct_simplify_t.v) FILTER (WHERE 
distinct_simplify_t.v > Int64(3))]
+02)--RepartitionExec: partitioning=Hash([g@0], 4), input_partitions=1
+03)----AggregateExec: mode=Partial, gby=[g@0 as g], 
aggr=[min(distinct_simplify_t.v) FILTER (WHERE distinct_simplify_t.v > 
Int32(3)) as min(DISTINCT distinct_simplify_t.v) FILTER (WHERE 
distinct_simplify_t.v > Int64(3))]
+04)------DataSourceExec: partitions=1, partition_sizes=[1]
+
+statement ok

Review Comment:
   This PR changes one visible result. On `main`, `max(DISTINCT v)` over 
`(-1.0, -0.0)` prints `0.0`, because the inner group by turns `-0.0` into `0.0` 
in its hash key. On this branch it prints `-0.0`, the same as `max(v)`. This is 
an improvement, but no test pins it, and a test with an `R` column cannot catch 
it because `R` prints both values as `0`. Please add a test that compares the 
text form. Expected output below is from this branch. A sentence about this in 
the PR description would also help.
   
   ```suggestion
   # min/max(DISTINCT) over floats now sees the raw values. Before this rule, 
the
   # inner group by of SingleDistinctToGroupBy turned -0.0 into 0.0 in its hash
   # key, so max(DISTINCT v) printed 0.0 here. Now it agrees with max(v).
   # The R column type prints -0.0 as 0, so compare the text form.
   statement ok
   CREATE TABLE distinct_simplify_float_t (v DOUBLE) AS VALUES (-1.0), (-0.0), 
(-0.0);
   
   query TTTT
   SELECT cast(min(DISTINCT v) AS VARCHAR), cast(max(DISTINCT v) AS VARCHAR), 
cast(min(v) AS VARCHAR), cast(max(v) AS VARCHAR) FROM distinct_simplify_float_t;
   ----
   -1.0 -0.0 -1.0 -0.0
   
   statement ok
   DROP TABLE distinct_simplify_float_t;
   
   statement ok
   ```



##########
docs/source/library-user-guide/functions/adding-udfs.md:
##########
@@ -1116,6 +1116,25 @@ impl Accumulator for GeometricMean {
 }
 ```
 
+### Declaring how an Aggregate UDF treats `DISTINCT`
+
+By default DataFusion assumes an aggregate honours the `DISTINCT` modifier, 
which means the accumulator is expected to
+read `AccumulatorArgs::is_distinct` and deduplicate its input. Override
+[`AggregateUDFImpl::distinct_handling`] when that is not what your function 
does:
+
+- Return `DistinctHandling::Ignored` when duplicates cannot change the result, 
that is, when merging a value the
+  accumulator has already seen is a no-op. `min`, `max`, `bool_and` and 
`bit_or` are all in this group. The optimizer
+  then plans `f(DISTINCT x)` as `f(x)`, which skips both the per-group hash 
set and the extra grouping stage that
+  `SingleDistinctToGroupBy` would otherwise introduce.
+- Return `DistinctHandling::Unsupported` when the result depends on 
duplicates, the accumulator does not deduplicate
+  its input, and nothing deduplicates it first, so `f(DISTINCT x)` errors or 
silently returns the non-distinct answer.
+  Today this is a declaration only; rejecting such queries at planning time is 
a follow-up change.
+- Leave the default `DistinctHandling::Honored` otherwise. That includes an 
accumulator that rejects `DISTINCT` with an
+  error but relies on `SingleDistinctToGroupBy` to deduplicate the input 
first, as `stddev` does: the query works when
+  that rewrite applies and errors when it does not.

Review Comment:
   To match the variant definitions suggested in `udaf.rs`:
   
   ```suggestion
   - Return `DistinctHandling::Unsupported` when the accumulator does not 
implement `DISTINCT`: it does not read
     `is_distinct`, or it rejects `DISTINCT` with an error. The planner must 
then deduplicate the input first or reject
     the query. Today this is a declaration only; rejecting such queries at 
planning time is a follow-up change.
   - Leave the default `DistinctHandling::Honored` when the accumulator reads 
`AccumulatorArgs::is_distinct` and
     deduplicates its input itself.
   ```



##########
datafusion/functions-aggregate/src/stddev.rs:
##########
@@ -140,6 +140,12 @@ impl AggregateUDFImpl for Stddev {
     fn documentation(&self) -> Option<&Documentation> {
         self.doc()
     }
+
+    // Left at the default `Honored`. The accumulator rejects `DISTINCT` with
+    // `not_impl_err!`, so `f(DISTINCT x)` only works when
+    // `SingleDistinctToGroupBy` rewrites the node and deduplicates the input
+    // first. When it cannot, for example beside an `avg`, the query errors
+    // rather than returning the non-distinct answer.

Review Comment:
   This accumulator rejects `DISTINCT` with `not_impl_err!`, the same as 
`approx_percentile_cont_with_weight`, which is tagged `Unsupported`. With the 
variant definitions suggested in `udaf.rs`, this one is `Unsupported` too. The 
full path avoids a new import outside the diff; a `use` line is better.
   
   ```suggestion
       fn distinct_handling(&self) -> datafusion_expr::DistinctHandling {
           // The accumulator rejects `DISTINCT` with `not_impl_err!`, so the
           // planner has to deduplicate the input first.
           datafusion_expr::DistinctHandling::Unsupported
       }
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to