Skip to main content

nodedb_sql/planner/
aggregate.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! GROUP BY and aggregate planning.
4
5use sqlparser::ast::{self, GroupByExpr};
6
7use crate::engine_rules::{self, AggregateParams};
8use crate::error::Result;
9use crate::functions::registry::{FunctionRegistry, SearchTrigger};
10use crate::parser::normalize::normalize_ident;
11use crate::planner::grouping_sets::expand_group_by;
12use crate::resolver::columns::ResolvedTable;
13use crate::resolver::expr::convert_expr;
14use crate::temporal::TemporalScope;
15use crate::types::*;
16
17/// Plan an aggregate query (GROUP BY + aggregate functions).
18pub fn plan_aggregate(
19    select: &ast::Select,
20    table: &ResolvedTable,
21    filters: &[Filter],
22    _scope: &crate::resolver::columns::TableScope,
23    functions: &FunctionRegistry,
24    temporal: &TemporalScope,
25) -> Result<SqlPlan> {
26    // Detect ROLLUP / CUBE / GROUPING SETS before falling through to plain convert.
27    let grouping_expansion = expand_group_by(&select.group_by)?;
28
29    let (group_by_exprs, grouping_sets) = if let Some(exp) = grouping_expansion {
30        (exp.canonical_keys, Some(exp.grouping_sets))
31    } else {
32        (convert_group_by(&select.group_by)?, None)
33    };
34
35    let mut aggregates = extract_aggregates_from_projection(&select.projection, functions)?;
36    let having = match &select.having {
37        Some(expr) => super::select::convert_where_to_filters(expr)?,
38        None => Vec::new(),
39    };
40
41    // When grouping sets are present, detect GROUPING(col) in the projection and
42    // synthesize AggregateExpr entries so the executor can compute them per-set.
43    if grouping_sets.is_some() {
44        let grouping_aggs = extract_grouping_calls(&select.projection, &group_by_exprs)?;
45        aggregates.extend(grouping_aggs);
46    }
47
48    // Records the SELECT-list interleaving of GROUP BY keys and aggregates so
49    // output columns follow the user's SELECT order. Computed after the full
50    // aggregate list (including any GROUPING pseudo-aggregates) is assembled so
51    // grouping slots index correctly.
52    let output_order = super::aggregate_order::compute_output_order(
53        &select.projection,
54        &group_by_exprs,
55        functions,
56    )?;
57
58    // Extract timeseries-specific params (bucket interval, group columns) if applicable.
59    let (bucket_interval_ms, group_columns) =
60        extract_timeseries_params(&select.group_by, &select.projection, functions)?;
61
62    let rules = engine_rules::resolve_engine_rules(table.info.engine);
63    let mut base_plan = rules.plan_aggregate(AggregateParams {
64        collection: table.name.clone(),
65        alias: table.alias.clone(),
66        filters: filters.to_vec(),
67        group_by: group_by_exprs.clone(),
68        aggregates: aggregates.clone(),
69        having: having.clone(),
70        limit: 10000,
71        bucket_interval_ms,
72        group_columns,
73        has_auto_tier: table.info.has_auto_tier,
74        bitemporal: table.info.bitemporal,
75        temporal: *temporal,
76    })?;
77
78    // The engine rules build the Aggregate node without a projection in scope,
79    // so the SELECT-list output aliases for the GROUP BY keys are attached here
80    // (Postgres: `SELECT k AS label ... GROUP BY k` yields output column
81    // `label`, not `k`).
82    let group_by_aliases = group_by_output_aliases(&select.projection, &group_by_exprs);
83    if let SqlPlan::Aggregate {
84        group_by_aliases: alias_slot,
85        output_order: order_slot,
86        ..
87    } = &mut base_plan
88    {
89        *alias_slot = group_by_aliases.clone();
90        *order_slot = output_order.clone();
91    }
92
93    // Wrap the plan to attach grouping sets if present.
94    if let Some(sets) = grouping_sets {
95        return Ok(attach_grouping_sets(
96            base_plan,
97            group_by_exprs,
98            group_by_aliases,
99            output_order,
100            aggregates,
101            having,
102            sets,
103        ));
104    }
105
106    Ok(base_plan)
107}
108
109/// Derive the SELECT-list output alias for each GROUP BY key by correlating
110/// each key with the projection item that references the same column. Returns
111/// a `Vec` parallel to `group_by`: `Some(alias)` when a projection item
112/// explicitly aliases that grouped column (`SELECT k AS label ... GROUP BY k`),
113/// otherwise `None` (the output column name falls back to the raw grouped
114/// column name).
115pub fn group_by_output_aliases(
116    projection: &[ast::SelectItem],
117    group_by: &[SqlExpr],
118) -> Vec<Option<String>> {
119    group_by
120        .iter()
121        .map(|key| {
122            key_column_name(key).and_then(|name| projection_alias_for_column(projection, name))
123        })
124        .collect()
125}
126
127/// The bare column name of a GROUP BY key, when the key is a column reference.
128pub(super) fn key_column_name(key: &SqlExpr) -> Option<&str> {
129    match key {
130        SqlExpr::Column { name, .. } => Some(name.as_str()),
131        _ => None,
132    }
133}
134
135/// The explicit alias of the projection item that references bare column
136/// `col`, if any. Only `expr AS alias` items where `expr` is that column
137/// (bare or qualified) qualify; unaliased items yield `None`.
138fn projection_alias_for_column(projection: &[ast::SelectItem], col: &str) -> Option<String> {
139    for item in projection {
140        if let ast::SelectItem::ExprWithAlias { expr, alias } = item
141            && expr_column_name(expr).is_some_and(|n| n == col)
142        {
143            return Some(normalize_ident(alias));
144        }
145    }
146    None
147}
148
149/// The bare column name referenced by an `ast::Expr`, when it is a simple or
150/// compound identifier; `None` for any other expression shape.
151pub(super) fn expr_column_name(expr: &ast::Expr) -> Option<String> {
152    match expr {
153        ast::Expr::Identifier(ident) => Some(normalize_ident(ident)),
154        ast::Expr::CompoundIdentifier(parts) => parts.last().map(normalize_ident),
155        _ => None,
156    }
157}
158
159/// Attach `grouping_sets` to an existing `SqlPlan::Aggregate` node, or wrap it
160/// in a new one when the engine rules returned a non-Aggregate plan.
161fn attach_grouping_sets(
162    base_plan: SqlPlan,
163    group_by: Vec<SqlExpr>,
164    group_by_aliases: Vec<Option<String>>,
165    output_order: Vec<AggOutputSlot>,
166    aggregates: Vec<AggregateExpr>,
167    having: Vec<Filter>,
168    grouping_sets: Vec<Vec<usize>>,
169) -> SqlPlan {
170    match base_plan {
171        SqlPlan::Aggregate {
172            input,
173            limit,
174            grouping_sets: _,
175            sort_keys,
176            ..
177        } => SqlPlan::Aggregate {
178            input,
179            group_by,
180            group_by_aliases,
181            output_order,
182            aggregates,
183            having,
184            limit,
185            grouping_sets: Some(grouping_sets),
186            sort_keys,
187        },
188        other => {
189            // Engine returned something other than Aggregate (e.g. TimeseriesIngest).
190            // Wrap it so grouping sets are not silently dropped.
191            SqlPlan::Aggregate {
192                input: Box::new(other),
193                group_by,
194                group_by_aliases,
195                output_order,
196                aggregates,
197                having,
198                limit: 10000,
199                grouping_sets: Some(grouping_sets),
200                sort_keys: Vec::new(),
201            }
202        }
203    }
204}
205
206/// Extract timeseries-specific parameters from GROUP BY (bucket interval, group columns).
207///
208/// Returns `(Some(interval_ms), group_columns)` if a `time_bucket()` call is found,
209/// or `(None, empty)` otherwise. Non-timeseries engines ignore these values.
210fn extract_timeseries_params(
211    raw_group_by: &GroupByExpr,
212    select_items: &[ast::SelectItem],
213    functions: &FunctionRegistry,
214) -> Result<(Option<i64>, Vec<String>)> {
215    let mut bucket_interval_ms: Option<i64> = None;
216    let mut group_columns = Vec::new();
217
218    if let GroupByExpr::Expressions(exprs, _) = raw_group_by {
219        for expr in exprs {
220            let resolved = resolve_group_by_expr(expr, select_items);
221            let check_expr = resolved.unwrap_or(expr);
222
223            if let Some(interval) = try_extract_time_bucket(check_expr, functions)? {
224                bucket_interval_ms = Some(interval);
225                continue;
226            }
227
228            if let ast::Expr::Identifier(ident) = expr {
229                group_columns.push(normalize_ident(ident));
230            }
231        }
232    }
233
234    Ok((bucket_interval_ms, group_columns))
235}
236
237/// Check if an expression is a time_bucket() call and extract the interval.
238fn try_extract_time_bucket(expr: &ast::Expr, functions: &FunctionRegistry) -> Result<Option<i64>> {
239    if let ast::Expr::Function(func) = expr {
240        let name = func
241            .name
242            .0
243            .iter()
244            .map(|p| match p {
245                ast::ObjectNamePart::Identifier(ident) => normalize_ident(ident),
246                _ => String::new(),
247            })
248            .collect::<Vec<_>>()
249            .join(".");
250        if functions.search_trigger(&name) == SearchTrigger::TimeBucket {
251            return Ok(Some(extract_bucket_interval(func)?));
252        }
253    }
254    Ok(None)
255}
256
257/// Resolve a GROUP BY expression that references a SELECT alias or ordinal.
258///
259/// `GROUP BY b` where `b` is an alias → returns the aliased expression.
260/// `GROUP BY 1` → returns the 1st SELECT expression (0-indexed).
261fn resolve_group_by_expr<'a>(
262    expr: &ast::Expr,
263    select_items: &'a [ast::SelectItem],
264) -> Option<&'a ast::Expr> {
265    match expr {
266        ast::Expr::Identifier(ident) => {
267            let alias_name = normalize_ident(ident);
268            select_items.iter().find_map(|item| {
269                if let ast::SelectItem::ExprWithAlias { expr, alias } = item
270                    && normalize_ident(alias) == alias_name
271                {
272                    Some(expr)
273                } else {
274                    None
275                }
276            })
277        }
278        ast::Expr::Value(v) => {
279            if let ast::Value::Number(n, _) = &v.value
280                && let Ok(idx) = n.parse::<usize>()
281                && idx >= 1
282                && idx <= select_items.len()
283            {
284                match &select_items[idx - 1] {
285                    ast::SelectItem::UnnamedExpr(e) => Some(e),
286                    ast::SelectItem::ExprWithAlias { expr, .. } => Some(expr),
287                    _ => None,
288                }
289            } else {
290                None
291            }
292        }
293        _ => None,
294    }
295}
296
297/// Extract the bucket interval from a time_bucket() call.
298///
299/// Handles both argument orders:
300/// - `time_bucket('1 hour', timestamp)` — interval first
301/// - `time_bucket(timestamp, '1 hour')` — timestamp first
302/// - `time_bucket(3600, timestamp)` — integer seconds
303fn extract_bucket_interval(func: &ast::Function) -> Result<i64> {
304    let args = match &func.args {
305        ast::FunctionArguments::List(args) => &args.args,
306        _ => return Ok(0),
307    };
308    // Try each argument position for the interval literal.
309    for arg in args {
310        if let ast::FunctionArg::Unnamed(ast::FunctionArgExpr::Expr(ast::Expr::Value(v))) = arg {
311            match &v.value {
312                ast::Value::SingleQuotedString(s) => {
313                    let ms = parse_interval_to_ms(s);
314                    if ms > 0 {
315                        return Ok(ms);
316                    }
317                }
318                ast::Value::Number(n, _) => {
319                    if let Ok(secs) = n.parse::<i64>()
320                        && secs > 0
321                    {
322                        return Ok(secs * 1000);
323                    }
324                }
325                _ => {}
326            }
327        }
328    }
329    Ok(0)
330}
331
332/// Parse an interval string to milliseconds.
333///
334/// Delegates to the canonical `nodedb_types::kv_parsing::parse_interval_to_ms`.
335fn parse_interval_to_ms(s: &str) -> i64 {
336    nodedb_types::kv_parsing::parse_interval_to_ms(s)
337        .map(|ms| ms as i64)
338        .unwrap_or(0)
339}
340
341/// Convert GROUP BY clause to SqlExpr list.
342pub fn convert_group_by(group_by: &GroupByExpr) -> Result<Vec<SqlExpr>> {
343    match group_by {
344        GroupByExpr::All(_) => Ok(Vec::new()),
345        GroupByExpr::Expressions(exprs, _) => exprs.iter().map(convert_expr).collect(),
346    }
347}
348
349/// Scan the SELECT projection for `GROUPING(col)` calls and return synthetic
350/// `AggregateExpr` entries so the executor can compute the per-set bitmask value.
351///
352/// For `GROUPING(col)`, the canonical index of `col` in `canonical_keys` is
353/// encoded into the `field` of the resulting `AggregateExpr` (as a decimal string).
354/// The executor reads this index and checks the grouping-set bitmask at run time.
355fn extract_grouping_calls(
356    items: &[ast::SelectItem],
357    canonical_keys: &[SqlExpr],
358) -> Result<Vec<AggregateExpr>> {
359    let mut out = Vec::new();
360    for item in items {
361        let (expr, alias): (&ast::Expr, String) = match item {
362            ast::SelectItem::UnnamedExpr(expr) => (expr, format!("{expr}")),
363            ast::SelectItem::ExprWithAlias { expr, alias } => (expr, normalize_ident(alias)),
364            _ => continue,
365        };
366        collect_grouping_from_expr(expr, &alias, canonical_keys, &mut out)?;
367    }
368    Ok(out)
369}
370
371/// Recursively collect `GROUPING(col)` calls from an expression.
372fn collect_grouping_from_expr(
373    expr: &ast::Expr,
374    alias: &str,
375    canonical_keys: &[SqlExpr],
376    out: &mut Vec<AggregateExpr>,
377) -> Result<()> {
378    match expr {
379        ast::Expr::Function(f) => {
380            let name = normalize_function_name(f);
381            if name.eq_ignore_ascii_case("grouping") {
382                // Extract the column argument(s).
383                let args = function_args_exprs(f);
384                for col_expr in &args {
385                    let canonical_idx = crate::planner::grouping_sets::resolve_grouping_col(
386                        col_expr,
387                        canonical_keys,
388                    )?;
389                    // Encode index in the field name; alias is user-visible output name.
390                    out.push(AggregateExpr {
391                        function: "grouping".into(),
392                        args: vec![convert_expr(col_expr)?],
393                        alias: alias.to_string(),
394                        distinct: false,
395                        grouping_col_index: Some(canonical_idx),
396                    });
397                }
398            }
399        }
400        // Recurse into binary ops and other wrappers.
401        ast::Expr::BinaryOp { left, right, .. } => {
402            collect_grouping_from_expr(left, alias, canonical_keys, out)?;
403            collect_grouping_from_expr(right, alias, canonical_keys, out)?;
404        }
405        _ => {}
406    }
407    Ok(())
408}
409
410/// Extract the positional expression arguments from a function call.
411pub(super) fn function_args_exprs(f: &ast::Function) -> Vec<&ast::Expr> {
412    match &f.args {
413        ast::FunctionArguments::List(list) => list
414            .args
415            .iter()
416            .filter_map(|a| match a {
417                ast::FunctionArg::Unnamed(ast::FunctionArgExpr::Expr(e)) => Some(e),
418                _ => None,
419            })
420            .collect(),
421        _ => Vec::new(),
422    }
423}
424
425/// Return the simple lowercase name of a function (unqualified part only).
426pub(super) fn normalize_function_name(f: &ast::Function) -> String {
427    f.name
428        .0
429        .last()
430        .map(|p| match p {
431            ast::ObjectNamePart::Identifier(ident) => normalize_ident(ident),
432            _ => String::new(),
433        })
434        .unwrap_or_default()
435}
436
437/// Extract aggregate expressions from SELECT projection.
438pub fn extract_aggregates_from_projection(
439    items: &[ast::SelectItem],
440    functions: &FunctionRegistry,
441) -> Result<Vec<AggregateExpr>> {
442    let mut aggregates = Vec::new();
443    for item in items {
444        let (expr, alias): (&ast::Expr, String) = match item {
445            // Lowercase the unparsed expression text so the resulting
446            // alias (which becomes the JSON row key after the
447            // `apply_user_aliases_to_rows` rename) matches the pgwire
448            // lookup key built by `expr_column_names`, which also
449            // lowercases. Without this match the row description
450            // column `count(distinct user_id)` would not resolve to
451            // the JSON value stored under `COUNT(DISTINCT user_id)`,
452            // and the client would see NULL.
453            ast::SelectItem::UnnamedExpr(expr) => (expr, format!("{expr}").to_lowercase()),
454            ast::SelectItem::ExprWithAlias { expr, alias } => (expr, normalize_ident(alias)),
455            _ => continue,
456        };
457        let mut extracted = crate::aggregate_walk::extract_aggregates(expr, &alias, functions)?;
458        aggregates.append(&mut extracted);
459    }
460    Ok(aggregates)
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn parse_intervals() {
469        assert_eq!(parse_interval_to_ms("1h"), 3_600_000);
470        assert_eq!(parse_interval_to_ms("15m"), 900_000);
471        assert_eq!(parse_interval_to_ms("30s"), 30_000);
472        assert_eq!(parse_interval_to_ms("7d"), 604_800_000);
473        // Word-form intervals.
474        assert_eq!(parse_interval_to_ms("1 hour"), 3_600_000);
475        assert_eq!(parse_interval_to_ms("2 hours"), 7_200_000);
476        assert_eq!(parse_interval_to_ms("15 minutes"), 900_000);
477        assert_eq!(parse_interval_to_ms("30 seconds"), 30_000);
478        assert_eq!(parse_interval_to_ms("1 day"), 86_400_000);
479        assert_eq!(parse_interval_to_ms("5 min"), 300_000);
480    }
481
482    /// Helper: parse a SQL SELECT and return the select body + projection.
483    fn parse_select(sql: &str) -> ast::Select {
484        use sqlparser::dialect::GenericDialect;
485        use sqlparser::parser::Parser;
486        let stmts = Parser::parse_sql(&GenericDialect {}, sql).unwrap();
487        match stmts.into_iter().next().unwrap() {
488            ast::Statement::Query(q) => match *q.body {
489                ast::SetExpr::Select(s) => *s,
490                _ => panic!("expected SELECT"),
491            },
492            _ => panic!("expected query"),
493        }
494    }
495
496    #[test]
497    fn resolve_group_by_alias_to_time_bucket() {
498        let select = parse_select(
499            "SELECT time_bucket('1 hour', timestamp) AS b, COUNT(*) FROM t GROUP BY b",
500        );
501        let functions = FunctionRegistry::new();
502
503        if let GroupByExpr::Expressions(exprs, _) = &select.group_by {
504            let resolved = resolve_group_by_expr(&exprs[0], &select.projection);
505            assert!(resolved.is_some(), "alias 'b' should resolve");
506            let interval = try_extract_time_bucket(resolved.unwrap(), &functions).unwrap();
507            assert_eq!(interval, Some(3_600_000));
508        } else {
509            panic!("expected GROUP BY expressions");
510        }
511    }
512
513    #[test]
514    fn resolve_group_by_ordinal_to_time_bucket() {
515        let select =
516            parse_select("SELECT time_bucket('5 minutes', timestamp), COUNT(*) FROM t GROUP BY 1");
517        let functions = FunctionRegistry::new();
518
519        if let GroupByExpr::Expressions(exprs, _) = &select.group_by {
520            let resolved = resolve_group_by_expr(&exprs[0], &select.projection);
521            assert!(resolved.is_some(), "ordinal 1 should resolve");
522            let interval = try_extract_time_bucket(resolved.unwrap(), &functions).unwrap();
523            assert_eq!(interval, Some(300_000));
524        } else {
525            panic!("expected GROUP BY expressions");
526        }
527    }
528
529    #[test]
530    fn resolve_group_by_plain_column_not_time_bucket() {
531        let select = parse_select("SELECT qtype, COUNT(*) FROM t GROUP BY qtype");
532        let functions = FunctionRegistry::new();
533
534        if let GroupByExpr::Expressions(exprs, _) = &select.group_by {
535            let resolved = resolve_group_by_expr(&exprs[0], &select.projection);
536            // 'qtype' is not an alias in SELECT, so resolve returns None.
537            assert!(resolved.is_none());
538            let interval = try_extract_time_bucket(&exprs[0], &functions).unwrap();
539            assert_eq!(interval, None);
540        } else {
541            panic!("expected GROUP BY expressions");
542        }
543    }
544}