Skip to main content

nautilus_core/
protocol_json.rs

1//! Convert typed Rust query arguments into the JSON shape consumed by the engine.
2
3use std::collections::HashMap;
4
5use serde_json::{Map as JsonMap, Value as JsonValue};
6
7use crate::expr::RelationFilterOp;
8use crate::{BinaryOp, Error, Expr, FindManyArgs, IncludeRelation, OrderBy, OrderDir, Result};
9
10/// Convert [`FindManyArgs`] into the same JSON payload shape used by thin clients.
11///
12/// This helper is intentionally conservative: if it encounters an expression
13/// that cannot yet be represented in the engine wire format, it returns
14/// [`Error::InvalidQuery`] so callers can decide whether to fail or to fall
15/// back to a local execution path.
16pub fn find_many_args_to_protocol_json(args: &FindManyArgs) -> Result<JsonValue> {
17    let mut result = JsonMap::new();
18
19    if let Some(where_) = &args.where_ {
20        result.insert("where".to_string(), expr_to_filter_json(where_)?);
21    }
22
23    if !args.order_by.is_empty() {
24        result.insert(
25            "orderBy".to_string(),
26            JsonValue::Array(
27                args.order_by
28                    .iter()
29                    .map(order_by_to_json)
30                    .collect::<Result<Vec<_>>>()?,
31            ),
32        );
33    }
34
35    if let Some(take) = args.take {
36        result.insert("take".to_string(), JsonValue::from(take));
37    }
38
39    if let Some(skip) = args.skip {
40        result.insert("skip".to_string(), JsonValue::from(skip));
41    }
42
43    if !args.include.is_empty() {
44        result.insert("include".to_string(), include_map_to_json(&args.include)?);
45    }
46
47    if !args.select.is_empty() {
48        let mut select = JsonMap::new();
49        for (field, enabled) in &args.select {
50            select.insert(field.clone(), JsonValue::Bool(*enabled));
51        }
52        result.insert("select".to_string(), JsonValue::Object(select));
53    }
54
55    if let Some(cursor) = &args.cursor {
56        let mut wire_cursor = JsonMap::new();
57        for (field, value) in cursor {
58            wire_cursor.insert(strip_column_qualifier(field), value.to_json_plain());
59        }
60        result.insert("cursor".to_string(), JsonValue::Object(wire_cursor));
61    }
62
63    if !args.distinct.is_empty() {
64        result.insert(
65            "distinct".to_string(),
66            JsonValue::Array(
67                args.distinct
68                    .iter()
69                    .map(|field| JsonValue::String(strip_column_qualifier(field)))
70                    .collect(),
71            ),
72        );
73    }
74
75    Ok(JsonValue::Object(result))
76}
77
78/// Convert a single Rust filter expression into the engine wire-format `"where"` object.
79pub fn where_expr_to_protocol_json(expr: &Expr) -> Result<JsonValue> {
80    expr_to_filter_json(expr)
81}
82
83fn include_map_to_json(include: &HashMap<String, IncludeRelation>) -> Result<JsonValue> {
84    let mut result = JsonMap::new();
85    for (field, relation) in include {
86        result.insert(field.clone(), include_relation_to_json(relation)?);
87    }
88    Ok(JsonValue::Object(result))
89}
90
91fn include_relation_to_json(include: &IncludeRelation) -> Result<JsonValue> {
92    let mut result = JsonMap::new();
93
94    if let Some(where_) = &include.where_ {
95        result.insert("where".to_string(), expr_to_filter_json(where_)?);
96    }
97
98    if !include.order_by.is_empty() {
99        result.insert(
100            "orderBy".to_string(),
101            JsonValue::Array(
102                include
103                    .order_by
104                    .iter()
105                    .map(order_by_to_json)
106                    .collect::<Result<Vec<_>>>()?,
107            ),
108        );
109    }
110
111    if let Some(take) = include.take {
112        result.insert("take".to_string(), JsonValue::from(take));
113    }
114
115    if let Some(skip) = include.skip {
116        result.insert("skip".to_string(), JsonValue::from(skip));
117    }
118
119    if let Some(cursor) = &include.cursor {
120        let mut wire_cursor = JsonMap::new();
121        for (field, value) in cursor {
122            wire_cursor.insert(strip_column_qualifier(field), value.to_json_plain());
123        }
124        result.insert("cursor".to_string(), JsonValue::Object(wire_cursor));
125    }
126
127    if !include.distinct.is_empty() {
128        result.insert(
129            "distinct".to_string(),
130            JsonValue::Array(
131                include
132                    .distinct
133                    .iter()
134                    .map(|field| JsonValue::String(strip_column_qualifier(field)))
135                    .collect(),
136            ),
137        );
138    }
139
140    if !include.include.is_empty() {
141        result.insert(
142            "include".to_string(),
143            include_map_to_json(&include.include)?,
144        );
145    }
146
147    Ok(JsonValue::Object(result))
148}
149
150fn order_by_to_json(order: &OrderBy) -> Result<JsonValue> {
151    let mut result = JsonMap::new();
152    result.insert(
153        strip_column_qualifier(&order.column),
154        JsonValue::String(match order.direction {
155            OrderDir::Asc => "asc".to_string(),
156            OrderDir::Desc => "desc".to_string(),
157        }),
158    );
159    Ok(JsonValue::Object(result))
160}
161
162fn expr_to_filter_json(expr: &Expr) -> Result<JsonValue> {
163    match expr {
164        Expr::Binary {
165            left,
166            op: BinaryOp::And,
167            right,
168        } => logical_expr_to_json("AND", left, right),
169        Expr::Binary {
170            left,
171            op: BinaryOp::Or,
172            right,
173        } => logical_expr_to_json("OR", left, right),
174        Expr::Not(inner) => {
175            let mut result = JsonMap::new();
176            result.insert("NOT".to_string(), expr_to_filter_json(inner)?);
177            Ok(JsonValue::Object(result))
178        }
179        Expr::Relation { op, relation } => {
180            relation_predicate_to_json(&relation.field, *op, relation.filter.as_ref())
181        }
182        Expr::Binary { left, op, right } => field_predicate_to_json(left, op, right),
183        Expr::IsNull(inner) => null_predicate_to_json(inner, true),
184        Expr::IsNotNull(inner) => null_predicate_to_json(inner, false),
185        other => Err(Error::InvalidQuery(format!(
186            "query cannot be serialized to engine JSON: unsupported expression {:?}",
187            other
188        ))),
189    }
190}
191
192fn relation_predicate_to_json(
193    field: &str,
194    op: RelationFilterOp,
195    filter: &Expr,
196) -> Result<JsonValue> {
197    let mut relation_spec = JsonMap::new();
198    relation_spec.insert(
199        match op {
200            RelationFilterOp::Some => "some".to_string(),
201            RelationFilterOp::None => "none".to_string(),
202            RelationFilterOp::Every => "every".to_string(),
203        },
204        expr_to_filter_json(filter)?,
205    );
206
207    let mut result = JsonMap::new();
208    result.insert(field.to_string(), JsonValue::Object(relation_spec));
209    Ok(JsonValue::Object(result))
210}
211
212fn logical_expr_to_json(name: &str, left: &Expr, right: &Expr) -> Result<JsonValue> {
213    let mut items = Vec::new();
214    collect_logical_operands(name, left, &mut items)?;
215    collect_logical_operands(name, right, &mut items)?;
216
217    let mut result = JsonMap::new();
218    result.insert(name.to_string(), JsonValue::Array(items));
219    Ok(JsonValue::Object(result))
220}
221
222fn collect_logical_operands(name: &str, expr: &Expr, out: &mut Vec<JsonValue>) -> Result<()> {
223    match (name, expr) {
224        (
225            "AND",
226            Expr::Binary {
227                left,
228                op: BinaryOp::And,
229                right,
230            },
231        ) => {
232            collect_logical_operands(name, left, out)?;
233            collect_logical_operands(name, right, out)?;
234            Ok(())
235        }
236        (
237            "OR",
238            Expr::Binary {
239                left,
240                op: BinaryOp::Or,
241                right,
242            },
243        ) => {
244            collect_logical_operands(name, left, out)?;
245            collect_logical_operands(name, right, out)?;
246            Ok(())
247        }
248        _ => {
249            out.push(expr_to_filter_json(expr)?);
250            Ok(())
251        }
252    }
253}
254
255fn field_predicate_to_json(left: &Expr, op: &BinaryOp, right: &Expr) -> Result<JsonValue> {
256    let field = match left {
257        Expr::Column(name) => strip_column_qualifier(name),
258        other => {
259            return Err(Error::InvalidQuery(format!(
260                "query cannot be serialized to engine JSON: unsupported field operand {:?}",
261                other
262            )));
263        }
264    };
265
266    let (operator, value) = match op {
267        BinaryOp::Eq => (None, expr_value_to_json(right)?),
268        BinaryOp::Ne => (Some("ne"), expr_value_to_json(right)?),
269        BinaryOp::Lt => (Some("lt"), expr_value_to_json(right)?),
270        BinaryOp::Le => (Some("lte"), expr_value_to_json(right)?),
271        BinaryOp::Gt => (Some("gt"), expr_value_to_json(right)?),
272        BinaryOp::Ge => (Some("gte"), expr_value_to_json(right)?),
273        BinaryOp::Like => like_operator_and_value(right)?,
274        BinaryOp::In => (Some("in"), list_expr_to_json_array(right)?),
275        BinaryOp::NotIn => (Some("notIn"), list_expr_to_json_array(right)?),
276        other => {
277            return Err(Error::InvalidQuery(format!(
278                "query cannot be serialized to engine JSON: unsupported binary op {:?}",
279                other
280            )));
281        }
282    };
283
284    let mut result = JsonMap::new();
285    match operator {
286        None => {
287            result.insert(field, value);
288        }
289        Some(op_name) => {
290            let mut operators = JsonMap::new();
291            operators.insert(op_name.to_string(), value);
292            result.insert(field, JsonValue::Object(operators));
293        }
294    }
295
296    Ok(JsonValue::Object(result))
297}
298
299fn null_predicate_to_json(inner: &Expr, is_null: bool) -> Result<JsonValue> {
300    let field = match inner {
301        Expr::Column(name) => strip_column_qualifier(name),
302        other => {
303            return Err(Error::InvalidQuery(format!(
304                "query cannot be serialized to engine JSON: unsupported null predicate {:?}",
305                other
306            )));
307        }
308    };
309
310    let mut operators = JsonMap::new();
311    operators.insert("isNull".to_string(), JsonValue::Bool(is_null));
312
313    let mut result = JsonMap::new();
314    result.insert(field, JsonValue::Object(operators));
315    Ok(JsonValue::Object(result))
316}
317
318fn like_operator_and_value(expr: &Expr) -> Result<(Option<&'static str>, JsonValue)> {
319    let value = match expr {
320        Expr::Param(value) => value.to_json_plain(),
321        other => {
322            return Err(Error::InvalidQuery(format!(
323                "query cannot be serialized to engine JSON: unsupported LIKE operand {:?}",
324                other
325            )));
326        }
327    };
328
329    let Some(pattern) = value.as_str() else {
330        return Ok((Some("like"), value));
331    };
332
333    if pattern.starts_with('%') && pattern.ends_with('%') && pattern.len() >= 2 {
334        return Ok((
335            Some("contains"),
336            JsonValue::String(pattern[1..pattern.len() - 1].to_string()),
337        ));
338    }
339
340    if let Some(stripped) = pattern.strip_prefix('%') {
341        return Ok((Some("endsWith"), JsonValue::String(stripped.to_string())));
342    }
343
344    if let Some(stripped) = pattern.strip_suffix('%') {
345        return Ok((Some("startsWith"), JsonValue::String(stripped.to_string())));
346    }
347
348    Ok((Some("like"), JsonValue::String(pattern.to_string())))
349}
350
351fn list_expr_to_json_array(expr: &Expr) -> Result<JsonValue> {
352    let Expr::List(items) = expr else {
353        return Err(Error::InvalidQuery(format!(
354            "query cannot be serialized to engine JSON: unsupported list operand {:?}",
355            expr
356        )));
357    };
358
359    Ok(JsonValue::Array(
360        items
361            .iter()
362            .map(expr_value_to_json)
363            .collect::<Result<Vec<_>>>()?,
364    ))
365}
366
367fn expr_value_to_json(expr: &Expr) -> Result<JsonValue> {
368    match expr {
369        Expr::Param(value) => Ok(value.to_json_plain()),
370        other => Err(Error::InvalidQuery(format!(
371            "query cannot be serialized to engine JSON: unsupported value expression {:?}",
372            other
373        ))),
374    }
375}
376
377fn strip_column_qualifier(name: &str) -> String {
378    name.split_once("__")
379        .map(|(_, column)| column.to_string())
380        .unwrap_or_else(|| name.to_string())
381}
382
383#[cfg(test)]
384mod tests {
385    use std::collections::HashMap;
386
387    use serde_json::json;
388
389    use super::*;
390    use crate::{Column, Value};
391
392    #[test]
393    fn find_many_args_serializes_supported_filters_and_includes() {
394        let args = FindManyArgs {
395            where_: Some(
396                Column::<String>::new("Entry", "slug")
397                    .contains("rust-entry-")
398                    .and(Expr::column("Entry__id").gt(Expr::param(2))),
399            ),
400            order_by: vec![Column::<i32>::new("Entry", "id").asc()],
401            take: Some(2),
402            skip: Some(1),
403            include: HashMap::from([(
404                "author".to_string(),
405                IncludeRelation::with_filter(
406                    Column::<String>::new("User", "email").eq("a@example.com"),
407                )
408                .with_order_by(Column::<i32>::new("User", "id").desc())
409                .with_take(1)
410                .with_include("posts", IncludeRelation::plain()),
411            )]),
412            select: HashMap::from([("id".to_string(), true), ("slug".to_string(), true)]),
413            cursor: Some(HashMap::from([("id".to_string(), Value::I32(10))])),
414            distinct: vec!["Entry__slug".to_string()],
415        };
416
417        let json = find_many_args_to_protocol_json(&args).expect("serialization should succeed");
418
419        assert_eq!(
420            json,
421            json!({
422                "where": {
423                    "AND": [
424                        { "slug": { "contains": "rust-entry-" } },
425                        { "id": { "gt": 2 } }
426                    ]
427                },
428                "orderBy": [{ "id": "asc" }],
429                "take": 2,
430                "skip": 1,
431                "include": {
432                    "author": {
433                        "where": { "email": "a@example.com" },
434                        "orderBy": [{ "id": "desc" }],
435                        "take": 1,
436                        "include": {
437                            "posts": {}
438                        }
439                    }
440                },
441                "select": {
442                    "id": true,
443                    "slug": true
444                },
445                "cursor": {
446                    "id": 10
447                },
448                "distinct": ["slug"]
449            })
450        );
451    }
452
453    #[test]
454    fn unsupported_expression_returns_invalid_query() {
455        let args = FindManyArgs {
456            where_: Some(Expr::exists(
457                crate::Select::from_table("Post")
458                    .filter(Expr::column("Post__user_id").eq(Expr::column("User__id")))
459                    .build()
460                    .expect("valid select"),
461            )),
462            ..Default::default()
463        };
464
465        let err = find_many_args_to_protocol_json(&args).expect_err("exists is not serializable");
466        assert!(matches!(err, Error::InvalidQuery(_)));
467    }
468
469    #[test]
470    fn relation_predicates_serialize_to_relation_where_objects() {
471        let args = FindManyArgs {
472            where_: Some(
473                Expr::relation_some(
474                    "posts",
475                    "User",
476                    "Post",
477                    "user_id",
478                    "id",
479                    crate::Column::<String>::new("Post", "title")
480                        .contains("rust")
481                        .and(Expr::relation_none(
482                            "comments",
483                            "Post",
484                            "Comment",
485                            "post_id",
486                            "id",
487                            crate::Column::<bool>::new("Comment", "flagged").eq(false),
488                        )),
489                )
490                .and(Expr::relation_every(
491                    "posts",
492                    "User",
493                    "Post",
494                    "user_id",
495                    "id",
496                    crate::Column::<bool>::new("Post", "published").eq(true),
497                )),
498            ),
499            ..Default::default()
500        };
501
502        let json =
503            find_many_args_to_protocol_json(&args).expect("relation filters should serialize");
504
505        assert_eq!(
506            json,
507            json!({
508                "where": {
509                    "AND": [
510                        {
511                            "posts": {
512                                "some": {
513                                    "AND": [
514                                        { "title": { "contains": "rust" } },
515                                        { "comments": { "none": { "flagged": false } } }
516                                    ]
517                                }
518                            }
519                        },
520                        {
521                            "posts": {
522                                "every": {
523                                    "published": true
524                                }
525                            }
526                        }
527                    ]
528                }
529            })
530        );
531    }
532}