use sqlparser::ast::{Expr, FunctionArguments, SelectItem};
#[derive(Debug, Clone, PartialEq)]
pub struct AggregateExpr {
pub func: AggFunc,
pub input: String,
pub alias: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AggFunc {
Count,
Sum,
Avg,
Min,
Max,
CountDistinct,
}
pub fn extract_aggregates(select_items: &[SelectItem]) -> Vec<AggregateExpr> {
select_items
.iter()
.filter_map(extract_agg_from_item)
.collect()
}
pub fn is_aggregate_expr(expr: &Expr) -> bool {
expr_is_aggregate(expr)
}
pub(crate) fn projection_item_is_aggregate(item: &SelectItem) -> bool {
match item {
SelectItem::UnnamedExpr(e)
| SelectItem::ExprWithAlias { expr: e, .. }
| SelectItem::ExprWithAliases { expr: e, .. } => expr_is_aggregate(e),
_ => false,
}
}
pub(crate) fn expr_is_aggregate(expr: &Expr) -> bool {
match expr {
Expr::Function(f) => {
let name = f.name.to_string().to_ascii_uppercase();
matches!(
name.as_str(),
"COUNT"
| "SUM"
| "AVG"
| "MIN"
| "MAX"
| "STDDEV"
| "VARIANCE"
| "ARRAY_AGG"
| "STRING_AGG"
)
}
Expr::BinaryOp { left, right, .. } => expr_is_aggregate(left) || expr_is_aggregate(right),
Expr::Nested(inner) => expr_is_aggregate(inner),
_ => false,
}
}
fn extract_agg_from_item(item: &SelectItem) -> Option<AggregateExpr> {
match item {
SelectItem::UnnamedExpr(expr) => {
let agg = extract_agg_from_expr(expr)?;
Some(AggregateExpr {
alias: agg_synthetic_alias(&agg.func, &agg.input),
func: agg.func,
input: agg.input,
})
}
SelectItem::ExprWithAlias { expr, alias } => {
let agg = extract_agg_from_expr(expr)?;
Some(AggregateExpr {
func: agg.func,
input: agg.input,
alias: alias.value.clone(),
})
}
SelectItem::ExprWithAliases { expr, aliases } => {
let agg = extract_agg_from_expr(expr)?;
let alias = aliases
.first()
.map(|a| a.value.clone())
.unwrap_or_else(|| agg_synthetic_alias(&agg.func, &agg.input));
Some(AggregateExpr {
func: agg.func,
input: agg.input,
alias,
})
}
_ => None,
}
}
struct PartialAgg {
func: AggFunc,
input: String,
}
fn extract_agg_from_expr(expr: &Expr) -> Option<PartialAgg> {
if let Expr::Function(f) = expr {
let name = f.name.to_string().to_ascii_uppercase();
let func = match name.as_str() {
"COUNT" => {
if is_distinct_args(&f.args) {
AggFunc::CountDistinct
} else {
AggFunc::Count
}
}
"SUM" => AggFunc::Sum,
"AVG" => AggFunc::Avg,
"MIN" => AggFunc::Min,
"MAX" => AggFunc::Max,
_ => return None,
};
let input = extract_first_arg_string(&f.args);
Some(PartialAgg { func, input })
} else {
None
}
}
fn is_distinct_args(args: &FunctionArguments) -> bool {
match args {
FunctionArguments::List(list) => list.duplicate_treatment.is_some(),
_ => false,
}
}
fn extract_first_arg_string(args: &FunctionArguments) -> String {
match args {
FunctionArguments::List(list) => {
if let Some(first) = list.args.first() {
first.to_string()
} else {
"*".to_string()
}
}
FunctionArguments::None => "*".to_string(),
FunctionArguments::Subquery(q) => q.to_string(),
}
}
fn agg_synthetic_alias(func: &AggFunc, input: &str) -> String {
let fn_part = match func {
AggFunc::Count | AggFunc::CountDistinct => "count",
AggFunc::Sum => "sum",
AggFunc::Avg => "avg",
AggFunc::Min => "min",
AggFunc::Max => "max",
};
let col_part = if input == "*" {
"star".to_string()
} else {
input.replace(|c: char| !c.is_alphanumeric(), "_")
};
format!("{fn_part}_{col_part}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse;
fn select_items(sql: &str) -> Vec<SelectItem> {
let stmts = parse(sql).expect("parse");
let stmt = stmts.into_iter().next().expect("one stmt");
if let sqlparser::ast::Statement::Query(q) = stmt {
if let sqlparser::ast::SetExpr::Select(sel) = *q.body {
return sel.projection;
}
}
vec![]
}
#[test]
fn test_extract_count_star() {
let items = select_items("SELECT COUNT(*) FROM t");
let aggs = extract_aggregates(&items);
assert_eq!(aggs.len(), 1);
assert_eq!(aggs[0].func, AggFunc::Count);
assert_eq!(aggs[0].input, "*");
}
#[test]
fn test_extract_sum() {
let items = select_items("SELECT SUM(amount) AS total FROM orders");
let aggs = extract_aggregates(&items);
assert_eq!(aggs.len(), 1);
assert_eq!(aggs[0].func, AggFunc::Sum);
assert_eq!(aggs[0].alias, "total");
}
#[test]
fn test_extract_count_distinct() {
let items = select_items("SELECT COUNT(DISTINCT user_id) FROM events");
let aggs = extract_aggregates(&items);
assert_eq!(aggs.len(), 1, "expected one aggregate, got {:?}", aggs);
assert_eq!(aggs[0].func, AggFunc::CountDistinct);
}
#[test]
fn test_is_aggregate_true() {
let items = select_items("SELECT COUNT(*) FROM t");
let item = items.into_iter().next().expect("item");
if let SelectItem::UnnamedExpr(expr) = item {
assert!(is_aggregate_expr(&expr));
} else {
panic!("expected UnnamedExpr");
}
}
#[test]
fn test_is_aggregate_false() {
let items = select_items("SELECT id FROM t");
let item = items.into_iter().next().expect("item");
if let SelectItem::UnnamedExpr(expr) = item {
assert!(!is_aggregate_expr(&expr));
} else {
panic!("expected UnnamedExpr");
}
}
#[test]
fn test_having_produces_filter_over_aggregate() {
use crate::{parse_one, plan_statement};
let stmt = parse_one(
"SELECT dept, AVG(salary) FROM employees GROUP BY dept HAVING AVG(salary) > 50000",
)
.expect("parse");
let plan = plan_statement(&stmt).expect("plan");
match &plan {
crate::LogicalPlan::Filter { input, predicate } => {
assert!(
predicate.contains("50000") || !predicate.is_empty(),
"HAVING predicate should be non-empty: {predicate}"
);
assert!(
matches!(input.as_ref(), crate::LogicalPlan::Aggregate { .. }),
"Filter should wrap Aggregate, got: {input:?}"
);
}
other => panic!("expected Filter over Aggregate, got: {other:?}"),
}
}
}