use std::collections::HashMap;
use vibesql_ast::Expression;
use vibesql_types::SqlValue;
use crate::{schema::CombinedSchema, select::grouping::AggregateAccumulator};
#[derive(Debug)]
pub struct PivotAggregateGroup {
pub function_name: String,
pub condition_col_idx: usize,
pub value_col_idx: usize,
pub distinct: bool,
pub entries: Vec<PivotEntry>,
}
#[derive(Debug)]
pub struct PivotEntry {
pub condition_value: SqlValue,
pub cache_key: String,
}
impl PivotAggregateGroup {
pub fn try_detect(
select_list: &[vibesql_ast::SelectItem],
schema: &CombinedSchema,
) -> Option<Self> {
let mut candidates: Vec<PivotCandidate> = Vec::new();
for item in select_list {
if let vibesql_ast::SelectItem::Expression { expr, .. } = item {
if let Some(candidate) = Self::try_extract_pivot_candidate(expr, schema) {
candidates.push(candidate);
}
}
}
if candidates.len() < 2 {
return None;
}
let mut groups: HashMap<(String, usize, usize, bool), Vec<PivotCandidate>> = HashMap::new();
for candidate in candidates {
let key = (
candidate.function_name.clone(),
candidate.condition_col_idx,
candidate.value_col_idx,
candidate.distinct,
);
groups.entry(key).or_default().push(candidate);
}
let best_group =
groups.into_iter().filter(|(_, v)| v.len() >= 2).max_by_key(|(_, v)| v.len());
best_group.map(
|((function_name, condition_col_idx, value_col_idx, distinct), candidates)| {
let entries = candidates
.into_iter()
.map(|c| PivotEntry {
condition_value: c.condition_value,
cache_key: c.cache_key,
})
.collect();
PivotAggregateGroup {
function_name,
condition_col_idx,
value_col_idx,
distinct,
entries,
}
},
)
}
fn try_extract_pivot_candidate(
expr: &Expression,
schema: &CombinedSchema,
) -> Option<PivotCandidate> {
let (name, distinct, args) = match expr {
Expression::AggregateFunction { name, distinct, args, .. } => (name, *distinct, args),
_ => return None,
};
if args.len() != 1 {
return None;
}
let (operand, when_clauses, else_result) = match &args[0] {
Expression::Case { operand, when_clauses, else_result } => {
(operand, when_clauses, else_result)
}
_ => return None,
};
if operand.is_some() {
return None;
}
if when_clauses.len() != 1 {
return None;
}
match else_result.as_deref() {
None => {}
Some(Expression::Literal(SqlValue::Null)) => {}
_ => return None,
}
let when_clause = &when_clauses[0];
if when_clause.conditions.len() != 1 {
return None;
}
let condition = &when_clause.conditions[0];
let result = &when_clause.result;
let (condition_col_idx, condition_value) = match condition {
Expression::BinaryOp { op: vibesql_ast::BinaryOperator::Equal, left, right } => {
if let Some(pair) = Self::extract_column_equals_literal(left, right, schema) {
pair
} else if let Some(pair) = Self::extract_column_equals_literal(right, left, schema)
{
pair
} else {
return None;
}
}
_ => return None,
};
let value_col_idx = match result {
Expression::ColumnRef(col_id) => {
schema.get_column_index(col_id.table_canonical(), col_id.column_canonical())?
}
_ => return None,
};
let cache_key = format!("{}:{}:{:?}", name.to_uppercase(), distinct, args);
Some(PivotCandidate {
function_name: name.to_uppercase(),
condition_col_idx,
condition_value,
value_col_idx,
distinct,
cache_key,
})
}
fn extract_column_equals_literal(
col_expr: &Expression,
lit_expr: &Expression,
schema: &CombinedSchema,
) -> Option<(usize, SqlValue)> {
let col_idx = match col_expr {
Expression::ColumnRef(col_id) => {
schema.get_column_index(col_id.table_canonical(), col_id.column_canonical())?
}
_ => return None,
};
let lit_val = match lit_expr {
Expression::Literal(val) => val.clone(),
_ => return None,
};
Some((col_idx, lit_val))
}
pub fn execute(
&self,
group_rows: &[vibesql_storage::Row],
) -> Result<HashMap<String, SqlValue>, crate::errors::ExecutorError> {
let mut accumulators: Vec<AggregateAccumulator> = self
.entries
.iter()
.map(|_| AggregateAccumulator::new(&self.function_name, self.distinct))
.collect::<Result<Vec<_>, _>>()?;
let mut value_to_idx: HashMap<&SqlValue, usize> =
HashMap::with_capacity(self.entries.len());
for (idx, entry) in self.entries.iter().enumerate() {
value_to_idx.insert(&entry.condition_value, idx);
}
for row in group_rows {
let condition_val = &row.values[self.condition_col_idx];
if let Some(&acc_idx) = value_to_idx.get(condition_val) {
let value = &row.values[self.value_col_idx];
accumulators[acc_idx].accumulate(value);
}
}
let mut results = HashMap::with_capacity(self.entries.len());
for (entry, acc) in self.entries.iter().zip(accumulators.into_iter()) {
results.insert(entry.cache_key.clone(), acc.finalize()?);
}
Ok(results)
}
}
#[derive(Debug)]
struct PivotCandidate {
function_name: String,
condition_col_idx: usize,
condition_value: SqlValue,
value_col_idx: usize,
distinct: bool,
cache_key: String,
}
#[cfg(test)]
mod tests {
use vibesql_ast::{BinaryOperator, CaseWhen, SelectItem};
use vibesql_catalog::{ColumnSchema, TableSchema};
use vibesql_storage::Row;
use vibesql_types::DataType;
use super::*;
fn create_test_schema() -> CombinedSchema {
let table_schema = TableSchema::new(
"test".to_string(),
vec![
ColumnSchema::new(
"d_day_name".to_string(),
DataType::Varchar { max_length: Some(20) },
false,
),
ColumnSchema::new("sales_price".to_string(), DataType::Integer, false),
ColumnSchema::new("d_week_seq".to_string(), DataType::Integer, false),
],
);
CombinedSchema::from_table("test".to_string(), table_schema)
}
fn create_sum_case_expression(match_value: &str) -> Expression {
Expression::AggregateFunction {
name: vibesql_ast::FunctionIdentifier::new("SUM"),
distinct: false,
args: vec![Expression::Case {
operand: None,
when_clauses: vec![CaseWhen {
conditions: vec![Expression::BinaryOp {
left: Box::new(Expression::ColumnRef(
vibesql_ast::ColumnIdentifier::simple("d_day_name", false),
)),
op: BinaryOperator::Equal,
right: Box::new(Expression::Literal(SqlValue::Varchar(
arcstr::ArcStr::from(match_value),
))),
}],
result: Expression::ColumnRef(vibesql_ast::ColumnIdentifier::simple(
"sales_price",
false,
)),
}],
else_result: Some(Box::new(Expression::Literal(SqlValue::Null))),
}],
order_by: None,
filter: None,
}
}
#[test]
fn test_detect_pivot_pattern_with_two_aggregates() {
let schema = create_test_schema();
let select_list = vec![
SelectItem::Expression {
expr: create_sum_case_expression("Sunday"),
alias: Some("sun_sales".to_string()),
source_text: None,
},
SelectItem::Expression {
expr: create_sum_case_expression("Monday"),
alias: Some("mon_sales".to_string()),
source_text: None,
},
];
let pivot = PivotAggregateGroup::try_detect(&select_list, &schema);
assert!(pivot.is_some(), "Should detect pivot pattern with 2 aggregates");
let pivot = pivot.unwrap();
assert_eq!(pivot.function_name, "SUM");
assert_eq!(pivot.condition_col_idx, 0); assert_eq!(pivot.value_col_idx, 1); assert_eq!(pivot.entries.len(), 2);
}
#[test]
fn test_detect_pivot_pattern_with_seven_aggregates() {
let schema = create_test_schema();
let days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
let select_list: Vec<SelectItem> = days
.iter()
.map(|day| SelectItem::Expression {
expr: create_sum_case_expression(day),
alias: Some(format!("{}_sales", day.to_lowercase())),
source_text: None,
})
.collect();
let pivot = PivotAggregateGroup::try_detect(&select_list, &schema);
assert!(pivot.is_some(), "Should detect pivot pattern with 7 aggregates");
let pivot = pivot.unwrap();
assert_eq!(pivot.entries.len(), 7);
}
#[test]
fn test_no_pivot_with_single_aggregate() {
let schema = create_test_schema();
let select_list = vec![SelectItem::Expression {
expr: create_sum_case_expression("Sunday"),
alias: Some("sun_sales".to_string()),
source_text: None,
}];
let pivot = PivotAggregateGroup::try_detect(&select_list, &schema);
assert!(pivot.is_none(), "Should not detect pivot with single aggregate");
}
#[test]
fn test_no_pivot_with_different_condition_columns() {
let schema = create_test_schema();
let expr1 = create_sum_case_expression("Sunday");
let expr2 = Expression::AggregateFunction {
name: vibesql_ast::FunctionIdentifier::new("SUM"),
distinct: false,
args: vec![Expression::Case {
operand: None,
when_clauses: vec![CaseWhen {
conditions: vec![Expression::BinaryOp {
left: Box::new(Expression::ColumnRef(
vibesql_ast::ColumnIdentifier::simple("d_week_seq", false),
)),
op: BinaryOperator::Equal,
right: Box::new(Expression::Literal(SqlValue::Integer(1))),
}],
result: Expression::ColumnRef(vibesql_ast::ColumnIdentifier::simple(
"sales_price",
false,
)),
}],
else_result: Some(Box::new(Expression::Literal(SqlValue::Null))),
}],
order_by: None,
filter: None,
};
let select_list = vec![
SelectItem::Expression {
expr: expr1,
alias: Some("sun_sales".to_string()),
source_text: None,
},
SelectItem::Expression {
expr: expr2,
alias: Some("week1_sales".to_string()),
source_text: None,
},
];
let pivot = PivotAggregateGroup::try_detect(&select_list, &schema);
assert!(pivot.is_none(), "Should not detect pivot with different condition columns");
}
#[test]
fn test_execute_pivot_aggregates() {
let schema = create_test_schema();
let select_list = vec![
SelectItem::Expression {
expr: create_sum_case_expression("Sunday"),
alias: Some("sun_sales".to_string()),
source_text: None,
},
SelectItem::Expression {
expr: create_sum_case_expression("Monday"),
alias: Some("mon_sales".to_string()),
source_text: None,
},
SelectItem::Expression {
expr: create_sum_case_expression("Tuesday"),
alias: Some("tue_sales".to_string()),
source_text: None,
},
];
let pivot = PivotAggregateGroup::try_detect(&select_list, &schema).unwrap();
let rows = vec![
Row::new(vec![
SqlValue::Varchar(arcstr::ArcStr::from("Sunday")),
SqlValue::Integer(100),
SqlValue::Integer(1),
]),
Row::new(vec![
SqlValue::Varchar(arcstr::ArcStr::from("Sunday")),
SqlValue::Integer(200),
SqlValue::Integer(1),
]),
Row::new(vec![
SqlValue::Varchar(arcstr::ArcStr::from("Monday")),
SqlValue::Integer(50),
SqlValue::Integer(1),
]),
Row::new(vec![
SqlValue::Varchar(arcstr::ArcStr::from("Wednesday")), SqlValue::Integer(999),
SqlValue::Integer(1),
]),
];
let results = pivot.execute(&rows).unwrap();
let sun_key = pivot
.entries
.iter()
.find(|e| matches!(&e.condition_value, SqlValue::Varchar(s) if s.as_str() == "Sunday"))
.unwrap();
let mon_key = pivot
.entries
.iter()
.find(|e| matches!(&e.condition_value, SqlValue::Varchar(s) if s.as_str() == "Monday"))
.unwrap();
let tue_key = pivot
.entries
.iter()
.find(|e| matches!(&e.condition_value, SqlValue::Varchar(s) if s.as_str() == "Tuesday"))
.unwrap();
assert_eq!(results.get(&sun_key.cache_key), Some(&SqlValue::Integer(300))); assert_eq!(results.get(&mon_key.cache_key), Some(&SqlValue::Integer(50)));
assert_eq!(results.get(&tue_key.cache_key), Some(&SqlValue::Null)); }
#[test]
fn test_execute_with_empty_rows() {
let schema = create_test_schema();
let select_list = vec![
SelectItem::Expression {
expr: create_sum_case_expression("Sunday"),
alias: Some("sun_sales".to_string()),
source_text: None,
},
SelectItem::Expression {
expr: create_sum_case_expression("Monday"),
alias: Some("mon_sales".to_string()),
source_text: None,
},
];
let pivot = PivotAggregateGroup::try_detect(&select_list, &schema).unwrap();
let results = pivot.execute(&[]).unwrap();
for entry in &pivot.entries {
assert_eq!(results.get(&entry.cache_key), Some(&SqlValue::Null));
}
}
}