1use hayson_engine::query::{ComparisonOp, PredicateNode, QuerySpec, SearchMode, SortDirection};
2use serde_json::Value;
3
4const ENVELOPE_MEMBERS: [&str; 6] = ["predicate", "search", "orderBy", "limit", "offset", "countOnly"];
8
9fn 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
119pub 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 #[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 #[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}