use crate::error::{Result, TqlError};
use crate::field_accessor;
use crate::parser::Aggregation;
use serde_json::{json, Value as JsonValue};
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
pub fn agg_params(agg: &Aggregation) -> HashMap<String, JsonValue> {
let mut params = HashMap::new();
if let Some(values) = &agg.percentile_values {
params.insert("percentile_values".to_string(), json!(values));
}
if let Some(values) = &agg.rank_values {
params.insert("rank_values".to_string(), json!(values));
}
params
}
fn render_group_key_part(value: &JsonValue) -> String {
match value {
JsonValue::String(s) => s.clone(),
JsonValue::Number(n) => n.to_string(),
JsonValue::Bool(b) => b.to_string(),
JsonValue::Null => "null".to_string(),
_ => serde_json::to_string(value).unwrap_or_default(),
}
}
fn group_key_class(value: &JsonValue) -> String {
match value {
JsonValue::Bool(b) => format!("#{}", if *b { 1 } else { 0 }),
JsonValue::Number(n) => format!("#{}", canonical_number_class(n)),
JsonValue::String(s) => format!("s{}", s),
JsonValue::Null => "n".to_string(),
other => format!("j{}", serde_json::to_string(other).unwrap_or_default()),
}
}
fn canonical_number_class(n: &serde_json::Number) -> String {
if let Some(i) = n.as_i64() {
return i.to_string();
}
if let Some(u) = n.as_u64() {
return u.to_string();
}
match n.as_f64() {
Some(f)
if f.is_finite()
&& f.fract() == 0.0
&& (i64::MIN as f64..=i64::MAX as f64).contains(&f) =>
{
(f as i64).to_string()
}
Some(f) => format!("{:?}", f),
None => n.to_string(),
}
}
fn sort_key(value: &JsonValue) -> (u8, f64, String) {
match value {
JsonValue::Number(n) => (0, n.as_f64().unwrap_or(0.0), String::new()),
JsonValue::Bool(b) => (0, if *b { 1.0 } else { 0.0 }, String::new()),
JsonValue::String(s) => (1, 0.0, s.clone()),
other => (2, 0.0, render_group_key_part(other)),
}
}
fn group_key_combinations(field_values: &[JsonValue]) -> Vec<Vec<JsonValue>> {
let mut combinations: Vec<Vec<JsonValue>> = vec![Vec::new()];
for value in field_values {
let alternatives: Vec<JsonValue> = match value {
JsonValue::Null => Vec::new(),
JsonValue::Array(items) => {
let mut seen = HashSet::new();
items
.iter()
.filter(|item| !item.is_null())
.filter(|item| seen.insert(group_key_class(item)))
.cloned()
.collect()
}
other => vec![other.clone()],
};
if alternatives.is_empty() {
return Vec::new();
}
combinations = combinations
.iter()
.flat_map(|prefix| {
alternatives.iter().map(move |alt| {
let mut extended = prefix.clone();
extended.push(alt.clone());
extended
})
})
.collect();
}
combinations
}
pub struct StatsEvaluator {
_max_depth: usize,
}
#[derive(Debug, Clone, Default)]
pub struct AggregationSpec {
pub function: String,
pub field: String,
pub alias: Option<String>,
pub modifier: Option<String>,
pub limit: Option<usize>,
pub params: HashMap<String, JsonValue>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct GroupBySpec {
pub field: String,
pub bucket_size: Option<usize>,
}
impl From<&str> for GroupBySpec {
fn from(field: &str) -> Self {
Self {
field: field.to_string(),
bucket_size: None,
}
}
}
impl From<String> for GroupBySpec {
fn from(field: String) -> Self {
Self {
field,
bucket_size: None,
}
}
}
impl From<&crate::parser::GroupBy> for GroupBySpec {
fn from(group_by: &crate::parser::GroupBy) -> Self {
Self {
field: group_by.field.clone(),
bucket_size: group_by.bucket_size,
}
}
}
impl From<&Aggregation> for AggregationSpec {
fn from(agg: &Aggregation) -> Self {
Self {
function: agg.function.clone(),
field: agg.field.clone().unwrap_or_else(|| "*".to_string()),
alias: agg.alias.clone(),
modifier: agg.modifier.clone(),
limit: agg.limit,
params: agg_params(agg),
}
}
}
type ReservedValues = HashMap<Vec<Option<String>>, HashSet<Option<String>>>;
#[derive(Debug, Clone)]
pub struct StatsQuery {
pub aggregations: Vec<AggregationSpec>,
pub group_by: Vec<GroupBySpec>,
}
impl Default for StatsEvaluator {
fn default() -> Self {
Self::new()
}
}
impl StatsEvaluator {
pub fn new() -> Self {
Self { _max_depth: 100 }
}
pub fn evaluate_stats(&self, records: &[JsonValue], query: &StatsQuery) -> Result<JsonValue> {
if query.group_by.is_empty() {
self.simple_aggregation(records, &query.aggregations)
} else {
self.grouped_aggregation(records, &query.aggregations, &query.group_by)
}
}
fn simple_aggregation(
&self,
records: &[JsonValue],
aggregations: &[AggregationSpec],
) -> Result<JsonValue> {
if aggregations.len() == 1 {
let agg = &aggregations[0];
let value = self.calculate_aggregation(records, agg)?;
Ok(json!({
"type": "simple_aggregation",
"function": agg.function,
"field": agg.field,
"alias": agg.alias,
"value": value
}))
} else {
let mut results = HashMap::new();
for agg in aggregations {
let value = self.calculate_aggregation(records, agg)?;
let key = agg
.alias
.clone()
.unwrap_or_else(|| format!("{}_{}", agg.function, agg.field));
results.insert(key, value);
}
Ok(json!({
"type": "multiple_aggregations",
"results": results
}))
}
}
fn grouped_aggregation(
&self,
records: &[JsonValue],
aggregations: &[AggregationSpec],
group_by: &[GroupBySpec],
) -> Result<JsonValue> {
let group_by_fields: Vec<String> = group_by.iter().map(|g| g.field.clone()).collect();
let mut order: Vec<Vec<String>> = Vec::new();
let mut groups: HashMap<Vec<String>, Vec<&JsonValue>> = HashMap::new();
let mut key_values: HashMap<Vec<String>, Vec<JsonValue>> = HashMap::new();
for record in records {
let field_values: Vec<JsonValue> = group_by_fields
.iter()
.map(|field| match field_accessor::get_field(record, field) {
Ok(Some(v)) => v.clone(),
Ok(None) | Err(_) => JsonValue::Null,
})
.collect();
for key_parts in group_key_combinations(&field_values) {
let rendered: Vec<String> = key_parts.iter().map(group_key_class).collect();
if !groups.contains_key(&rendered) {
order.push(rendered.clone());
key_values.insert(rendered.clone(), key_parts);
}
groups.entry(rendered).or_default().push(record);
}
}
let mut results = Vec::new();
for rendered_key in &order {
let group_records = &groups[rendered_key];
let mut group_result: HashMap<String, JsonValue> = HashMap::new();
let key_parts = key_values
.get(rendered_key)
.expect("every group has a recorded key");
let mut key_map = HashMap::new();
for (i, field) in group_by_fields.iter().enumerate() {
key_map.insert(field.clone(), key_parts[i].clone());
}
group_result.insert("key".to_string(), json!(key_map));
group_result.insert("doc_count".to_string(), json!(group_records.len()));
if aggregations.len() == 1 {
let agg = &aggregations[0];
let owned_records: Vec<JsonValue> =
group_records.iter().map(|&r| r.clone()).collect();
let value = self.calculate_aggregation(&owned_records, agg)?;
let agg_key = agg.alias.clone().unwrap_or_else(|| agg.function.clone());
group_result.insert(agg_key, value);
} else {
let mut agg_results = HashMap::new();
for agg in aggregations {
let owned_records: Vec<JsonValue> =
group_records.iter().map(|&r| r.clone()).collect();
let value = self.calculate_aggregation(&owned_records, agg)?;
let agg_key = agg
.alias
.clone()
.unwrap_or_else(|| format!("{}_{}", agg.function, agg.field));
agg_results.insert(agg_key, value);
}
group_result.insert("aggregations".to_string(), json!(agg_results));
}
results.push(json!(group_result));
}
let results = Self::apply_modifiers(results, aggregations)?;
let results = Self::apply_bucket_limits(results, group_by);
Ok(json!({
"type": "grouped_aggregation",
"group_by": group_by_fields,
"results": results
}))
}
fn modifier_container(result: &JsonValue) -> &JsonValue {
match result.get("aggregations") {
Some(aggs) => aggs,
None => result,
}
}
fn modifier_value<'a>(result: &'a JsonValue, agg_key: &str) -> Result<&'a JsonValue> {
let container = Self::modifier_container(result);
container.get(agg_key).ok_or_else(|| {
let found: Vec<&str> = match container.as_object() {
Some(map) => {
let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
keys.sort_unstable();
keys
}
None => Vec::new(),
};
TqlError::ExecutionError(format!(
"Cannot rank buckets by '{}': no aggregation under that name in the \
group result (found {:?}). This is a naming mismatch between the \
aggregation emitter and this sort, not missing data. Give the \
aggregation an explicit alias with `as <name>`.",
agg_key, found
))
})
}
fn modifier_sort_key(value: &JsonValue) -> (u8, f64, String) {
match value {
JsonValue::Null => (0, 0.0, String::new()),
other => sort_key(other),
}
}
fn apply_modifiers(
results: Vec<JsonValue>,
aggregations: &[AggregationSpec],
) -> Result<Vec<JsonValue>> {
for agg in aggregations {
let Some(modifier) = agg.modifier.as_deref() else {
continue;
};
let agg_key = agg.alias.clone().unwrap_or_else(|| {
if aggregations.len() == 1 {
agg.function.clone()
} else {
format!("{}_{}", agg.function, agg.field)
}
});
let descending = modifier == "top";
let mut ranked: Vec<((u8, f64, String), JsonValue)> = Vec::with_capacity(results.len());
for result in results {
let key = Self::modifier_sort_key(Self::modifier_value(&result, &agg_key)?);
ranked.push((key, result));
}
ranked.sort_by(|(ka, _), (kb, _)| {
let ordering = ka.partial_cmp(kb).unwrap_or(Ordering::Equal);
if descending {
ordering.reverse()
} else {
ordering
}
});
ranked.truncate(agg.limit.unwrap_or(10));
return Ok(ranked.into_iter().map(|(_, result)| result).collect());
}
Ok(results)
}
fn apply_bucket_limits(
mut results: Vec<JsonValue>,
group_by: &[GroupBySpec],
) -> Vec<JsonValue> {
if !group_by.iter().any(|g| g.bucket_size.is_some()) {
return results;
}
let doc_count = |r: &JsonValue| r.get("doc_count").and_then(|v| v.as_u64()).unwrap_or(0);
if group_by.len() == 1 {
if let Some(bucket_size) = group_by[0].bucket_size {
if bucket_size > 0 {
results.sort_by_key(|r| std::cmp::Reverse(doc_count(r)));
results.truncate(bucket_size);
}
}
return results;
}
results.sort_by_key(|r| std::cmp::Reverse(doc_count(r)));
let mut level_values: Vec<ReservedValues> = vec![HashMap::new(); group_by.len()];
let mut filtered = Vec::new();
for result in results {
let mut should_include = true;
let mut key_path: Vec<Option<String>> = Vec::new();
for (level, spec) in group_by.iter().enumerate() {
let field_value = result
.get("key")
.and_then(|k| k.get(&spec.field))
.map(group_key_class);
key_path.push(field_value.clone());
let Some(bucket_size) = spec.bucket_size else {
continue;
};
let parent_key = key_path[..level].to_vec();
let reserved = level_values[level].entry(parent_key).or_default();
if !reserved.contains(&field_value) {
if reserved.len() >= bucket_size {
should_include = false;
break;
}
reserved.insert(field_value);
}
}
if should_include {
filtered.push(result);
}
}
filtered
}
fn calculate_aggregation(
&self,
records: &[JsonValue],
agg_spec: &AggregationSpec,
) -> Result<JsonValue> {
let func = &agg_spec.function;
let field = &agg_spec.field;
if func == "count" && field == "*" {
return Ok(json!(records.len()));
}
let values: Vec<JsonValue> = records
.iter()
.filter_map(|record| match field_accessor::get_field(record, field) {
Ok(Some(value)) => {
if value != &JsonValue::Null {
Some(value.clone())
} else {
None
}
}
_ => None,
})
.collect();
match func.to_lowercase().as_str() {
"count" => Ok(json!(values.len())),
"unique_count" | "cardinality" => {
let unique: HashSet<String> = values
.iter()
.map(|v| serde_json::to_string(v).unwrap_or_default())
.collect();
Ok(json!(unique.len()))
}
"sum" => {
let sum: f64 = self.numeric_values(&values, field)?.iter().sum();
Ok(json!(if sum == 0.0 { 0.0 } else { sum }))
}
"min" => {
let min = self
.numeric_values(&values, field)?
.into_iter()
.min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
Ok(json!(min))
}
"max" => {
let max = self
.numeric_values(&values, field)?
.into_iter()
.max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
Ok(json!(max))
}
"average" | "avg" | "mean" => {
let numeric_values: Vec<f64> = self.numeric_values(&values, field)?;
if numeric_values.is_empty() {
Ok(JsonValue::Null)
} else {
let avg = numeric_values.iter().sum::<f64>() / numeric_values.len() as f64;
Ok(json!(avg))
}
}
"median" | "med" => {
let mut numeric_values: Vec<f64> = self.numeric_values(&values, field)?;
if numeric_values.is_empty() {
return Ok(JsonValue::Null);
}
numeric_values
.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let len = numeric_values.len();
let median = if len.is_multiple_of(2) {
(numeric_values[len / 2 - 1] + numeric_values[len / 2]) / 2.0
} else {
numeric_values[len / 2]
};
Ok(json!(median))
}
"std" | "standard_deviation" => {
let numeric_values: Vec<f64> = self.numeric_values(&values, field)?;
if numeric_values.len() < 2 {
return Ok(JsonValue::Null);
}
let mean = numeric_values.iter().sum::<f64>() / numeric_values.len() as f64;
let variance = numeric_values
.iter()
.map(|v| (v - mean).powi(2))
.sum::<f64>()
/ (numeric_values.len() - 1) as f64;
let std_dev = variance.sqrt();
Ok(json!(std_dev))
}
"percentile" | "percentiles" | "p" | "pct" => {
let mut numeric_values: Vec<f64> = self.numeric_values(&values, field)?;
if numeric_values.is_empty() {
return Ok(JsonValue::Null);
}
numeric_values
.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let percentile_values = agg_spec
.params
.get("percentile_values")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_f64()).collect::<Vec<f64>>())
.unwrap_or_else(|| vec![50.0]);
if percentile_values.len() == 1 {
let p = self.calculate_percentile(&numeric_values, percentile_values[0])?;
Ok(json!(p))
} else {
let mut result = HashMap::new();
for p_val in percentile_values {
let p = self.calculate_percentile(&numeric_values, p_val)?;
result.insert(format!("p{}", p_val as i32), json!(p));
}
Ok(json!(result))
}
}
"percentile_rank" | "percentile_ranks" | "pct_rank" | "pct_ranks" => {
let mut numeric_values: Vec<f64> = self.numeric_values(&values, field)?;
if numeric_values.is_empty() {
return Ok(JsonValue::Null);
}
numeric_values
.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let rank_values = agg_spec
.params
.get("rank_values")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_f64()).collect::<Vec<f64>>())
.unwrap_or_default();
if rank_values.is_empty() {
return Err(TqlError::ExecutionError(
"percentile_rank requires at least one value".to_string(),
));
}
if rank_values.len() == 1 {
Ok(json!(self.calculate_percentile_rank(
&numeric_values,
rank_values[0]
)))
} else {
let mut result = HashMap::new();
for v in rank_values {
result.insert(
format!("rank_{:?}", v),
json!(self.calculate_percentile_rank(&numeric_values, v)),
);
}
Ok(json!(result))
}
}
"values" | "unique" | "distinct" => {
let mut unique: Vec<JsonValue> = Vec::new();
let mut seen = HashSet::new();
for value in &values {
if seen.insert(group_key_class(value)) {
unique.push(value.clone());
}
}
unique.sort_by(|a, b| {
sort_key(a)
.partial_cmp(&sort_key(b))
.unwrap_or(Ordering::Equal)
});
Ok(json!(unique))
}
_ => Err(TqlError::ExecutionError(format!(
"Unsupported aggregation function: {}",
func
))),
}
}
fn numeric_values(&self, values: &[JsonValue], field: &str) -> Result<Vec<f64>> {
values
.iter()
.map(|value| {
self.to_numeric(value).ok_or_else(|| {
TqlError::ExecutionError(format!(
"Cannot convert {} to numeric value for aggregation on field '{}'. Ensure the field contains numeric data.",
serde_json::to_string(value).unwrap_or_else(|_| "value".to_string()),
field
))
})
})
.collect()
}
fn to_numeric(&self, value: &JsonValue) -> Option<f64> {
match value {
JsonValue::Number(n) => n.as_f64(),
JsonValue::String(s) => s.parse::<f64>().ok(),
JsonValue::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
_ => None,
}
}
fn calculate_percentile(&self, sorted_values: &[f64], percentile: f64) -> Result<Option<f64>> {
if sorted_values.is_empty() {
return Ok(None);
}
if !(0.0..=100.0).contains(&percentile) {
return Err(TqlError::ExecutionError(format!(
"Percentile must be between 0 and 100, got {}",
percentile
)));
}
let n = sorted_values.len();
if n == 1 {
return Ok(Some(sorted_values[0]));
}
let pos = (n as f64 - 1.0) * (percentile / 100.0);
let lower_idx = pos.floor() as usize;
let upper_idx = (lower_idx + 1).min(n - 1);
if lower_idx == upper_idx {
return Ok(Some(sorted_values[lower_idx]));
}
let lower = sorted_values[lower_idx];
let upper = sorted_values[upper_idx];
let fraction = pos - lower_idx as f64;
Ok(Some(lower + fraction * (upper - lower)))
}
fn calculate_percentile_rank(&self, sorted_values: &[f64], value: f64) -> Option<f64> {
if sorted_values.is_empty() {
return None;
}
let n = sorted_values.len() as f64;
let count_less = sorted_values.iter().filter(|v| **v < value).count() as f64;
let count_equal = sorted_values.iter().filter(|v| **v == value).count() as f64;
let rank = if count_equal > 0.0 {
(count_less + count_equal / 2.0) / n * 100.0
} else {
count_less / n * 100.0
};
Some((rank * 100.0).round() / 100.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_rank_key_the_emitter_never_wrote_is_refused() {
let results = vec![
json!({"key": {"dept": "a"}, "doc_count": 1, "aggregations": {"sum_salary": 100}}),
json!({"key": {"dept": "b"}, "doc_count": 1, "aggregations": {"sum_salary": 200}}),
];
let aggregations = vec![
AggregationSpec {
function: "count".to_string(),
field: "*".to_string(),
..Default::default()
},
AggregationSpec {
function: "sum".to_string(),
field: "salary".to_string(),
alias: Some("never_emitted".to_string()),
modifier: Some("top".to_string()),
limit: Some(1),
..Default::default()
},
];
let err = StatsEvaluator::apply_modifiers(results, &aggregations)
.expect_err("a missing rank key must be reported, not defaulted to 0");
let message = err.to_string();
assert!(
message.contains("never_emitted"),
"the error must name the key that missed: {}",
message
);
assert!(
message.contains("sum_salary"),
"the error must name what WAS present, so the mismatch is readable: {}",
message
);
}
#[test]
fn a_null_aggregate_ranks_lowest_rather_than_refusing() {
let results = vec![
json!({"key": {"dept": "a"}, "doc_count": 1, "aggregations": {"avg_x": null, "count_*": 1}}),
json!({"key": {"dept": "b"}, "doc_count": 1, "aggregations": {"avg_x": 5.0, "count_*": 1}}),
];
let aggregations = vec![
AggregationSpec {
function: "count".to_string(),
field: "*".to_string(),
..Default::default()
},
AggregationSpec {
function: "avg".to_string(),
field: "x".to_string(),
modifier: Some("top".to_string()),
limit: Some(1),
..Default::default()
},
];
let ranked = StatsEvaluator::apply_modifiers(results, &aggregations)
.expect("a null aggregate must not abort the query");
assert_eq!(ranked.len(), 1);
assert_eq!(ranked[0]["key"]["dept"], json!("b"));
}
#[test]
fn test_simple_count() {
let evaluator = StatsEvaluator::new();
let records = vec![
json!({"name": "Alice", "age": 30}),
json!({"name": "Bob", "age": 25}),
json!({"name": "Charlie", "age": 35}),
];
let query = StatsQuery {
aggregations: vec![AggregationSpec {
function: "count".to_string(),
field: "*".to_string(),
alias: None,
params: HashMap::new(),
..Default::default()
}],
group_by: vec![],
};
let result = evaluator.evaluate_stats(&records, &query).unwrap();
assert_eq!(result["value"], json!(3));
}
#[test]
fn test_sum_aggregation() {
let evaluator = StatsEvaluator::new();
let records = vec![
json!({"name": "Alice", "score": 90}),
json!({"name": "Bob", "score": 85}),
json!({"name": "Charlie", "score": 95}),
];
let query = StatsQuery {
aggregations: vec![AggregationSpec {
function: "sum".to_string(),
field: "score".to_string(),
alias: None,
params: HashMap::new(),
..Default::default()
}],
group_by: vec![],
};
let result = evaluator.evaluate_stats(&records, &query).unwrap();
assert_eq!(result["value"], json!(270.0));
}
#[test]
fn test_average_aggregation() {
let evaluator = StatsEvaluator::new();
let records = vec![
json!({"name": "Alice", "age": 30}),
json!({"name": "Bob", "age": 20}),
json!({"name": "Charlie", "age": 40}),
];
let query = StatsQuery {
aggregations: vec![AggregationSpec {
function: "avg".to_string(),
field: "age".to_string(),
alias: None,
params: HashMap::new(),
..Default::default()
}],
group_by: vec![],
};
let result = evaluator.evaluate_stats(&records, &query).unwrap();
assert_eq!(result["value"], json!(30.0));
}
#[test]
fn test_min_max_aggregation() {
let evaluator = StatsEvaluator::new();
let records = vec![
json!({"value": 10}),
json!({"value": 50}),
json!({"value": 30}),
];
let query = StatsQuery {
aggregations: vec![AggregationSpec {
function: "min".to_string(),
field: "value".to_string(),
alias: Some("min_value".to_string()),
params: HashMap::new(),
..Default::default()
}],
group_by: vec![],
};
let result = evaluator.evaluate_stats(&records, &query).unwrap();
assert_eq!(result["value"], json!(10.0));
}
#[test]
fn test_grouped_aggregation() {
let evaluator = StatsEvaluator::new();
let records = vec![
json!({"city": "NYC", "sales": 100}),
json!({"city": "LA", "sales": 150}),
json!({"city": "NYC", "sales": 200}),
json!({"city": "LA", "sales": 250}),
];
let query = StatsQuery {
aggregations: vec![AggregationSpec {
function: "sum".to_string(),
field: "sales".to_string(),
alias: None,
params: HashMap::new(),
..Default::default()
}],
group_by: vec![GroupBySpec::from("city")],
};
let result = evaluator.evaluate_stats(&records, &query).unwrap();
assert_eq!(result["type"], "grouped_aggregation");
let results = result["results"].as_array().unwrap();
assert_eq!(results.len(), 2);
}
#[test]
fn test_multiple_aggregations() {
let evaluator = StatsEvaluator::new();
let records = vec![
json!({"score": 90}),
json!({"score": 85}),
json!({"score": 95}),
];
let query = StatsQuery {
aggregations: vec![
AggregationSpec {
function: "sum".to_string(),
field: "score".to_string(),
alias: Some("total".to_string()),
params: HashMap::new(),
..Default::default()
},
AggregationSpec {
function: "avg".to_string(),
field: "score".to_string(),
alias: Some("average".to_string()),
params: HashMap::new(),
..Default::default()
},
],
group_by: vec![],
};
let result = evaluator.evaluate_stats(&records, &query).unwrap();
assert_eq!(result["type"], "multiple_aggregations");
assert_eq!(result["results"]["total"], json!(270.0));
assert_eq!(result["results"]["average"], json!(90.0));
}
#[test]
fn test_median() {
let evaluator = StatsEvaluator::new();
let records = vec![
json!({"value": 10}),
json!({"value": 20}),
json!({"value": 30}),
json!({"value": 40}),
json!({"value": 50}),
];
let query = StatsQuery {
aggregations: vec![AggregationSpec {
function: "median".to_string(),
field: "value".to_string(),
alias: None,
params: HashMap::new(),
..Default::default()
}],
group_by: vec![],
};
let result = evaluator.evaluate_stats(&records, &query).unwrap();
assert_eq!(result["value"], json!(30.0));
}
#[test]
fn test_unique_count() {
let evaluator = StatsEvaluator::new();
let records = vec![
json!({"city": "NYC"}),
json!({"city": "LA"}),
json!({"city": "NYC"}),
json!({"city": "SF"}),
];
let query = StatsQuery {
aggregations: vec![AggregationSpec {
function: "unique_count".to_string(),
field: "city".to_string(),
alias: None,
params: HashMap::new(),
..Default::default()
}],
group_by: vec![],
};
let result = evaluator.evaluate_stats(&records, &query).unwrap();
assert_eq!(result["value"], json!(3));
}
}