Skip to main content

oxisql_parse/
agg.rs

1//! Aggregate function analysis helpers.
2//!
3//! Provides structured types and extraction functions for aggregate expressions
4//! found in SQL SELECT projections.  The types here complement the string-based
5//! `aggregates: Vec<String>` stored in [`crate::LogicalPlan::Aggregate`] by
6//! giving callers a richer, typed view of the aggregate expressions.
7
8use sqlparser::ast::{Expr, FunctionArguments, SelectItem};
9
10// ── Public types ──────────────────────────────────────────────────────────────
11
12/// Named aggregate function with an output alias.
13#[derive(Debug, Clone, PartialEq)]
14pub struct AggregateExpr {
15    /// The aggregate function kind (COUNT, SUM, …).
16    pub func: AggFunc,
17    /// The column or expression being aggregated (e.g. `"*"`, `"amount"`).
18    pub input: String,
19    /// The output column alias (or a synthetic name when no alias is given).
20    pub alias: String,
21}
22
23/// Aggregate function variant.
24#[derive(Debug, Clone, PartialEq)]
25pub enum AggFunc {
26    /// `COUNT(*)` or `COUNT(col)`.
27    Count,
28    /// `SUM(expr)`.
29    Sum,
30    /// `AVG(expr)`.
31    Avg,
32    /// `MIN(expr)`.
33    Min,
34    /// `MAX(expr)`.
35    Max,
36    /// `COUNT(DISTINCT expr)`.
37    CountDistinct,
38}
39
40// ── Public API ────────────────────────────────────────────────────────────────
41
42/// Extract aggregate expressions from a sqlparser SELECT item list.
43///
44/// Only items that contain a top-level aggregate function call are returned.
45/// Items that do not reference an aggregate are silently skipped.
46pub fn extract_aggregates(select_items: &[SelectItem]) -> Vec<AggregateExpr> {
47    select_items
48        .iter()
49        .filter_map(extract_agg_from_item)
50        .collect()
51}
52
53/// Check if a sqlparser `Expr` is (or contains) an aggregate function call.
54///
55/// Returns `true` for direct aggregate function references.  Nested
56/// expressions inside `BinaryOp` and `Nested` are also inspected.
57pub fn is_aggregate_expr(expr: &Expr) -> bool {
58    expr_is_aggregate(expr)
59}
60
61// ── Helpers (also pub(crate) for use by lib.rs) ───────────────────────────────
62
63/// Return `true` if a SELECT projection item contains an aggregate function call.
64pub(crate) fn projection_item_is_aggregate(item: &SelectItem) -> bool {
65    match item {
66        SelectItem::UnnamedExpr(e)
67        | SelectItem::ExprWithAlias { expr: e, .. }
68        | SelectItem::ExprWithAliases { expr: e, .. } => expr_is_aggregate(e),
69        _ => false,
70    }
71}
72
73/// Recursively check whether `expr` contains an aggregate function call.
74pub(crate) fn expr_is_aggregate(expr: &Expr) -> bool {
75    match expr {
76        Expr::Function(f) => {
77            let name = f.name.to_string().to_ascii_uppercase();
78            matches!(
79                name.as_str(),
80                "COUNT"
81                    | "SUM"
82                    | "AVG"
83                    | "MIN"
84                    | "MAX"
85                    | "STDDEV"
86                    | "VARIANCE"
87                    | "ARRAY_AGG"
88                    | "STRING_AGG"
89            )
90        }
91        Expr::BinaryOp { left, right, .. } => expr_is_aggregate(left) || expr_is_aggregate(right),
92        Expr::Nested(inner) => expr_is_aggregate(inner),
93        _ => false,
94    }
95}
96
97// ── Private helpers ───────────────────────────────────────────────────────────
98
99fn extract_agg_from_item(item: &SelectItem) -> Option<AggregateExpr> {
100    match item {
101        SelectItem::UnnamedExpr(expr) => {
102            let agg = extract_agg_from_expr(expr)?;
103            // Synthesize alias from the raw string representation.
104            Some(AggregateExpr {
105                alias: agg_synthetic_alias(&agg.func, &agg.input),
106                func: agg.func,
107                input: agg.input,
108            })
109        }
110        SelectItem::ExprWithAlias { expr, alias } => {
111            let agg = extract_agg_from_expr(expr)?;
112            Some(AggregateExpr {
113                func: agg.func,
114                input: agg.input,
115                alias: alias.value.clone(),
116            })
117        }
118        SelectItem::ExprWithAliases { expr, aliases } => {
119            let agg = extract_agg_from_expr(expr)?;
120            let alias = aliases
121                .first()
122                .map(|a| a.value.clone())
123                .unwrap_or_else(|| agg_synthetic_alias(&agg.func, &agg.input));
124            Some(AggregateExpr {
125                func: agg.func,
126                input: agg.input,
127                alias,
128            })
129        }
130        _ => None,
131    }
132}
133
134/// Partial aggregate expression (before alias is resolved).
135struct PartialAgg {
136    func: AggFunc,
137    input: String,
138}
139
140fn extract_agg_from_expr(expr: &Expr) -> Option<PartialAgg> {
141    if let Expr::Function(f) = expr {
142        let name = f.name.to_string().to_ascii_uppercase();
143        let func = match name.as_str() {
144            "COUNT" => {
145                // Detect COUNT(DISTINCT …).
146                if is_distinct_args(&f.args) {
147                    AggFunc::CountDistinct
148                } else {
149                    AggFunc::Count
150                }
151            }
152            "SUM" => AggFunc::Sum,
153            "AVG" => AggFunc::Avg,
154            "MIN" => AggFunc::Min,
155            "MAX" => AggFunc::Max,
156            _ => return None,
157        };
158        let input = extract_first_arg_string(&f.args);
159        Some(PartialAgg { func, input })
160    } else {
161        None
162    }
163}
164
165/// Returns `true` when the function arguments include a `DISTINCT` qualifier.
166fn is_distinct_args(args: &FunctionArguments) -> bool {
167    match args {
168        FunctionArguments::List(list) => list.duplicate_treatment.is_some(),
169        _ => false,
170    }
171}
172
173/// Extract the string representation of the first argument, or `"*"` for
174/// wildcard / empty argument lists.
175fn extract_first_arg_string(args: &FunctionArguments) -> String {
176    match args {
177        FunctionArguments::List(list) => {
178            if let Some(first) = list.args.first() {
179                first.to_string()
180            } else {
181                "*".to_string()
182            }
183        }
184        FunctionArguments::None => "*".to_string(),
185        FunctionArguments::Subquery(q) => q.to_string(),
186    }
187}
188
189/// Build a synthetic alias like `count_star` or `sum_amount`.
190fn agg_synthetic_alias(func: &AggFunc, input: &str) -> String {
191    let fn_part = match func {
192        AggFunc::Count | AggFunc::CountDistinct => "count",
193        AggFunc::Sum => "sum",
194        AggFunc::Avg => "avg",
195        AggFunc::Min => "min",
196        AggFunc::Max => "max",
197    };
198    let col_part = if input == "*" {
199        "star".to_string()
200    } else {
201        input.replace(|c: char| !c.is_alphanumeric(), "_")
202    };
203    format!("{fn_part}_{col_part}")
204}
205
206// ── Tests ─────────────────────────────────────────────────────────────────────
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::parse;
212
213    fn select_items(sql: &str) -> Vec<SelectItem> {
214        let stmts = parse(sql).expect("parse");
215        let stmt = stmts.into_iter().next().expect("one stmt");
216        if let sqlparser::ast::Statement::Query(q) = stmt {
217            if let sqlparser::ast::SetExpr::Select(sel) = *q.body {
218                return sel.projection;
219            }
220        }
221        vec![]
222    }
223
224    #[test]
225    fn test_extract_count_star() {
226        let items = select_items("SELECT COUNT(*) FROM t");
227        let aggs = extract_aggregates(&items);
228        assert_eq!(aggs.len(), 1);
229        assert_eq!(aggs[0].func, AggFunc::Count);
230        assert_eq!(aggs[0].input, "*");
231    }
232
233    #[test]
234    fn test_extract_sum() {
235        let items = select_items("SELECT SUM(amount) AS total FROM orders");
236        let aggs = extract_aggregates(&items);
237        assert_eq!(aggs.len(), 1);
238        assert_eq!(aggs[0].func, AggFunc::Sum);
239        assert_eq!(aggs[0].alias, "total");
240    }
241
242    #[test]
243    fn test_extract_count_distinct() {
244        let items = select_items("SELECT COUNT(DISTINCT user_id) FROM events");
245        let aggs = extract_aggregates(&items);
246        assert_eq!(aggs.len(), 1, "expected one aggregate, got {:?}", aggs);
247        assert_eq!(aggs[0].func, AggFunc::CountDistinct);
248    }
249
250    #[test]
251    fn test_is_aggregate_true() {
252        let items = select_items("SELECT COUNT(*) FROM t");
253        let item = items.into_iter().next().expect("item");
254        if let SelectItem::UnnamedExpr(expr) = item {
255            assert!(is_aggregate_expr(&expr));
256        } else {
257            panic!("expected UnnamedExpr");
258        }
259    }
260
261    #[test]
262    fn test_is_aggregate_false() {
263        let items = select_items("SELECT id FROM t");
264        let item = items.into_iter().next().expect("item");
265        if let SelectItem::UnnamedExpr(expr) = item {
266            assert!(!is_aggregate_expr(&expr));
267        } else {
268            panic!("expected UnnamedExpr");
269        }
270    }
271
272    #[test]
273    fn test_having_produces_filter_over_aggregate() {
274        use crate::{parse_one, plan_statement};
275
276        let stmt = parse_one(
277            "SELECT dept, AVG(salary) FROM employees GROUP BY dept HAVING AVG(salary) > 50000",
278        )
279        .expect("parse");
280        let plan = plan_statement(&stmt).expect("plan");
281
282        // The outermost plan node should be Filter (from HAVING).
283        // It wraps Aggregate.
284        match &plan {
285            crate::LogicalPlan::Filter { input, predicate } => {
286                assert!(
287                    predicate.contains("50000") || !predicate.is_empty(),
288                    "HAVING predicate should be non-empty: {predicate}"
289                );
290                assert!(
291                    matches!(input.as_ref(), crate::LogicalPlan::Aggregate { .. }),
292                    "Filter should wrap Aggregate, got: {input:?}"
293                );
294            }
295            other => panic!("expected Filter over Aggregate, got: {other:?}"),
296        }
297    }
298}