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