hayson-binding-shared 0.21.0

Shared error mapping, JSON shapes, options and predicate parsing for hayson's NAPI and WASM language bindings
Documentation
use hayson_engine::query::{ComparisonOp, PredicateNode, QuerySpec, SearchMode, SortDirection};
use serde_json::Value;

/// The members that make a document a whole query rather than a bare predicate node. None of them
/// can appear on a leaf (`field`/`op`) or on a group (`combine`/`nodes`/`and`/`or`), so the two
/// shapes stay tellable apart and the older node-only document keeps working unchanged.
const ENVELOPE_MEMBERS: [&str; 6] = ["predicate", "search", "orderBy", "limit", "offset", "countOnly"];

/// Accepts the operator vocabulary the TS `ComparisonOp` union already publishes (`=`, `!=`, `>`,
/// …), plus the word aliases `hayson-server`'s own `predicate_json` uses, so a caller that already
/// speaks to the gRPC server can send the same document here.
fn parse_op(op: &str) -> Result<ComparisonOp, String> {
    match op {
        "=" | "eq" => Ok(ComparisonOp::Eq),
        "!=" | "ne" => Ok(ComparisonOp::Ne),
        ">" | "gt" => Ok(ComparisonOp::Gt),
        ">=" | "gte" => Ok(ComparisonOp::Gte),
        "<" | "lt" => Ok(ComparisonOp::Lt),
        "<=" | "lte" => Ok(ComparisonOp::Lte),
        "in" => Ok(ComparisonOp::In),
        "contains" => Ok(ComparisonOp::Contains),
        "startsWith" => Ok(ComparisonOp::StartsWith),
        other => Err(format!("unknown predicate operator \"{other}\"")),
    }
}

fn parse_node_list(value: &Value) -> Result<Vec<PredicateNode>, String> {
    let items = value
        .as_array()
        .ok_or_else(|| "a predicate group's nodes must be a JSON array".to_string())?;
    items.iter().map(parse_node).collect()
}

fn parse_node(value: &Value) -> Result<PredicateNode, String> {
    let object = value
        .as_object()
        .ok_or_else(|| "a predicate node must be a JSON object".to_string())?;

    if let Some(combine) = object.get("combine").and_then(Value::as_str) {
        let nodes = match object.get("nodes") {
            Some(nodes) => parse_node_list(nodes)?,
            None => Vec::new(),
        };
        return match combine {
            "and" => Ok(PredicateNode::and(nodes)),
            "or" => Ok(PredicateNode::or(nodes)),
            other => Err(format!(
                "a predicate group combines with \"and\" or \"or\", not \"{other}\""
            )),
        };
    }
    if let Some(nodes) = object.get("and") {
        return Ok(PredicateNode::and(parse_node_list(nodes)?));
    }
    if let Some(nodes) = object.get("or") {
        return Ok(PredicateNode::or(parse_node_list(nodes)?));
    }

    let field = object
        .get("field")
        .and_then(Value::as_str)
        .ok_or_else(|| "a predicate leaf needs a string \"field\"".to_string())?;
    let op = object
        .get("op")
        .and_then(Value::as_str)
        .ok_or_else(|| "a predicate leaf needs a string \"op\"".to_string())?;
    Ok(PredicateNode::leaf(
        field,
        parse_op(op)?,
        object.get("value").cloned().unwrap_or(Value::Null),
    ))
}

fn parse_search(value: &Value) -> Result<(String, SearchMode), String> {
    let object = value
        .as_object()
        .ok_or_else(|| "\"search\" must be a JSON object".to_string())?;
    let text = object
        .get("text")
        .and_then(Value::as_str)
        .ok_or_else(|| "\"search\" needs a string \"text\"".to_string())?;
    let mode = match object.get("mode").and_then(Value::as_str) {
        None | Some("all") => SearchMode::All,
        Some("any") => SearchMode::Any,
        Some(other) => return Err(format!("a search matches in mode \"all\" or \"any\", not \"{other}\"")),
    };
    Ok((text.to_string(), mode))
}

fn parse_order_by(value: &Value) -> Result<(String, SortDirection), String> {
    let object = value
        .as_object()
        .ok_or_else(|| "\"orderBy\" must be a JSON object".to_string())?;
    let field = object
        .get("field")
        .and_then(Value::as_str)
        .ok_or_else(|| "\"orderBy\" needs a string \"field\"".to_string())?;
    let direction = match object.get("direction").and_then(Value::as_str) {
        None | Some("asc") => SortDirection::Asc,
        Some("desc") => SortDirection::Desc,
        Some(other) => return Err(format!("a sort runs \"asc\" or \"desc\", not \"{other}\"")),
    };
    Ok((field.to_string(), direction))
}

fn parse_count(value: &Value, member: &str) -> Result<usize, String> {
    let count = value
        .as_u64()
        .ok_or_else(|| format!("\"{member}\" must be a whole number of records"))?;
    usize::try_from(count).map_err(|_| format!("\"{member}\" is larger than this platform can count"))
}

fn parse_flag(value: &Value, member: &str) -> Result<bool, String> {
    value
        .as_bool()
        .ok_or_else(|| format!("\"{member}\" must be true or false"))
}

/// Builds the spec `Table::query` takes, from either shape the bindings accept: a bare predicate
/// node, or a whole-query envelope `{ predicate, search, orderBy, limit, offset, countOnly }`
/// carrying the rest of what the TS `QueryBuilder` publishes. `null` and an empty document both
/// mean "every record", which is how a `QueryBuilder` with no `where` reaches the engine.
pub fn parse_predicate(value: &Value) -> Result<QuerySpec, String> {
    if value.is_null() {
        return Ok(QuerySpec::new(PredicateNode::and(Vec::new())));
    }
    let Some(object) = value.as_object() else {
        return Ok(QuerySpec::new(parse_node(value)?));
    };
    if object.is_empty() {
        return Ok(QuerySpec::new(PredicateNode::and(Vec::new())));
    }
    if !ENVELOPE_MEMBERS.iter().any(|member| object.contains_key(*member)) {
        return Ok(QuerySpec::new(parse_node(value)?));
    }

    let root = match object.get("predicate") {
        None | Some(Value::Null) => PredicateNode::and(Vec::new()),
        Some(node) if node.as_object().is_some_and(serde_json::Map::is_empty) => PredicateNode::and(Vec::new()),
        Some(node) => parse_node(node)?,
    };
    let mut spec = QuerySpec::new(root);
    if let Some(search) = object.get("search").filter(|search| !search.is_null()) {
        let (text, mode) = parse_search(search)?;
        spec = spec.with_search(text, mode);
    }
    if let Some(order) = object.get("orderBy").filter(|order| !order.is_null()) {
        let (field, direction) = parse_order_by(order)?;
        spec = spec.with_order_by(field, direction);
    }
    if let Some(limit) = object.get("limit").filter(|limit| !limit.is_null()) {
        spec = spec.with_limit(parse_count(limit, "limit")?);
    }
    if let Some(offset) = object.get("offset").filter(|offset| !offset.is_null()) {
        spec = spec.with_offset(parse_count(offset, "offset")?);
    }
    if let Some(counting) = object.get("countOnly").filter(|counting| !counting.is_null()) {
        spec = spec.counting(parse_flag(counting, "countOnly")?);
    }
    Ok(spec)
}

#[cfg(test)]
mod tests {
    use hayson_engine::query::{Combine, OrderBy, Predicate};
    use serde_json::json;

    use super::*;

    #[test]
    fn an_absent_or_empty_predicate_matches_every_record() {
        assert_eq!(
            parse_predicate(&Value::Null).unwrap().root,
            PredicateNode::and(Vec::new())
        );
        assert_eq!(
            parse_predicate(&json!({})).unwrap().root,
            PredicateNode::and(Vec::new())
        );
    }

    #[test]
    fn a_leaf_using_the_ts_operator_vocabulary_parses_to_the_engine_op() {
        let spec = parse_predicate(&json!({ "field": "age", "op": ">=", "value": 18 })).unwrap();

        assert_eq!(
            spec.root,
            PredicateNode::Leaf(Predicate::new("age", ComparisonOp::Gte, json!(18)))
        );
    }

    #[test]
    fn the_word_aliases_hayson_server_uses_parse_to_the_same_ops() {
        for (symbol, word) in [
            ("=", "eq"),
            ("!=", "ne"),
            (">", "gt"),
            (">=", "gte"),
            ("<", "lt"),
            ("<=", "lte"),
        ] {
            assert_eq!(
                parse_op(symbol).unwrap(),
                parse_op(word).unwrap(),
                "{symbol} and {word} disagree"
            );
        }
    }

    #[test]
    fn a_ts_shaped_group_nests_and_keeps_its_combine() {
        let spec = parse_predicate(&json!({
            "combine": "or",
            "nodes": [
                { "field": "role", "op": "=", "value": "admin" },
                { "combine": "and", "nodes": [{ "field": "age", "op": ">", "value": 30 }] },
            ],
        }))
        .unwrap();

        let PredicateNode::Group { combine, nodes } = spec.root else {
            panic!("expected a group")
        };
        assert_eq!(combine, Combine::Or);
        assert_eq!(nodes.len(), 2);
        assert!(matches!(
            nodes[1],
            PredicateNode::Group {
                combine: Combine::And,
                ..
            }
        ));
    }

    #[test]
    fn the_server_shaped_and_or_form_parses_to_the_same_tree_as_the_ts_form() {
        let server = parse_predicate(&json!({ "and": [{ "field": "a", "op": "=", "value": 1 }] })).unwrap();
        let ts = parse_predicate(&json!({
            "combine": "and",
            "nodes": [{ "field": "a", "op": "=", "value": 1 }],
        }))
        .unwrap();

        assert_eq!(server.root, ts.root);
    }

    #[test]
    fn an_envelope_carries_the_search_the_order_and_the_page_alongside_the_predicate() {
        let spec = parse_predicate(&json!({
            "predicate": { "field": "role", "op": "=", "value": "admin" },
            "search": { "text": "backend", "mode": "any" },
            "orderBy": { "field": "age", "direction": "desc" },
            "limit": 5,
            "offset": 10,
        }))
        .unwrap();

        assert_eq!(
            spec.root,
            PredicateNode::Leaf(Predicate::new("role", ComparisonOp::Eq, json!("admin")))
        );
        let search = spec.search.expect("the search survived the boundary");
        assert_eq!(search.text, "backend");
        assert_eq!(search.mode, SearchMode::Any);
        assert_eq!(
            spec.order_by,
            Some(OrderBy {
                field: "age".into(),
                direction: SortDirection::Desc
            })
        );
        assert_eq!(spec.limit, Some(5));
        assert_eq!(spec.offset, Some(10));
    }

    #[test]
    fn an_envelope_defaults_the_search_mode_to_all_and_the_sort_to_ascending() {
        let spec = parse_predicate(&json!({
            "search": { "text": "backend" },
            "orderBy": { "field": "age" },
        }))
        .unwrap();

        assert_eq!(spec.search.expect("a search").mode, SearchMode::All);
        assert_eq!(spec.order_by.expect("an order").direction, SortDirection::Asc);
    }

    #[test]
    fn an_envelope_without_a_predicate_matches_every_record_and_still_pages() {
        let spec = parse_predicate(&json!({ "limit": 2 })).unwrap();

        assert_eq!(spec.root, PredicateNode::and(Vec::new()));
        assert_eq!(spec.limit, Some(2));
        assert_eq!(spec.offset, None);
    }

    /// A bare node has none of the envelope's members, so the document the bindings sent before
    /// this envelope existed still parses to exactly the same spec.
    #[test]
    fn a_bare_predicate_node_is_still_read_as_the_whole_query() {
        let bare = parse_predicate(&json!({ "field": "age", "op": ">=", "value": 18 })).unwrap();
        let wrapped = parse_predicate(&json!({ "predicate": { "field": "age", "op": ">=", "value": 18 } })).unwrap();

        assert_eq!(bare, wrapped);
        assert_eq!(bare.limit, None);
        assert_eq!(bare.order_by, None);
    }

    #[test]
    fn a_page_of_zero_records_is_a_page_and_not_an_absent_limit() {
        let spec = parse_predicate(&json!({ "limit": 0, "offset": 0 })).unwrap();

        assert_eq!(spec.limit, Some(0));
        assert_eq!(spec.offset, Some(0));
    }

    #[test]
    fn an_envelope_carries_the_count_only_flag_the_ts_count_and_explain_set() {
        let admin = json!({ "field": "role", "op": "=", "value": "admin" });
        let counting = parse_predicate(&json!({ "predicate": admin, "countOnly": true })).unwrap();
        let paging = parse_predicate(&json!({ "predicate": admin })).unwrap();

        assert!(counting.count_only);
        assert!(
            !paging.count_only,
            "a document that says nothing about counting asks for its page"
        );
    }

    /// `countOnly` alone makes a document an envelope, so `.count()` on a builder with no `where`
    /// still reaches the engine as "count every record" rather than as a malformed predicate leaf.
    #[test]
    fn count_only_on_its_own_is_an_envelope_matching_every_record() {
        let spec = parse_predicate(&json!({ "countOnly": true })).unwrap();

        assert_eq!(spec.root, PredicateNode::and(Vec::new()));
        assert!(spec.count_only);
    }

    #[test]
    fn a_count_only_that_is_not_a_flag_is_refused() {
        assert!(parse_predicate(&json!({ "countOnly": "yes" })).is_err());
        assert!(parse_predicate(&json!({ "countOnly": 1 })).is_err());
    }

    #[test]
    fn an_unknown_sort_direction_search_mode_or_negative_page_is_refused() {
        assert!(parse_predicate(&json!({ "orderBy": { "field": "age", "direction": "sideways" } })).is_err());
        assert!(parse_predicate(&json!({ "orderBy": { "direction": "asc" } })).is_err());
        assert!(parse_predicate(&json!({ "search": { "text": "x", "mode": "some" } })).is_err());
        assert!(parse_predicate(&json!({ "search": { "mode": "all" } })).is_err());
        assert!(parse_predicate(&json!({ "limit": -1 })).is_err());
        assert!(parse_predicate(&json!({ "offset": "2" })).is_err());
    }

    #[test]
    fn an_unknown_operator_and_a_leaf_without_a_field_are_both_refused() {
        assert!(parse_predicate(&json!({ "field": "a", "op": "~=", "value": 1 })).is_err());
        assert!(parse_predicate(&json!({ "op": "=", "value": 1 })).is_err());
        assert!(parse_predicate(&json!([1, 2])).is_err());
    }
}