Skip to main content

hayson_binding_shared/
predicate.rs

1use hayson_engine::query::{ComparisonOp, PredicateNode, QuerySpec, SearchMode, SortDirection};
2use serde_json::Value;
3
4/// The members that make a document a whole query rather than a bare predicate node. None of them
5/// can appear on a leaf (`field`/`op`) or on a group (`combine`/`nodes`/`and`/`or`), so the two
6/// shapes stay tellable apart and the older node-only document keeps working unchanged.
7const ENVELOPE_MEMBERS: [&str; 6] = ["predicate", "search", "orderBy", "limit", "offset", "countOnly"];
8
9/// Accepts the operator vocabulary the TS `ComparisonOp` union already publishes (`=`, `!=`, `>`,
10/// …), plus the word aliases `hayson-server`'s own `predicate_json` uses, so a caller that already
11/// speaks to the gRPC server can send the same document here.
12fn parse_op(op: &str) -> Result<ComparisonOp, String> {
13    match op {
14        "=" | "eq" => Ok(ComparisonOp::Eq),
15        "!=" | "ne" => Ok(ComparisonOp::Ne),
16        ">" | "gt" => Ok(ComparisonOp::Gt),
17        ">=" | "gte" => Ok(ComparisonOp::Gte),
18        "<" | "lt" => Ok(ComparisonOp::Lt),
19        "<=" | "lte" => Ok(ComparisonOp::Lte),
20        "in" => Ok(ComparisonOp::In),
21        "contains" => Ok(ComparisonOp::Contains),
22        "startsWith" => Ok(ComparisonOp::StartsWith),
23        other => Err(format!("unknown predicate operator \"{other}\"")),
24    }
25}
26
27fn parse_node_list(value: &Value) -> Result<Vec<PredicateNode>, String> {
28    let items = value
29        .as_array()
30        .ok_or_else(|| "a predicate group's nodes must be a JSON array".to_string())?;
31    items.iter().map(parse_node).collect()
32}
33
34fn parse_node(value: &Value) -> Result<PredicateNode, String> {
35    let object = value
36        .as_object()
37        .ok_or_else(|| "a predicate node must be a JSON object".to_string())?;
38
39    if let Some(combine) = object.get("combine").and_then(Value::as_str) {
40        let nodes = match object.get("nodes") {
41            Some(nodes) => parse_node_list(nodes)?,
42            None => Vec::new(),
43        };
44        return match combine {
45            "and" => Ok(PredicateNode::and(nodes)),
46            "or" => Ok(PredicateNode::or(nodes)),
47            other => Err(format!(
48                "a predicate group combines with \"and\" or \"or\", not \"{other}\""
49            )),
50        };
51    }
52    if let Some(nodes) = object.get("and") {
53        return Ok(PredicateNode::and(parse_node_list(nodes)?));
54    }
55    if let Some(nodes) = object.get("or") {
56        return Ok(PredicateNode::or(parse_node_list(nodes)?));
57    }
58
59    let field = object
60        .get("field")
61        .and_then(Value::as_str)
62        .ok_or_else(|| "a predicate leaf needs a string \"field\"".to_string())?;
63    let op = object
64        .get("op")
65        .and_then(Value::as_str)
66        .ok_or_else(|| "a predicate leaf needs a string \"op\"".to_string())?;
67    Ok(PredicateNode::leaf(
68        field,
69        parse_op(op)?,
70        object.get("value").cloned().unwrap_or(Value::Null),
71    ))
72}
73
74fn parse_search(value: &Value) -> Result<(String, SearchMode), String> {
75    let object = value
76        .as_object()
77        .ok_or_else(|| "\"search\" must be a JSON object".to_string())?;
78    let text = object
79        .get("text")
80        .and_then(Value::as_str)
81        .ok_or_else(|| "\"search\" needs a string \"text\"".to_string())?;
82    let mode = match object.get("mode").and_then(Value::as_str) {
83        None | Some("all") => SearchMode::All,
84        Some("any") => SearchMode::Any,
85        Some(other) => return Err(format!("a search matches in mode \"all\" or \"any\", not \"{other}\"")),
86    };
87    Ok((text.to_string(), mode))
88}
89
90fn parse_order_by(value: &Value) -> Result<(String, SortDirection), String> {
91    let object = value
92        .as_object()
93        .ok_or_else(|| "\"orderBy\" must be a JSON object".to_string())?;
94    let field = object
95        .get("field")
96        .and_then(Value::as_str)
97        .ok_or_else(|| "\"orderBy\" needs a string \"field\"".to_string())?;
98    let direction = match object.get("direction").and_then(Value::as_str) {
99        None | Some("asc") => SortDirection::Asc,
100        Some("desc") => SortDirection::Desc,
101        Some(other) => return Err(format!("a sort runs \"asc\" or \"desc\", not \"{other}\"")),
102    };
103    Ok((field.to_string(), direction))
104}
105
106fn parse_count(value: &Value, member: &str) -> Result<usize, String> {
107    let count = value
108        .as_u64()
109        .ok_or_else(|| format!("\"{member}\" must be a whole number of records"))?;
110    usize::try_from(count).map_err(|_| format!("\"{member}\" is larger than this platform can count"))
111}
112
113fn parse_flag(value: &Value, member: &str) -> Result<bool, String> {
114    value
115        .as_bool()
116        .ok_or_else(|| format!("\"{member}\" must be true or false"))
117}
118
119/// Builds the spec `Table::query` takes, from either shape the bindings accept: a bare predicate
120/// node, or a whole-query envelope `{ predicate, search, orderBy, limit, offset, countOnly }`
121/// carrying the rest of what the TS `QueryBuilder` publishes. `null` and an empty document both
122/// mean "every record", which is how a `QueryBuilder` with no `where` reaches the engine.
123pub fn parse_predicate(value: &Value) -> Result<QuerySpec, String> {
124    if value.is_null() {
125        return Ok(QuerySpec::new(PredicateNode::and(Vec::new())));
126    }
127    let Some(object) = value.as_object() else {
128        return Ok(QuerySpec::new(parse_node(value)?));
129    };
130    if object.is_empty() {
131        return Ok(QuerySpec::new(PredicateNode::and(Vec::new())));
132    }
133    if !ENVELOPE_MEMBERS.iter().any(|member| object.contains_key(*member)) {
134        return Ok(QuerySpec::new(parse_node(value)?));
135    }
136
137    let root = match object.get("predicate") {
138        None | Some(Value::Null) => PredicateNode::and(Vec::new()),
139        Some(node) if node.as_object().is_some_and(serde_json::Map::is_empty) => PredicateNode::and(Vec::new()),
140        Some(node) => parse_node(node)?,
141    };
142    let mut spec = QuerySpec::new(root);
143    if let Some(search) = object.get("search").filter(|search| !search.is_null()) {
144        let (text, mode) = parse_search(search)?;
145        spec = spec.with_search(text, mode);
146    }
147    if let Some(order) = object.get("orderBy").filter(|order| !order.is_null()) {
148        let (field, direction) = parse_order_by(order)?;
149        spec = spec.with_order_by(field, direction);
150    }
151    if let Some(limit) = object.get("limit").filter(|limit| !limit.is_null()) {
152        spec = spec.with_limit(parse_count(limit, "limit")?);
153    }
154    if let Some(offset) = object.get("offset").filter(|offset| !offset.is_null()) {
155        spec = spec.with_offset(parse_count(offset, "offset")?);
156    }
157    if let Some(counting) = object.get("countOnly").filter(|counting| !counting.is_null()) {
158        spec = spec.counting(parse_flag(counting, "countOnly")?);
159    }
160    Ok(spec)
161}
162
163#[cfg(test)]
164mod tests {
165    use hayson_engine::query::{Combine, OrderBy, Predicate};
166    use serde_json::json;
167
168    use super::*;
169
170    #[test]
171    fn an_absent_or_empty_predicate_matches_every_record() {
172        assert_eq!(
173            parse_predicate(&Value::Null).unwrap().root,
174            PredicateNode::and(Vec::new())
175        );
176        assert_eq!(
177            parse_predicate(&json!({})).unwrap().root,
178            PredicateNode::and(Vec::new())
179        );
180    }
181
182    #[test]
183    fn a_leaf_using_the_ts_operator_vocabulary_parses_to_the_engine_op() {
184        let spec = parse_predicate(&json!({ "field": "age", "op": ">=", "value": 18 })).unwrap();
185
186        assert_eq!(
187            spec.root,
188            PredicateNode::Leaf(Predicate::new("age", ComparisonOp::Gte, json!(18)))
189        );
190    }
191
192    #[test]
193    fn the_word_aliases_hayson_server_uses_parse_to_the_same_ops() {
194        for (symbol, word) in [
195            ("=", "eq"),
196            ("!=", "ne"),
197            (">", "gt"),
198            (">=", "gte"),
199            ("<", "lt"),
200            ("<=", "lte"),
201        ] {
202            assert_eq!(
203                parse_op(symbol).unwrap(),
204                parse_op(word).unwrap(),
205                "{symbol} and {word} disagree"
206            );
207        }
208    }
209
210    #[test]
211    fn a_ts_shaped_group_nests_and_keeps_its_combine() {
212        let spec = parse_predicate(&json!({
213            "combine": "or",
214            "nodes": [
215                { "field": "role", "op": "=", "value": "admin" },
216                { "combine": "and", "nodes": [{ "field": "age", "op": ">", "value": 30 }] },
217            ],
218        }))
219        .unwrap();
220
221        let PredicateNode::Group { combine, nodes } = spec.root else {
222            panic!("expected a group")
223        };
224        assert_eq!(combine, Combine::Or);
225        assert_eq!(nodes.len(), 2);
226        assert!(matches!(
227            nodes[1],
228            PredicateNode::Group {
229                combine: Combine::And,
230                ..
231            }
232        ));
233    }
234
235    #[test]
236    fn the_server_shaped_and_or_form_parses_to_the_same_tree_as_the_ts_form() {
237        let server = parse_predicate(&json!({ "and": [{ "field": "a", "op": "=", "value": 1 }] })).unwrap();
238        let ts = parse_predicate(&json!({
239            "combine": "and",
240            "nodes": [{ "field": "a", "op": "=", "value": 1 }],
241        }))
242        .unwrap();
243
244        assert_eq!(server.root, ts.root);
245    }
246
247    #[test]
248    fn an_envelope_carries_the_search_the_order_and_the_page_alongside_the_predicate() {
249        let spec = parse_predicate(&json!({
250            "predicate": { "field": "role", "op": "=", "value": "admin" },
251            "search": { "text": "backend", "mode": "any" },
252            "orderBy": { "field": "age", "direction": "desc" },
253            "limit": 5,
254            "offset": 10,
255        }))
256        .unwrap();
257
258        assert_eq!(
259            spec.root,
260            PredicateNode::Leaf(Predicate::new("role", ComparisonOp::Eq, json!("admin")))
261        );
262        let search = spec.search.expect("the search survived the boundary");
263        assert_eq!(search.text, "backend");
264        assert_eq!(search.mode, SearchMode::Any);
265        assert_eq!(
266            spec.order_by,
267            Some(OrderBy {
268                field: "age".into(),
269                direction: SortDirection::Desc
270            })
271        );
272        assert_eq!(spec.limit, Some(5));
273        assert_eq!(spec.offset, Some(10));
274    }
275
276    #[test]
277    fn an_envelope_defaults_the_search_mode_to_all_and_the_sort_to_ascending() {
278        let spec = parse_predicate(&json!({
279            "search": { "text": "backend" },
280            "orderBy": { "field": "age" },
281        }))
282        .unwrap();
283
284        assert_eq!(spec.search.expect("a search").mode, SearchMode::All);
285        assert_eq!(spec.order_by.expect("an order").direction, SortDirection::Asc);
286    }
287
288    #[test]
289    fn an_envelope_without_a_predicate_matches_every_record_and_still_pages() {
290        let spec = parse_predicate(&json!({ "limit": 2 })).unwrap();
291
292        assert_eq!(spec.root, PredicateNode::and(Vec::new()));
293        assert_eq!(spec.limit, Some(2));
294        assert_eq!(spec.offset, None);
295    }
296
297    /// A bare node has none of the envelope's members, so the document the bindings sent before
298    /// this envelope existed still parses to exactly the same spec.
299    #[test]
300    fn a_bare_predicate_node_is_still_read_as_the_whole_query() {
301        let bare = parse_predicate(&json!({ "field": "age", "op": ">=", "value": 18 })).unwrap();
302        let wrapped = parse_predicate(&json!({ "predicate": { "field": "age", "op": ">=", "value": 18 } })).unwrap();
303
304        assert_eq!(bare, wrapped);
305        assert_eq!(bare.limit, None);
306        assert_eq!(bare.order_by, None);
307    }
308
309    #[test]
310    fn a_page_of_zero_records_is_a_page_and_not_an_absent_limit() {
311        let spec = parse_predicate(&json!({ "limit": 0, "offset": 0 })).unwrap();
312
313        assert_eq!(spec.limit, Some(0));
314        assert_eq!(spec.offset, Some(0));
315    }
316
317    #[test]
318    fn an_envelope_carries_the_count_only_flag_the_ts_count_and_explain_set() {
319        let admin = json!({ "field": "role", "op": "=", "value": "admin" });
320        let counting = parse_predicate(&json!({ "predicate": admin, "countOnly": true })).unwrap();
321        let paging = parse_predicate(&json!({ "predicate": admin })).unwrap();
322
323        assert!(counting.count_only);
324        assert!(
325            !paging.count_only,
326            "a document that says nothing about counting asks for its page"
327        );
328    }
329
330    /// `countOnly` alone makes a document an envelope, so `.count()` on a builder with no `where`
331    /// still reaches the engine as "count every record" rather than as a malformed predicate leaf.
332    #[test]
333    fn count_only_on_its_own_is_an_envelope_matching_every_record() {
334        let spec = parse_predicate(&json!({ "countOnly": true })).unwrap();
335
336        assert_eq!(spec.root, PredicateNode::and(Vec::new()));
337        assert!(spec.count_only);
338    }
339
340    #[test]
341    fn a_count_only_that_is_not_a_flag_is_refused() {
342        assert!(parse_predicate(&json!({ "countOnly": "yes" })).is_err());
343        assert!(parse_predicate(&json!({ "countOnly": 1 })).is_err());
344    }
345
346    #[test]
347    fn an_unknown_sort_direction_search_mode_or_negative_page_is_refused() {
348        assert!(parse_predicate(&json!({ "orderBy": { "field": "age", "direction": "sideways" } })).is_err());
349        assert!(parse_predicate(&json!({ "orderBy": { "direction": "asc" } })).is_err());
350        assert!(parse_predicate(&json!({ "search": { "text": "x", "mode": "some" } })).is_err());
351        assert!(parse_predicate(&json!({ "search": { "mode": "all" } })).is_err());
352        assert!(parse_predicate(&json!({ "limit": -1 })).is_err());
353        assert!(parse_predicate(&json!({ "offset": "2" })).is_err());
354    }
355
356    #[test]
357    fn an_unknown_operator_and_a_leaf_without_a_field_are_both_refused() {
358        assert!(parse_predicate(&json!({ "field": "a", "op": "~=", "value": 1 })).is_err());
359        assert!(parse_predicate(&json!({ "op": "=", "value": 1 })).is_err());
360        assert!(parse_predicate(&json!([1, 2])).is_err());
361    }
362}