nodedb_sql/planner/agg_naming.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! Canonical output names for aggregate expressions.
4//!
5//! One aggregate has two names: the *canonical* key it is computed and stored
6//! under in a finalized group row (`sum(amount)`), and the *user alias* the
7//! client sees (`SELECT SUM(amount) AS total`). The rename to the user alias
8//! happens last, after HAVING has filtered, so anything that has to address an
9//! aggregate mid-pipeline must use the canonical key. Deriving that key in one
10//! place keeps the planner's HAVING rewrite and the physical spec builder from
11//! drifting into two different names for the same column.
12
13use crate::types::{AggregateExpr, SqlExpr};
14
15/// The accumulator name for an aggregate, accounting for `DISTINCT`.
16pub fn aggregate_function_name(a: &AggregateExpr) -> String {
17 if !a.distinct {
18 return a.function.clone();
19 }
20 match a.function.as_str() {
21 "count" => "count_distinct".into(),
22 "array_agg" => "array_agg_distinct".into(),
23 // SUM(DISTINCT col) / AVG(DISTINCT col) route to a dedicated
24 // accumulator that dedupes input values before summing. The plain
25 // "sum"/"avg" accumulator does not dedupe, so without this remap
26 // `DISTINCT` would be silently ignored.
27 "sum" => "sum_distinct".into(),
28 "avg" => "avg_distinct".into(),
29 // MIN(DISTINCT) and MAX(DISTINCT) yield the same result as their
30 // non-distinct counterparts (the smallest / largest value is the same
31 // whether or not duplicates are deduped), so we accept the DISTINCT
32 // modifier but route to the regular accumulator.
33 _ => a.function.clone(),
34 }
35}
36
37/// The field name an aggregate accumulates over: the bare column when the
38/// argument is one, and `*` for a wildcard or a computed argument (which the
39/// accumulator receives as an expression rather than a field).
40pub fn aggregate_field_name(a: &AggregateExpr) -> String {
41 a.args
42 .first()
43 .map(|arg| match arg {
44 SqlExpr::Column { name, .. } => name.clone(),
45 _ => "*".to_string(),
46 })
47 .unwrap_or_else(|| "*".to_string())
48}
49
50/// The canonical output-column key an aggregate's value is stored under in a
51/// finalized group row.
52///
53/// A computed argument is named by its source text and a `FILTER` clause is
54/// appended, so `SUM(a * 2)`, `SUM(b * 3)` and `COUNT(*) FILTER (WHERE ...)`
55/// each land under their own key.
56pub fn aggregate_output_key(a: &AggregateExpr) -> String {
57 let arg = a
58 .arg_text
59 .clone()
60 .unwrap_or_else(|| aggregate_field_name(a));
61 let base = nodedb_query::agg_key::canonical_agg_key(&aggregate_function_name(a), &arg);
62 match &a.filter_text {
63 None => base,
64 Some(text) => format!("{base} filter (where {text})"),
65 }
66}
67
68/// The internal name of a computed (non-column) GROUP BY key: a stable
69/// `group_{index}` placeholder. The executor emits the evaluated key under
70/// it, and the response shaper reads the value back by it. The SELECT alias
71/// never reaches this name; it is a display name only.
72pub fn computed_group_key_name(index: usize) -> String {
73 format!("group_{index}")
74}
75
76/// The key a finalized group row carries one GROUP BY key under. A column
77/// key keeps its bare column name; a computed key uses
78/// [`computed_group_key_name`].
79pub fn group_key_row_name(key: &SqlExpr, index: usize) -> String {
80 match key {
81 SqlExpr::Column { name, .. } => name.clone(),
82 SqlExpr::Function { .. }
83 | SqlExpr::BinaryOp { .. }
84 | SqlExpr::UnaryOp { .. }
85 | SqlExpr::Cast { .. }
86 | SqlExpr::IsNull { .. }
87 | SqlExpr::Case { .. }
88 | SqlExpr::InList { .. }
89 | SqlExpr::Between { .. }
90 | SqlExpr::Like { .. }
91 | SqlExpr::ArrayLiteral(_)
92 | SqlExpr::Literal(_)
93 | SqlExpr::Subquery(_)
94 | SqlExpr::Wildcard => computed_group_key_name(index),
95 }
96}