Skip to main content

nodedb_sql/
aggregate_walk.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! AST traversal for aggregate detection and extraction.
4//!
5//! Uses sqlparser's own `Visitor` so aggregates nested inside any
6//! expression position — `CASE`, `CAST`, `COALESCE`, `-SUM(x)`,
7//! `SUM(x) BETWEEN ...`, `SUM(x) IN (...)`, window specs, array
8//! elements — are found without us maintaining a hand-written walker
9//! that has to enumerate every `Expr` variant.
10//!
11//! # What this replaces
12//!
13//! Two earlier hand-written walkers only descended through `Function`,
14//! `BinaryOp`, and `Nested`. Every other position was a silent
15//! fall-through that produced wrong plans for ordinary SQL:
16//!
17//! - `SELECT CASE WHEN x > 0 THEN SUM(y) ELSE 0 END FROM t`
18//! - `SELECT CAST(SUM(x) AS TEXT) FROM t`
19//! - `SELECT -SUM(x) FROM t`
20//! - `SELECT COALESCE(SUM(x), 0) FROM t`
21//!
22//! None of those were recognised as aggregates; the planner took the
23//! non-aggregate path and produced a plan that executed the inner
24//! column scan without grouping.
25
26use core::ops::ControlFlow;
27
28use sqlparser::ast::{self, Expr, Visit, Visitor};
29
30use crate::error::{Result, SqlError};
31use crate::functions::registry::FunctionRegistry;
32use crate::parser::normalize::normalize_ident;
33use crate::resolver::ColumnScope;
34use crate::resolver::columns::TableScope;
35use crate::resolver::expr::convert_expr;
36use crate::types::{AggregateExpr, SqlExpr, SqlValue};
37
38/// Return `true` if any aggregate function call appears anywhere inside
39/// `expr` — at any nesting depth, inside any expression position.
40pub fn contains_aggregate(expr: &Expr, functions: &FunctionRegistry) -> bool {
41    let mut detector = AggregateDetector {
42        functions,
43        found: false,
44    };
45    let _ = expr.visit(&mut detector);
46    detector.found
47}
48
49/// Extract every aggregate function call from `expr`, binding each to
50/// the given output `alias`. Nested aggregates (e.g. `SUM(AVG(x))`,
51/// which is illegal SQL in Postgres and most other systems) are
52/// reported as a planner error rather than silently double-extracted.
53///
54/// `scope` carries the relations the arguments resolve against, so a
55/// column no relation declares is rejected at plan time.
56pub fn extract_aggregates(
57    expr: &Expr,
58    alias: &str,
59    functions: &FunctionRegistry,
60    scope: &TableScope,
61) -> Result<Vec<AggregateExpr>> {
62    let mut extractor = AggregateExtractor {
63        functions,
64        alias,
65        scope,
66        inside_aggregate: 0,
67        out: Vec::new(),
68        error: None,
69    };
70    let _ = expr.visit(&mut extractor);
71    if let Some(e) = extractor.error {
72        return Err(e);
73    }
74    Ok(extractor.out)
75}
76
77// ── Detector ────────────────────────────────────────────────────────
78
79struct AggregateDetector<'a> {
80    functions: &'a FunctionRegistry,
81    found: bool,
82}
83
84impl Visitor for AggregateDetector<'_> {
85    type Break = ();
86
87    fn pre_visit_expr(&mut self, expr: &Expr) -> ControlFlow<()> {
88        if let Expr::Function(f) = expr
89            && self.functions.is_aggregate(&function_name(f))
90            // A function with an OVER clause is a window function, not an
91            // aggregate. Window functions are handled by the window planner
92            // and must not trigger the GROUP BY / aggregate plan path.
93            && f.over.is_none()
94        {
95            self.found = true;
96            return ControlFlow::Break(());
97        }
98        ControlFlow::Continue(())
99    }
100}
101
102// ── Extractor ───────────────────────────────────────────────────────
103
104struct AggregateExtractor<'a> {
105    functions: &'a FunctionRegistry,
106    alias: &'a str,
107    scope: &'a TableScope,
108    /// Depth counter: >0 means we're currently inside the argument
109    /// subtree of an already-extracted aggregate. A second aggregate
110    /// found in that subtree is an illegal nested aggregate.
111    inside_aggregate: u32,
112    out: Vec<AggregateExpr>,
113    /// Deferred error — `Visitor::Break` is `()` so we can't carry the
114    /// error through the control flow directly without boxing. Storing
115    /// it and short-circuiting the traversal on next pre-visit gives
116    /// the same observable behavior.
117    error: Option<SqlError>,
118}
119
120impl Visitor for AggregateExtractor<'_> {
121    type Break = ();
122
123    fn pre_visit_expr(&mut self, expr: &Expr) -> ControlFlow<()> {
124        if self.error.is_some() {
125            return ControlFlow::Break(());
126        }
127        if let Expr::Function(f) = expr
128            && self.functions.is_aggregate(&function_name(f))
129            // Skip window function calls — those with OVER are handled by the
130            // window planner, not the aggregate planner.
131            && f.over.is_none()
132        {
133            if self.inside_aggregate > 0 {
134                self.error = Some(SqlError::Unsupported {
135                    detail: format!(
136                        "nested aggregate functions are not allowed: {}(...{}...)",
137                        function_name(f),
138                        function_name(f),
139                    ),
140                });
141                return ControlFlow::Break(());
142            }
143            let (args, distinct) = match function_args_and_distinct(f, self.scope) {
144                Ok(parsed) => parsed,
145                Err(e) => {
146                    self.error = Some(e);
147                    return ControlFlow::Break(());
148                }
149            };
150            let filter = match aggregate_filter(f, self.scope) {
151                Ok(filter) => filter,
152                Err(e) => {
153                    self.error = Some(e);
154                    return ControlFlow::Break(());
155                }
156            };
157            let mut arg_text = computed_arg_text(f, &args);
158            let (args, filter_text) = match filter {
159                Some((predicate, text)) => {
160                    // The folded argument is a CASE; the key keeps naming
161                    // the argument as written.
162                    if arg_text.is_none() {
163                        arg_text = Some(match args.first() {
164                            Some(SqlExpr::Column { name, .. }) => name.clone(),
165                            _ => "*".to_string(),
166                        });
167                    }
168                    (filtered_args(args, predicate), Some(text))
169                }
170                None => (args, None),
171            };
172            self.out.push(AggregateExpr {
173                function: function_name(f),
174                args,
175                alias: self.alias.into(),
176                distinct,
177                grouping_col_index: None,
178                filter_text,
179                arg_text,
180            });
181            self.inside_aggregate += 1;
182        }
183        ControlFlow::Continue(())
184    }
185
186    fn post_visit_expr(&mut self, expr: &Expr) -> ControlFlow<()> {
187        if let Expr::Function(f) = expr
188            && self.functions.is_aggregate(&function_name(f))
189            && f.over.is_none()
190            && self.inside_aggregate > 0
191        {
192            self.inside_aggregate -= 1;
193        }
194        ControlFlow::Continue(())
195    }
196}
197
198// ── Helpers ─────────────────────────────────────────────────────────
199
200/// Return the function name, or a qualified stub that will never match any
201/// registered function if the name is schema-qualified.
202///
203/// Detection (`contains_aggregate`) uses this to look up the function in the
204/// registry — a qualified name won't be found, so the aggregate check is a
205/// safe no-op. Extraction (`extract_aggregates`) will not reach this path
206/// for schema-qualified names because `convert_expr` rejects them first.
207fn function_name(f: &ast::Function) -> String {
208    if f.name.0.len() > 1 {
209        // Return a sentinel that cannot match any registry entry.
210        // The actual rejection happens in convert_expr / convert_function_depth.
211        let qualified: String = f
212            .name
213            .0
214            .iter()
215            .map(|p| match p {
216                ast::ObjectNamePart::Identifier(ident) => ident.value.clone(),
217                _ => String::new(),
218            })
219            .collect::<Vec<_>>()
220            .join(".");
221        return format!("__schema_qualified__{qualified}");
222    }
223    f.name
224        .0
225        .iter()
226        .map(|p| match p {
227            ast::ObjectNamePart::Identifier(ident) => normalize_ident(ident),
228            _ => String::new(),
229        })
230        .collect::<Vec<_>>()
231        .join(".")
232}
233
234/// Convert an aggregate call's argument list against `scope`.
235///
236/// An argument the converter rejects is an error, never a dropped argument:
237/// discarding one silently changes the aggregate's arity and hides an unknown
238/// column behind a query that reports success.
239fn function_args_and_distinct(
240    f: &ast::Function,
241    scope: &TableScope,
242) -> Result<(Vec<SqlExpr>, bool)> {
243    let ast::FunctionArguments::List(args) = &f.args else {
244        return Ok((Vec::new(), false));
245    };
246    let mut parsed = Vec::with_capacity(args.args.len());
247    for a in &args.args {
248        match a {
249            ast::FunctionArg::Unnamed(ast::FunctionArgExpr::Expr(e)) => {
250                parsed.push(convert_expr(e, &ColumnScope::Relations(scope))?);
251            }
252            // `COUNT(*)` carries no column to resolve.
253            ast::FunctionArg::Unnamed(ast::FunctionArgExpr::Wildcard) => {
254                parsed.push(SqlExpr::Wildcard);
255            }
256            _ => {}
257        }
258    }
259    let distinct = matches!(
260        args.duplicate_treatment,
261        Some(ast::DuplicateTreatment::Distinct)
262    );
263    Ok((parsed, distinct))
264}
265
266/// Convert an aggregate call's `FILTER (WHERE ...)` clause against `scope`:
267/// the predicate and its lowercase source text.
268///
269/// An aggregate inside the predicate is rejected by the extractor's nested
270/// aggregate check, because the visitor descends into the clause while the
271/// enclosing aggregate is open.
272fn aggregate_filter(f: &ast::Function, scope: &TableScope) -> Result<Option<(SqlExpr, String)>> {
273    let Some(predicate) = f.filter.as_deref() else {
274        return Ok(None);
275    };
276    Ok(Some((
277        convert_expr(predicate, &ColumnScope::Relations(scope))?,
278        format!("{predicate}").to_lowercase(),
279    )))
280}
281
282/// Fold a `FILTER` predicate into the aggregate's arguments.
283///
284/// Each argument becomes `CASE WHEN <predicate> THEN <arg> END`, NULL for
285/// a row the predicate does not admit. Every aggregate skips a NULL input,
286/// so the result equals PostgreSQL's FILTER semantics. `COUNT(*)` counts
287/// rows, so its wildcard becomes the constant 1.
288fn filtered_args(args: Vec<SqlExpr>, predicate: SqlExpr) -> Vec<SqlExpr> {
289    let args = if args.is_empty() {
290        vec![SqlExpr::Wildcard]
291    } else {
292        args
293    };
294    args.into_iter()
295        .map(|arg| {
296            let value = match arg {
297                SqlExpr::Wildcard => SqlExpr::Literal(SqlValue::Int(1)),
298                other => other,
299            };
300            SqlExpr::Case {
301                operand: None,
302                when_then: vec![(predicate.clone(), value)],
303                else_expr: None,
304            }
305        })
306        .collect()
307}
308
309/// Lowercase source text of the first argument when it is computed. A
310/// column or wildcard argument is named by the column itself.
311fn computed_arg_text(f: &ast::Function, args: &[SqlExpr]) -> Option<String> {
312    match args.first() {
313        None | Some(SqlExpr::Column { .. }) | Some(SqlExpr::Wildcard) => return None,
314        Some(_) => {}
315    }
316    let ast::FunctionArguments::List(list) = &f.args else {
317        return None;
318    };
319    list.args.iter().find_map(|a| match a {
320        ast::FunctionArg::Unnamed(ast::FunctionArgExpr::Expr(e)) => {
321            Some(format!("{e}").to_lowercase())
322        }
323        _ => None,
324    })
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use crate::parser::statement::parse_sql;
331    use crate::resolver::columns::ResolvedTable;
332    use crate::types::{CollectionInfo, EngineType};
333
334    fn first_select_projection(sql: &str) -> Vec<ast::SelectItem> {
335        let stmts = parse_sql(sql).unwrap();
336        match stmts.into_iter().next().unwrap() {
337            ast::Statement::Query(q) => match *q.body {
338                ast::SetExpr::Select(s) => s.projection,
339                _ => panic!(),
340            },
341            _ => panic!(),
342        }
343    }
344
345    fn first_expr(sql: &str) -> ast::Expr {
346        match first_select_projection(sql).into_iter().next().unwrap() {
347            ast::SelectItem::UnnamedExpr(e) | ast::SelectItem::ExprWithAlias { expr: e, .. } => e,
348            _ => panic!(),
349        }
350    }
351
352    fn functions() -> FunctionRegistry {
353        FunctionRegistry::new()
354    }
355
356    /// A one-relation scope that accepts any column name.
357    fn open_scope() -> TableScope {
358        let info = CollectionInfo {
359            name: "t".into(),
360            engine: EngineType::DocumentSchemaless,
361            columns: Vec::new(),
362            primary_key: None,
363            has_auto_tier: false,
364            indexes: Vec::new(),
365            bitemporal: false,
366            primary: nodedb_types::PrimaryEngine::Document,
367            vector_primary: None,
368            partition_strategy: nodedb_types::PartitionStrategy::CollectionHomed,
369            open_schema: CollectionInfo::open_schema_for(EngineType::DocumentSchemaless),
370        };
371        TableScope::single(ResolvedTable {
372            name: info.name.clone(),
373            alias: None,
374            info,
375        })
376        .expect("single-relation scope")
377    }
378
379    // ── detection ──
380
381    #[test]
382    fn detect_plain_aggregate() {
383        assert!(contains_aggregate(
384            &first_expr("SELECT SUM(x) FROM t"),
385            &functions()
386        ));
387    }
388
389    #[test]
390    fn detect_aggregate_inside_case() {
391        assert!(contains_aggregate(
392            &first_expr("SELECT CASE WHEN x > 0 THEN SUM(y) ELSE 0 END FROM t"),
393            &functions(),
394        ));
395    }
396
397    #[test]
398    fn detect_aggregate_inside_cast() {
399        assert!(contains_aggregate(
400            &first_expr("SELECT CAST(SUM(x) AS TEXT) FROM t"),
401            &functions(),
402        ));
403    }
404
405    #[test]
406    fn detect_aggregate_inside_unary_op() {
407        assert!(contains_aggregate(
408            &first_expr("SELECT -SUM(x) FROM t"),
409            &functions(),
410        ));
411    }
412
413    #[test]
414    fn detect_aggregate_inside_coalesce() {
415        assert!(contains_aggregate(
416            &first_expr("SELECT COALESCE(SUM(x), 0) FROM t"),
417            &functions(),
418        ));
419    }
420
421    #[test]
422    fn detect_aggregate_inside_between() {
423        assert!(contains_aggregate(
424            &first_expr("SELECT SUM(x) BETWEEN 1 AND 10 FROM t"),
425            &functions(),
426        ));
427    }
428
429    #[test]
430    fn detect_aggregate_inside_in_list() {
431        assert!(contains_aggregate(
432            &first_expr("SELECT SUM(x) IN (1, 2, 3) FROM t"),
433            &functions(),
434        ));
435    }
436
437    #[test]
438    fn no_aggregate_in_plain_select() {
439        assert!(!contains_aggregate(
440            &first_expr("SELECT x FROM t"),
441            &functions()
442        ));
443        assert!(!contains_aggregate(
444            &first_expr("SELECT x + 1 FROM t"),
445            &functions()
446        ));
447        assert!(!contains_aggregate(
448            &first_expr("SELECT upper(name) FROM t"),
449            &functions(),
450        ));
451    }
452
453    // ── extraction ──
454
455    #[test]
456    fn extract_plain_aggregate() {
457        let aggs = extract_aggregates(
458            &first_expr("SELECT SUM(x) FROM t"),
459            "total",
460            &functions(),
461            &open_scope(),
462        )
463        .unwrap();
464        assert_eq!(aggs.len(), 1);
465        assert_eq!(aggs[0].function, "sum");
466        assert_eq!(aggs[0].alias, "total");
467    }
468
469    #[test]
470    fn extract_aggregate_inside_cast() {
471        let aggs = extract_aggregates(
472            &first_expr("SELECT CAST(SUM(x) AS TEXT) AS n FROM t"),
473            "n",
474            &functions(),
475            &open_scope(),
476        )
477        .unwrap();
478        assert_eq!(aggs.len(), 1);
479        assert_eq!(aggs[0].function, "sum");
480    }
481
482    #[test]
483    fn extract_aggregate_inside_case() {
484        let aggs = extract_aggregates(
485            &first_expr("SELECT CASE WHEN x > 0 THEN SUM(y) ELSE 0 END FROM t"),
486            "r",
487            &functions(),
488            &open_scope(),
489        )
490        .unwrap();
491        assert_eq!(aggs.len(), 1);
492        assert_eq!(aggs[0].function, "sum");
493    }
494
495    #[test]
496    fn extract_aggregate_inside_coalesce() {
497        let aggs = extract_aggregates(
498            &first_expr("SELECT COALESCE(SUM(x), 0) FROM t"),
499            "r",
500            &functions(),
501            &open_scope(),
502        )
503        .unwrap();
504        assert_eq!(aggs.len(), 1);
505        assert_eq!(aggs[0].function, "sum");
506    }
507
508    #[test]
509    fn extract_two_aggregates_under_one_alias() {
510        let aggs = extract_aggregates(
511            &first_expr("SELECT SUM(x) + COUNT(y) AS total FROM t"),
512            "total",
513            &functions(),
514            &open_scope(),
515        )
516        .unwrap();
517        assert_eq!(aggs.len(), 2);
518        let names: Vec<&str> = aggs.iter().map(|a| a.function.as_str()).collect();
519        assert!(names.contains(&"sum"));
520        assert!(names.contains(&"count"));
521    }
522
523    #[test]
524    fn nested_aggregate_directly_inside_aggregate_rejected() {
525        let err = extract_aggregates(
526            &first_expr("SELECT SUM(AVG(x)) FROM t"),
527            "r",
528            &functions(),
529            &open_scope(),
530        )
531        .unwrap_err();
532        let msg = format!("{err:?}");
533        assert!(
534            msg.to_lowercase().contains("nested aggregate"),
535            "error must identify the nested-aggregate class: {msg}"
536        );
537    }
538
539    /// Nested aggregate buried under an intermediate non-aggregate
540    /// (`AVG(x)` wrapped in `CAST(...)` inside `SUM(...)`) must also
541    /// be caught — the depth counter tracks the ancestor aggregate,
542    /// not the direct parent.
543    #[test]
544    fn nested_aggregate_through_cast_rejected() {
545        let err = extract_aggregates(
546            &first_expr("SELECT SUM(CAST(AVG(x) AS BIGINT)) FROM t"),
547            "r",
548            &functions(),
549            &open_scope(),
550        )
551        .unwrap_err();
552        assert!(
553            format!("{err:?}")
554                .to_lowercase()
555                .contains("nested aggregate"),
556            "got: {err:?}"
557        );
558    }
559
560    /// Two top-level aggregates separated by a non-aggregate binary
561    /// operator are siblings, not nested — both must extract cleanly.
562    #[test]
563    fn sibling_aggregates_not_treated_as_nested() {
564        let aggs = extract_aggregates(
565            &first_expr("SELECT CAST(SUM(x) AS TEXT) || CAST(COUNT(y) AS TEXT) FROM t"),
566            "r",
567            &functions(),
568            &open_scope(),
569        )
570        .unwrap();
571        assert_eq!(aggs.len(), 2);
572    }
573
574    #[test]
575    fn extract_distinct_preserved() {
576        let aggs = extract_aggregates(
577            &first_expr("SELECT COUNT(DISTINCT x) FROM t"),
578            "c",
579            &functions(),
580            &open_scope(),
581        )
582        .unwrap();
583        assert_eq!(aggs.len(), 1);
584        assert!(aggs[0].distinct);
585    }
586}