Skip to main content

datafusion_optimizer/
replace_distinct_aggregate.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! [`ReplaceDistinctWithAggregate`] replaces `DISTINCT ...` with `GROUP BY ...`
19
20use crate::optimizer::{ApplyOrder, ApplyOrder::BottomUp};
21use crate::{OptimizerConfig, OptimizerRule};
22use std::sync::Arc;
23
24use datafusion_common::tree_node::Transformed;
25use datafusion_common::{Column, Dependency, Result};
26use datafusion_expr::expr_rewriter::normalize_cols;
27use datafusion_expr::utils::expand_wildcard;
28use datafusion_expr::{Aggregate, Distinct, DistinctOn, Expr, LogicalPlan};
29use datafusion_expr::{ExprFunctionExt, Limit, LogicalPlanBuilder, col, lit};
30
31/// Optimizer that replaces logical [[Distinct]] with a logical [[Aggregate]]
32///
33/// ```text
34/// SELECT DISTINCT a, b FROM tab
35/// ```
36///
37/// Into
38/// ```text
39/// SELECT a, b FROM tab GROUP BY a, b
40/// ```
41///
42/// On the other hand, for a `DISTINCT ON` query the replacement is
43/// a bit more involved and effectively converts
44/// ```text
45/// SELECT DISTINCT ON (a) b FROM tab ORDER BY a DESC, c
46/// ```
47///
48/// into
49/// ```text
50/// SELECT b FROM (
51///     SELECT a, FIRST_VALUE(b ORDER BY a DESC, c) AS b
52///     FROM tab
53///     GROUP BY a
54/// )
55/// ORDER BY a DESC
56/// ```
57///
58/// In case there are no columns, the [[Distinct]] is replaced by a [[Limit]]
59///
60/// ```text
61/// SELECT DISTINCT * FROM empty_table
62/// ```
63///
64/// Into
65/// ```text
66/// SELECT * FROM empty_table LIMIT 1
67/// ```
68#[derive(Default, Debug)]
69pub struct ReplaceDistinctWithAggregate {}
70
71impl ReplaceDistinctWithAggregate {
72    #[expect(missing_docs)]
73    pub fn new() -> Self {
74        Self {}
75    }
76}
77
78impl OptimizerRule for ReplaceDistinctWithAggregate {
79    fn supports_rewrite(&self) -> bool {
80        true
81    }
82
83    fn rewrite(
84        &self,
85        plan: LogicalPlan,
86        config: &dyn OptimizerConfig,
87    ) -> Result<Transformed<LogicalPlan>> {
88        match plan {
89            LogicalPlan::Distinct(Distinct::All(input)) => {
90                let group_expr = expand_wildcard(input.schema(), &input, None)?;
91
92                if group_expr.is_empty() {
93                    // Special case: there are no columns to group by, so we can't replace it by a group by
94                    // however, we can replace it by LIMIT 1 because there is either no output or a single empty row
95                    return Ok(Transformed::yes(LogicalPlan::Limit(Limit {
96                        skip: None,
97                        fetch: Some(Box::new(lit(1i64))),
98                        input,
99                    })));
100                }
101
102                let field_count = input.schema().fields().len();
103                for dep in input.schema().functional_dependencies().iter() {
104                    // If the input is already unique on all of its columns (e.g.
105                    // it is a GROUP BY over exactly these columns), the DISTINCT
106                    // is a no-op and we can simply remove it. The dependency mode
107                    // must be `Single`: a `Multi` dependence (e.g. a former key
108                    // downgraded by a join) means equal rows may occur multiple
109                    // times, so the DISTINCT still has work to do.
110                    if dep.mode == Dependency::Single
111                        && dep.source_indices.len() >= field_count
112                        && dep.source_indices[..field_count]
113                            .iter()
114                            .enumerate()
115                            .all(|(idx, f_idx)| idx == *f_idx)
116                    {
117                        return Ok(Transformed::yes(Arc::unwrap_or_clone(input)));
118                    }
119                }
120
121                // Replace with aggregation:
122                let aggr_plan = LogicalPlan::Aggregate(Aggregate::try_new(
123                    input,
124                    group_expr,
125                    vec![],
126                )?);
127                Ok(Transformed::yes(aggr_plan))
128            }
129            LogicalPlan::Distinct(Distinct::On(DistinctOn {
130                select_expr,
131                on_expr,
132                sort_expr,
133                input,
134                schema,
135            })) => {
136                let expr_cnt = on_expr.len();
137
138                // Construct the aggregation expression to be used to fetch the selected expressions.
139                let first_value_udaf: Arc<datafusion_expr::AggregateUDF> =
140                    config.function_registry().unwrap().udaf("first_value")?;
141                let aggr_expr = select_expr.into_iter().map(|e| {
142                    if let Some(order_by) = &sort_expr {
143                        first_value_udaf
144                            .call(vec![e])
145                            .order_by(order_by.clone())
146                            .build()
147                            // guaranteed to be `Expr::AggregateFunction`
148                            .unwrap()
149                    } else {
150                        first_value_udaf.call(vec![e])
151                    }
152                });
153
154                let aggr_expr = normalize_cols(aggr_expr, input.as_ref())?;
155                let group_expr = normalize_cols(on_expr, input.as_ref())?;
156
157                // Build the aggregation plan
158                let plan = LogicalPlan::Aggregate(Aggregate::try_new(
159                    input, group_expr, aggr_expr,
160                )?);
161                // TODO use LogicalPlanBuilder directly rather than recreating the Aggregate
162                // when https://github.com/apache/datafusion/issues/10485 is available
163                let lpb = LogicalPlanBuilder::from(plan);
164
165                let plan = if let Some(mut sort_expr) = sort_expr {
166                    // While sort expressions were used in the `FIRST_VALUE` aggregation itself above,
167                    // this on it's own isn't enough to guarantee the proper output order of the grouping
168                    // (`ON`) expression, so we need to sort those as well.
169
170                    // truncate the sort_expr to the length of on_expr
171                    sort_expr.truncate(expr_cnt);
172
173                    lpb.sort(sort_expr)?.build()?
174                } else {
175                    lpb.build()?
176                };
177
178                // Whereas the aggregation plan by default outputs both the grouping and the aggregation
179                // expressions, for `DISTINCT ON` we only need to emit the original selection expressions.
180
181                let project_exprs = plan
182                    .schema()
183                    .iter()
184                    .skip(expr_cnt)
185                    .zip(schema.iter())
186                    .map(|((new_qualifier, new_field), (old_qualifier, old_field))| {
187                        col(Column::from((new_qualifier, new_field)))
188                            .alias_qualified(old_qualifier.cloned(), old_field.name())
189                    })
190                    .collect::<Vec<Expr>>();
191
192                let plan = LogicalPlanBuilder::from(plan)
193                    .project(project_exprs)?
194                    .build()?;
195
196                Ok(Transformed::yes(plan))
197            }
198            _ => Ok(Transformed::no(plan)),
199        }
200    }
201
202    fn name(&self) -> &str {
203        "replace_distinct_aggregate"
204    }
205
206    fn apply_order(&self) -> Option<ApplyOrder> {
207        Some(BottomUp)
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use crate::assert_optimized_plan_eq_snapshot;
214    use crate::replace_distinct_aggregate::ReplaceDistinctWithAggregate;
215    use crate::test::*;
216    use arrow::datatypes::{Fields, Schema};
217    use std::sync::Arc;
218
219    use crate::OptimizerContext;
220    use datafusion_common::Result;
221    use datafusion_expr::{
222        Expr, col, logical_plan::builder::LogicalPlanBuilder, table_scan,
223    };
224    use datafusion_functions_aggregate::sum::sum;
225
226    macro_rules! assert_optimized_plan_equal {
227        (
228            $plan:expr,
229            @ $expected:literal $(,)?
230        ) => {{
231            let optimizer_ctx = OptimizerContext::new().with_max_passes(1);
232            let rules: Vec<Arc<dyn crate::OptimizerRule + Send + Sync>> = vec![Arc::new(ReplaceDistinctWithAggregate::new())];
233            assert_optimized_plan_eq_snapshot!(
234                optimizer_ctx,
235                rules,
236                $plan,
237                @ $expected,
238            )
239        }};
240    }
241
242    #[test]
243    fn eliminate_redundant_distinct_simple() -> Result<()> {
244        let table_scan = test_table_scan().unwrap();
245        let plan = LogicalPlanBuilder::from(table_scan)
246            .aggregate(vec![col("c")], Vec::<Expr>::new())?
247            .project(vec![col("c")])?
248            .distinct()?
249            .build()?;
250
251        assert_optimized_plan_equal!(plan, @r"
252        Projection: test.c
253          Aggregate: groupBy=[[test.c]], aggr=[[]]
254            TableScan: test
255        ")
256    }
257
258    #[test]
259    fn eliminate_redundant_distinct_pair() -> Result<()> {
260        let table_scan = test_table_scan().unwrap();
261        let plan = LogicalPlanBuilder::from(table_scan)
262            .aggregate(vec![col("a"), col("b")], Vec::<Expr>::new())?
263            .project(vec![col("a"), col("b")])?
264            .distinct()?
265            .build()?;
266
267        assert_optimized_plan_equal!(plan, @r"
268        Projection: test.a, test.b
269          Aggregate: groupBy=[[test.a, test.b]], aggr=[[]]
270            TableScan: test
271        ")
272    }
273
274    #[test]
275    fn do_not_eliminate_distinct() -> Result<()> {
276        let table_scan = test_table_scan().unwrap();
277        let plan = LogicalPlanBuilder::from(table_scan)
278            .project(vec![col("a"), col("b")])?
279            .distinct()?
280            .build()?;
281
282        assert_optimized_plan_equal!(plan, @r"
283        Aggregate: groupBy=[[test.a, test.b]], aggr=[[]]
284          Projection: test.a, test.b
285            TableScan: test
286        ")
287    }
288
289    #[test]
290    fn do_not_eliminate_distinct_with_aggr() -> Result<()> {
291        let table_scan = test_table_scan().unwrap();
292        let plan = LogicalPlanBuilder::from(table_scan)
293            .aggregate(vec![col("a"), col("b"), col("c")], vec![sum(col("c"))])?
294            .project(vec![col("a"), col("b")])?
295            .distinct()?
296            .build()?;
297
298        assert_optimized_plan_equal!(plan, @r"
299        Aggregate: groupBy=[[test.a, test.b]], aggr=[[]]
300          Projection: test.a, test.b
301            Aggregate: groupBy=[[test.a, test.b, test.c]], aggr=[[sum(test.c)]]
302              TableScan: test
303        ")
304    }
305
306    #[test]
307    fn use_limit_1_when_no_columns() -> Result<()> {
308        let plan = table_scan(Some("test"), &Schema::new(Fields::empty()), None)?
309            .distinct()?
310            .build()?;
311
312        assert_optimized_plan_equal!(plan, @r"
313        Limit: skip=0, fetch=1
314          TableScan: test
315        ")
316    }
317}