Skip to main content

nodedb_sql/planner/
group_by.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! GROUP BY key conversion and output naming.
4//!
5//! Two concerns live here: turning a parsed GROUP BY clause into grouping key
6//! expressions (including resolving a key written as a SELECT-list output
7//! alias), and deriving the output column name each key is emitted under.
8
9use sqlparser::ast::{self, GroupByExpr};
10
11use crate::error::Result;
12use crate::parser::normalize::normalize_ident;
13use crate::resolver::ColumnScope;
14use crate::resolver::columns::TableScope;
15use crate::resolver::expr::convert_expr;
16use crate::types::{ColumnInfo, SqlExpr};
17
18/// Convert GROUP BY clause to SqlExpr list.
19pub fn convert_group_by(group_by: &GroupByExpr, scope: &TableScope) -> Result<Vec<SqlExpr>> {
20    convert_group_by_with_projection(group_by, &[], &[], scope)
21}
22
23/// Convert a GROUP BY clause, resolving SELECT-list output aliases.
24///
25/// `GROUP BY k` may name an output column declared in the SELECT list
26/// (`SELECT 10 / denom AS k ... GROUP BY k`) — PostgreSQL resolves it to that
27/// expression. Left unresolved, the key builder looks for a stored field named
28/// `k`, finds none in any row, and buckets every row under one null key: the
29/// query reports a single global total where the client asked for one row per
30/// computed group.
31///
32/// PostgreSQL's precedence applies: a name that matches an actual input column
33/// refers to that column, not to the output alias. `table_columns` carries the
34/// declared columns when the collection has a schema; a schemaless collection
35/// passes an empty slice, where any alias match is the only interpretation
36/// available.
37pub fn convert_group_by_with_projection(
38    group_by: &GroupByExpr,
39    projection: &[ast::SelectItem],
40    table_columns: &[ColumnInfo],
41    scope: &TableScope,
42) -> Result<Vec<SqlExpr>> {
43    // A key written as an output alias is substituted for its SELECT-list
44    // expression above, so only the remaining names need the alias widening.
45    // `SELECT col AS col` names the column itself, so that alias is left out:
46    // the key resolves to the column and, over a join, to its owning side.
47    let key_scope = scope.with_output_names(
48        crate::planner::select::select_output_aliases(projection)
49            .into_iter()
50            .filter(|alias| !is_self_alias(projection, alias)),
51    );
52    let key_scope = ColumnScope::Relations(&key_scope);
53    match group_by {
54        GroupByExpr::All(_) => Ok(Vec::new()),
55        GroupByExpr::Expressions(exprs, _) => exprs
56            .iter()
57            .map(|e| {
58                reject_ordinal_out_of_range(e, projection)?;
59                match resolve_output_alias(e, projection, table_columns) {
60                    Some(aliased) => convert_expr(aliased, &key_scope),
61                    None => convert_expr(e, &key_scope),
62                }
63            })
64            .collect(),
65    }
66}
67
68/// Whether `alias` is the output name of a SELECT item `col AS col` that
69/// projects the column of the same name, bare or qualified.
70fn is_self_alias(projection: &[ast::SelectItem], alias: &str) -> bool {
71    projection.iter().any(|item| match item {
72        ast::SelectItem::ExprWithAlias { expr, alias: name } => {
73            normalize_ident(name) == alias && expr_column_name(expr).is_some_and(|c| c == alias)
74        }
75        ast::SelectItem::UnnamedExpr(_)
76        | ast::SelectItem::ExprWithAliases { .. }
77        | ast::SelectItem::QualifiedWildcard(..)
78        | ast::SelectItem::Wildcard(_) => false,
79    })
80}
81
82/// Refuse `GROUP BY n` when the SELECT list has no `n`-th expression, as
83/// PostgreSQL does. An integer key is never a constant group.
84///
85/// A projection-less call (`convert_group_by`) has no SELECT list to check
86/// against and leaves the key as written.
87fn reject_ordinal_out_of_range(expr: &ast::Expr, projection: &[ast::SelectItem]) -> Result<()> {
88    if projection.is_empty() {
89        return Ok(());
90    }
91    let ast::Expr::Value(v) = expr else {
92        return Ok(());
93    };
94    let ast::Value::Number(n, _) = &v.value else {
95        return Ok(());
96    };
97    let in_range = n
98        .parse::<usize>()
99        .is_ok_and(|position| position >= 1 && position <= projection.len());
100    if in_range {
101        return Ok(());
102    }
103    Err(crate::error::SqlError::Unsupported {
104        detail: format!("GROUP BY position {n} is not in select list"),
105    })
106}
107
108/// The SELECT-list expression a bare GROUP BY identifier refers to, if the
109/// identifier names an output alias rather than an input column.
110fn resolve_output_alias<'a>(
111    expr: &ast::Expr,
112    projection: &'a [ast::SelectItem],
113    table_columns: &[ColumnInfo],
114) -> Option<&'a ast::Expr> {
115    // `GROUP BY 2` names the second SELECT-list expression.
116    if let ast::Expr::Value(v) = expr
117        && let ast::Value::Number(n, _) = &v.value
118        && let Ok(position) = n.parse::<usize>()
119        && position >= 1
120    {
121        return match projection.get(position - 1) {
122            Some(
123                ast::SelectItem::UnnamedExpr(e) | ast::SelectItem::ExprWithAlias { expr: e, .. },
124            ) => Some(e),
125            Some(_) | None => None,
126        };
127    }
128    let ast::Expr::Identifier(ident) = expr else {
129        return None;
130    };
131    let needle = normalize_ident(ident);
132
133    // An input column of the same name wins over the output alias.
134    if table_columns.iter().any(|c| c.name == needle) {
135        return None;
136    }
137
138    projection.iter().find_map(|item| match item {
139        ast::SelectItem::ExprWithAlias { expr, alias }
140            if normalize_ident(alias) == needle
141                // `SELECT col AS col` resolves to itself; leaving it alone
142                // keeps the key a plain column reference.
143                && !matches!(expr, ast::Expr::Identifier(i) if normalize_ident(i) == needle) =>
144        {
145            Some(expr)
146        }
147        _ => None,
148    })
149}
150
151/// Derive the SELECT-list output alias for each GROUP BY key by correlating
152/// each key with the projection item that references the same column. Returns
153/// a `Vec` parallel to `group_by`: `Some(alias)` when a projection item
154/// explicitly aliases that grouped column (`SELECT k AS label ... GROUP BY k`),
155/// otherwise `None` (the output column name falls back to the raw grouped
156/// column name).
157pub fn group_by_output_aliases(
158    projection: &[ast::SelectItem],
159    group_by: &[SqlExpr],
160) -> Vec<Option<String>> {
161    group_by
162        .iter()
163        .map(|key| {
164            key_column_name(key).and_then(|name| projection_alias_for_column(projection, name))
165        })
166        .collect()
167}
168
169/// The bare column name of a GROUP BY key, when the key is a column reference.
170pub fn key_column_name(key: &SqlExpr) -> Option<&str> {
171    match key {
172        SqlExpr::Column { name, .. } => Some(name.as_str()),
173        _ => None,
174    }
175}
176
177/// The explicit alias of the projection item that references bare column
178/// `col`, if any. Only `expr AS alias` items where `expr` is that column
179/// (bare or qualified) qualify; unaliased items yield `None`.
180fn projection_alias_for_column(projection: &[ast::SelectItem], col: &str) -> Option<String> {
181    for item in projection {
182        if let ast::SelectItem::ExprWithAlias { expr, alias } = item
183            && expr_column_name(expr).is_some_and(|n| n == col)
184        {
185            return Some(normalize_ident(alias));
186        }
187    }
188    None
189}
190
191/// The bare column name referenced by an `ast::Expr`, when it is a simple or
192/// compound identifier; `None` for any other expression shape.
193pub fn expr_column_name(expr: &ast::Expr) -> Option<String> {
194    match expr {
195        ast::Expr::Identifier(ident) => Some(normalize_ident(ident)),
196        ast::Expr::CompoundIdentifier(parts) => parts.last().map(normalize_ident),
197        _ => None,
198    }
199}