hayson-binding-shared 0.21.0

Shared error mapping, JSON shapes, options and predicate parsing for hayson's NAPI and WASM language bindings
Documentation
//! The aggregate document `Table::aggregate` takes through a binding, and the shape it answers in.

use std::collections::HashMap;

use hayson_engine::query::{AggregateResult, AggregateSpec, GroupedAggregateResult};
use serde_json::{json, Value};

/// `{ count?, sum?: string[], avg?, min?, max?, groupBy?: string }`. An absent document asks for the
/// default aggregate (just the count).
pub fn parse_aggregate(value: &Value) -> Result<AggregateSpec, String> {
    if value.is_null() {
        return Ok(AggregateSpec::default());
    }
    let object = value
        .as_object()
        .ok_or_else(|| "the aggregate must be a JSON object".to_string())?;
    const KNOWN: [&str; 6] = ["count", "groupBy", "sum", "avg", "min", "max"];
    if let Some(key) = object.keys().find(|key| !KNOWN.contains(&key.as_str())) {
        return Err(format!("unknown aggregate field \"{key}\""));
    }
    let count = match object.get("count") {
        None | Some(Value::Null) => false,
        Some(Value::Bool(flag)) => *flag,
        Some(_) => return Err("\"count\" must be a boolean".to_string()),
    };
    let group_by = match object.get("groupBy") {
        None | Some(Value::Null) => None,
        Some(Value::String(field)) => Some(field.clone()),
        Some(_) => return Err("\"groupBy\" must be a field name".to_string()),
    };
    Ok(AggregateSpec {
        count,
        sum: field_list(object.get("sum"), "sum")?,
        avg: field_list(object.get("avg"), "avg")?,
        min: field_list(object.get("min"), "min")?,
        max: field_list(object.get("max"), "max")?,
        group_by,
    })
}

fn field_list(value: Option<&Value>, name: &str) -> Result<Vec<String>, String> {
    match value {
        None | Some(Value::Null) => Ok(Vec::new()),
        Some(Value::Array(items)) => items
            .iter()
            .map(|item| {
                item.as_str()
                    .map(str::to_owned)
                    .ok_or_else(|| format!("\"{name}\" must list field names as strings"))
            })
            .collect(),
        Some(_) => Err(format!("\"{name}\" must be a JSON array of field names")),
    }
}

fn field_map_to_json<T: Copy>(values: &HashMap<String, T>, to_json: impl Fn(T) -> Value) -> Value {
    let mut fields: Vec<(&String, &T)> = values.iter().collect();
    fields.sort_by_key(|(field, _)| *field);
    Value::Object(
        fields
            .into_iter()
            .map(|(field, value)| (field.clone(), to_json(*value)))
            .collect(),
    )
}

fn optional_number(value: Option<f64>) -> Value {
    value.map(Value::from).unwrap_or(Value::Null)
}

/// `{ count, sum, avg, min, max }`, the maps keyed by field name in name order.
pub fn aggregate_result_to_json(result: &AggregateResult) -> Value {
    json!({
        "count": result.count,
        "sum": field_map_to_json(&result.sum, Value::from),
        "avg": field_map_to_json(&result.avg, optional_number),
        "min": field_map_to_json(&result.min, optional_number),
        "max": field_map_to_json(&result.max, optional_number),
    })
}

/// `[{ key, result }]`, one entry per distinct value of the grouped field, in value order.
pub fn grouped_aggregate_result_to_json(result: &GroupedAggregateResult) -> Value {
    Value::Array(
        result
            .0
            .iter()
            .map(|(key, group)| json!({ "key": key, "result": aggregate_result_to_json(group) }))
            .collect(),
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_document_names_fields_and_a_group() {
        let spec = parse_aggregate(&json!({ "count": true, "sum": ["bytes"], "groupBy": "ns" })).unwrap();

        assert!(spec.count);
        assert_eq!(spec.sum, vec!["bytes".to_string()]);
        assert_eq!(spec.group_by.as_deref(), Some("ns"));
        assert_eq!(parse_aggregate(&Value::Null).unwrap(), AggregateSpec::default());
    }

    #[test]
    fn a_malformed_document_is_refused_by_name() {
        for bad in [
            json!(3),
            json!({ "sums": [] }),
            json!({ "sum": "bytes" }),
            json!({ "sum": [1] }),
            json!({ "groupBy": 1 }),
            json!({ "count": "yes" }),
        ] {
            assert!(parse_aggregate(&bad).is_err(), "{bad}");
        }
    }
}