Skip to main content

parse_rust_storage/
query.rs

1//! The query AST adapters lower.
2//!
3//! Not a Mongo query document. A Mongo query document is already lowered, and handing one to the
4//! Postgres adapter would mean writing a Mongo-query interpreter in SQL, which is roughly the
5//! shape upstream ended up with.
6//!
7//! The constraint set is the 0.1.0 scope: equality, `$ne`, `$in`, `$nin`, `$exists` and the four
8//! range operators, plus `limit`, `skip`, `order`, `count` and `keys`. **Anything outside it
9//! is an error, never silently ignored**, because a dropped constraint broadens the result set,
10//! which is an authorization failure rather than a missing feature. That rule is enforced at
11//! parse time by [`Comparison::from_operator`] returning an error for an unknown operator, so
12//! there is no code path where an unrecognised constraint reaches an adapter.
13
14use parse_rust_core::{ParseError, ParseValue};
15
16/// How one field is compared to one value.
17#[derive(Debug, Clone)]
18pub enum Comparison {
19    /// Bare equality: `{"field": value}`.
20    Equal(ParseValue),
21    NotEqual(ParseValue),
22    GreaterThan(ParseValue),
23    GreaterThanOrEqual(ParseValue),
24    LessThan(ParseValue),
25    LessThanOrEqual(ParseValue),
26    In(Vec<ParseValue>),
27    NotIn(Vec<ParseValue>),
28    Exists(bool),
29}
30
31impl Comparison {
32    /// Map a Parse `$` operator onto a comparison.
33    ///
34    /// Returns `INVALID_QUERY` for anything unsupported. That is the whole point: the alternative,
35    /// ignoring it, returns more rows than the caller asked for.
36    pub fn from_operator(op: &str, value: ParseValue) -> Result<Self, ParseError> {
37        Ok(match op {
38            "$ne" => Comparison::NotEqual(value),
39            "$gt" => Comparison::GreaterThan(value),
40            "$gte" => Comparison::GreaterThanOrEqual(value),
41            "$lt" => Comparison::LessThan(value),
42            "$lte" => Comparison::LessThanOrEqual(value),
43            "$in" | "$nin" => {
44                let items = match value {
45                    ParseValue::Array(items) => items,
46                    _ => {
47                        return Err(ParseError::invalid_query(format!(
48                            "bad {op} value: expected an array"
49                        )))
50                    }
51                };
52                if op == "$in" {
53                    Comparison::In(items)
54                } else {
55                    Comparison::NotIn(items)
56                }
57            }
58            "$exists" => match value {
59                ParseValue::Bool(b) => Comparison::Exists(b),
60                _ => {
61                    return Err(ParseError::invalid_query(
62                        "bad $exists value: expected a boolean".to_string(),
63                    ))
64                }
65            },
66            other => {
67                return Err(ParseError::invalid_query(format!(
68                    "unsupported query operator: {other}"
69                )))
70            }
71        })
72    }
73}
74
75/// One field, one comparison.
76#[derive(Debug, Clone)]
77pub struct Constraint {
78    pub field: String,
79    pub comparison: Comparison,
80}
81
82impl Constraint {
83    pub fn equal(field: impl Into<String>, value: ParseValue) -> Self {
84        Self {
85            field: field.into(),
86            comparison: Comparison::Equal(value),
87        }
88    }
89}
90
91/// Sort direction for one key. Parse spells descending with a leading `-`.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum SortDirection {
94    Ascending,
95    Descending,
96}
97
98/// Parse's default page size when a query does not ask for one.
99///
100/// **Not unlimited.** An omitted `limit` used to produce an unbounded Mongo query, which is both
101/// the wrong answer and a denial-of-service surface: one request could ask for every row in a
102/// collection.
103pub const DEFAULT_LIMIT: u32 = 100;
104
105/// Everything about a read that is not a constraint.
106#[derive(Debug, Clone)]
107pub struct QueryOptions {
108    pub limit: Option<u32>,
109    pub skip: Option<u32>,
110    pub order: Vec<(String, SortDirection)>,
111    /// Projection. `None` means every field; `Some` is the explicit list.
112    ///
113    /// Upstream converts `excludeKeys` into `keys` before it reaches storage, so an adapter only
114    /// ever sees the positive form.
115    pub keys: Option<Vec<String>>,
116}
117
118impl Default for QueryOptions {
119    fn default() -> Self {
120        Self {
121            limit: Some(DEFAULT_LIMIT),
122            skip: None,
123            order: Vec::new(),
124            keys: None,
125        }
126    }
127}
128
129impl QueryOptions {
130    /// Parse Parse's `order` parameter: comma-separated keys, `-` prefix for descending.
131    pub fn parse_order(order: &str) -> Vec<(String, SortDirection)> {
132        order
133            .split(',')
134            .map(str::trim)
135            .filter(|s| !s.is_empty())
136            .map(|k| match k.strip_prefix('-') {
137                Some(rest) => (rest.to_string(), SortDirection::Descending),
138                None => (k.to_string(), SortDirection::Ascending),
139            })
140            .collect()
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn supported_operators_map() {
150        for op in ["$ne", "$gt", "$gte", "$lt", "$lte"] {
151            assert!(
152                Comparison::from_operator(op, ParseValue::Number(1.0)).is_ok(),
153                "{op}"
154            );
155        }
156        assert!(Comparison::from_operator("$in", ParseValue::Array(vec![])).is_ok());
157        assert!(Comparison::from_operator("$nin", ParseValue::Array(vec![])).is_ok());
158        assert!(Comparison::from_operator("$exists", ParseValue::Bool(true)).is_ok());
159    }
160
161    /// The rule that keeps a dropped constraint from broadening a result set.
162    #[test]
163    fn an_unsupported_operator_is_an_error_not_a_no_op() {
164        for op in [
165            "$regex",
166            "$select",
167            "$inQuery",
168            "$all",
169            "$nearSphere",
170            "$text",
171        ] {
172            let e = Comparison::from_operator(op, ParseValue::Null).unwrap_err();
173            assert_eq!(e.code, parse_rust_core::ErrorCode::InvalidQuery, "{op}");
174            assert!(e.message.contains(op), "the message must name the operator");
175        }
176    }
177
178    #[test]
179    fn in_requires_an_array_and_exists_requires_a_boolean() {
180        assert!(Comparison::from_operator("$in", ParseValue::Number(1.0)).is_err());
181        assert!(Comparison::from_operator("$exists", ParseValue::Number(1.0)).is_err());
182    }
183
184    #[test]
185    fn the_default_limit_is_a_hundred_not_unlimited() {
186        assert_eq!(QueryOptions::default().limit, Some(DEFAULT_LIMIT));
187        assert_eq!(DEFAULT_LIMIT, 100);
188    }
189
190    #[test]
191    fn order_parsing_handles_the_minus_prefix() {
192        assert_eq!(
193            QueryOptions::parse_order("name,-createdAt, score"),
194            vec![
195                ("name".to_string(), SortDirection::Ascending),
196                ("createdAt".to_string(), SortDirection::Descending),
197                ("score".to_string(), SortDirection::Ascending),
198            ]
199        );
200        assert!(QueryOptions::parse_order("").is_empty());
201    }
202}