Skip to main content

hayson_binding_shared/
aggregate.rs

1//! The aggregate document `Table::aggregate` takes through a binding, and the shape it answers in.
2
3use std::collections::HashMap;
4
5use hayson_engine::query::{AggregateResult, AggregateSpec, GroupedAggregateResult};
6use serde_json::{json, Value};
7
8/// `{ count?, sum?: string[], avg?, min?, max?, groupBy?: string }`. An absent document asks for the
9/// default aggregate (just the count).
10pub fn parse_aggregate(value: &Value) -> Result<AggregateSpec, String> {
11    if value.is_null() {
12        return Ok(AggregateSpec::default());
13    }
14    let object = value
15        .as_object()
16        .ok_or_else(|| "the aggregate must be a JSON object".to_string())?;
17    const KNOWN: [&str; 6] = ["count", "groupBy", "sum", "avg", "min", "max"];
18    if let Some(key) = object.keys().find(|key| !KNOWN.contains(&key.as_str())) {
19        return Err(format!("unknown aggregate field \"{key}\""));
20    }
21    let count = match object.get("count") {
22        None | Some(Value::Null) => false,
23        Some(Value::Bool(flag)) => *flag,
24        Some(_) => return Err("\"count\" must be a boolean".to_string()),
25    };
26    let group_by = match object.get("groupBy") {
27        None | Some(Value::Null) => None,
28        Some(Value::String(field)) => Some(field.clone()),
29        Some(_) => return Err("\"groupBy\" must be a field name".to_string()),
30    };
31    Ok(AggregateSpec {
32        count,
33        sum: field_list(object.get("sum"), "sum")?,
34        avg: field_list(object.get("avg"), "avg")?,
35        min: field_list(object.get("min"), "min")?,
36        max: field_list(object.get("max"), "max")?,
37        group_by,
38    })
39}
40
41fn field_list(value: Option<&Value>, name: &str) -> Result<Vec<String>, String> {
42    match value {
43        None | Some(Value::Null) => Ok(Vec::new()),
44        Some(Value::Array(items)) => items
45            .iter()
46            .map(|item| {
47                item.as_str()
48                    .map(str::to_owned)
49                    .ok_or_else(|| format!("\"{name}\" must list field names as strings"))
50            })
51            .collect(),
52        Some(_) => Err(format!("\"{name}\" must be a JSON array of field names")),
53    }
54}
55
56fn field_map_to_json<T: Copy>(values: &HashMap<String, T>, to_json: impl Fn(T) -> Value) -> Value {
57    let mut fields: Vec<(&String, &T)> = values.iter().collect();
58    fields.sort_by_key(|(field, _)| *field);
59    Value::Object(
60        fields
61            .into_iter()
62            .map(|(field, value)| (field.clone(), to_json(*value)))
63            .collect(),
64    )
65}
66
67fn optional_number(value: Option<f64>) -> Value {
68    value.map(Value::from).unwrap_or(Value::Null)
69}
70
71/// `{ count, sum, avg, min, max }`, the maps keyed by field name in name order.
72pub fn aggregate_result_to_json(result: &AggregateResult) -> Value {
73    json!({
74        "count": result.count,
75        "sum": field_map_to_json(&result.sum, Value::from),
76        "avg": field_map_to_json(&result.avg, optional_number),
77        "min": field_map_to_json(&result.min, optional_number),
78        "max": field_map_to_json(&result.max, optional_number),
79    })
80}
81
82/// `[{ key, result }]`, one entry per distinct value of the grouped field, in value order.
83pub fn grouped_aggregate_result_to_json(result: &GroupedAggregateResult) -> Value {
84    Value::Array(
85        result
86            .0
87            .iter()
88            .map(|(key, group)| json!({ "key": key, "result": aggregate_result_to_json(group) }))
89            .collect(),
90    )
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn the_document_names_fields_and_a_group() {
99        let spec = parse_aggregate(&json!({ "count": true, "sum": ["bytes"], "groupBy": "ns" })).unwrap();
100
101        assert!(spec.count);
102        assert_eq!(spec.sum, vec!["bytes".to_string()]);
103        assert_eq!(spec.group_by.as_deref(), Some("ns"));
104        assert_eq!(parse_aggregate(&Value::Null).unwrap(), AggregateSpec::default());
105    }
106
107    #[test]
108    fn a_malformed_document_is_refused_by_name() {
109        for bad in [
110            json!(3),
111            json!({ "sums": [] }),
112            json!({ "sum": "bytes" }),
113            json!({ "sum": [1] }),
114            json!({ "groupBy": 1 }),
115            json!({ "count": "yes" }),
116        ] {
117            assert!(parse_aggregate(&bad).is_err(), "{bad}");
118        }
119    }
120}