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