use hayson_engine::query::{ComparisonOp, PredicateNode, QuerySpec, SearchMode, SortDirection};
use serde_json::Value;
const ENVELOPE_MEMBERS: [&str; 6] = ["predicate", "search", "orderBy", "limit", "offset", "countOnly"];
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"))
}
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);
}
#[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"
);
}
#[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());
}
}