parse_rust_storage/query.rs
1//! The query AST adapters lower, and the update AST they apply.
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//! **Anything outside the supported vocabulary is an error, never silently ignored**, because a
8//! dropped constraint broadens the result set, which is an authorization failure rather than a
9//! missing feature. That rule is enforced at parse time by [`Comparison::from_operator`] returning
10//! an error for an unknown operator, so there is no code path where an unrecognised constraint
11//! reaches an adapter.
12//!
13//! 0.2.0 turned the flat constraint list into a tree. That was not a generalization for its own
14//! sake: pointer permissions compose **disjunctively** across fields
15//! (`DatabaseController.js:1815`), so a class permitting `{find: {pointerFields: ['owner',
16//! 'editor']}}` cannot be expressed without `$or`. A flat list would have forced either a wrong
17//! conjunction, which under-returns, or no constraint at all, which is the breach.
18
19use indexmap::IndexMap;
20
21use parse_rust_core::{ParseError, ParseValue};
22
23/// How one field is compared to one value.
24#[derive(Debug, Clone)]
25pub enum Comparison {
26 /// Bare equality: `{"field": value}`. Lowers to the value itself, with no operator wrapper.
27 Equal(ParseValue),
28 /// Explicit `$eq`: `{"field": {"$eq": value}}`.
29 ///
30 /// **Deliberately not the same variant as [`Comparison::Equal`], because the two do not lower
31 /// the same way.** Bare equality puts the value directly under the field, so it cannot share
32 /// the field with another operator: `{"n": 5}` and `{"n": {"$gt": 1}}` are two whole documents
33 /// for one key and merging them means one overwrites the other. `$eq` is an operator like any
34 /// other and composes, which is the entire reason upstream's `replaceEquality` rewrites a
35 /// mixed constraint into this form rather than leaving it bare (`RestQuery.js:828-849`).
36 ///
37 /// Folding the two together compiles, passes a round-trip test, and silently undoes that
38 /// rewrite one layer below where it was applied.
39 ///
40 /// It also must not pin an objectId. `ParsedWhere::pinned_object_id` reads the shorthand form
41 /// only, matching upstream's direct `query.objectId` read (`DatabaseController.js:1838`), and
42 /// a separate variant is what keeps `{"objectId": {"$eq": "x"}}` out of that path.
43 EqualOperator(ParseValue),
44 NotEqual(ParseValue),
45 GreaterThan(ParseValue),
46 GreaterThanOrEqual(ParseValue),
47 LessThan(ParseValue),
48 LessThanOrEqual(ParseValue),
49 In(Vec<ParseValue>),
50 NotIn(Vec<ParseValue>),
51 Exists(bool),
52 /// `$all`: the array field contains every listed value.
53 All(Vec<ParseValue>),
54 /// `$regex`, with `$options` folded in.
55 ///
56 /// The two arrive as separate keys and upstream keeps them separate all the way down, relying
57 /// on reverse-alphabetical key iteration so that `$regex` is handled before `$options`
58 /// (`MongoTransform.js:670-675`). Folding them into one variant here removes the ordering
59 /// dependency without changing what reaches the backend, because a lone `$options` is
60 /// meaningless and a lone `$regex` gets `None`.
61 Regex {
62 pattern: String,
63 options: Option<String>,
64 },
65}
66
67impl Comparison {
68 /// Map a Parse `$` operator onto a comparison.
69 ///
70 /// Returns `INVALID_QUERY` for anything unsupported. That is the whole point: the alternative,
71 /// ignoring it, returns more rows than the caller asked for.
72 ///
73 /// `$regex` and `$options` are not handled here, because they are two keys producing one
74 /// comparison. The caller assembles them; see `parse_operators` in `parse-rust-rest`.
75 pub fn from_operator(op: &str, value: ParseValue) -> Result<Self, ParseError> {
76 Ok(match op {
77 // Upstream accepts an explicit `$eq` alongside the bare-value form
78 // (`MongoTransform.js:684-696`), and `replaceEquality` synthesizes one, so this arm is
79 // reachable both from a client that wrote `{"$eq": v}` and from a mixed constraint
80 // that was rewritten into one. Either way it stays an operator all the way down.
81 "$eq" => Comparison::EqualOperator(value),
82 "$ne" => Comparison::NotEqual(value),
83 "$gt" => Comparison::GreaterThan(value),
84 "$gte" => Comparison::GreaterThanOrEqual(value),
85 "$lt" => Comparison::LessThan(value),
86 "$lte" => Comparison::LessThanOrEqual(value),
87 "$in" | "$nin" | "$all" => {
88 let items = match value {
89 ParseValue::Array(items) => items,
90 _ => {
91 return Err(ParseError::invalid_query(format!(
92 "bad {op} value: expected an array"
93 )))
94 }
95 };
96 match op {
97 "$in" => Comparison::In(items),
98 "$nin" => Comparison::NotIn(items),
99 _ => Comparison::All(items),
100 }
101 }
102 "$exists" => match value {
103 ParseValue::Bool(b) => Comparison::Exists(b),
104 _ => {
105 return Err(ParseError::invalid_query(
106 "bad $exists value: expected a boolean".to_string(),
107 ))
108 }
109 },
110 other => {
111 return Err(ParseError::invalid_query(format!(
112 "unsupported query operator: {other}"
113 )))
114 }
115 })
116 }
117}
118
119/// One field, one comparison.
120#[derive(Debug, Clone)]
121pub struct Constraint {
122 pub field: String,
123 pub comparison: Comparison,
124}
125
126impl Constraint {
127 pub fn equal(field: impl Into<String>, value: ParseValue) -> Self {
128 Self {
129 field: field.into(),
130 comparison: Comparison::Equal(value),
131 }
132 }
133
134 pub fn one_of(field: impl Into<String>, values: Vec<ParseValue>) -> Self {
135 Self {
136 field: field.into(),
137 comparison: Comparison::In(values),
138 }
139 }
140}
141
142/// One element of a query. Elements of a [`Query`] are conjoined.
143#[derive(Debug, Clone)]
144pub enum Clause {
145 Field(Constraint),
146 /// At least one sub-query matches.
147 Or(Vec<Query>),
148 /// Every sub-query matches. Distinct from putting the clauses side by side, because a nested
149 /// `$and` can carry two constraints on the same field without them merging.
150 And(Vec<Query>),
151 /// No sub-query matches.
152 Nor(Vec<Query>),
153}
154
155/// A conjunction of clauses. An empty query matches everything.
156#[derive(Debug, Clone, Default)]
157pub struct Query {
158 pub clauses: Vec<Clause>,
159}
160
161impl Query {
162 pub fn new() -> Self {
163 Self::default()
164 }
165
166 pub fn is_empty(&self) -> bool {
167 self.clauses.is_empty()
168 }
169
170 pub fn push(&mut self, clause: Clause) {
171 self.clauses.push(clause);
172 }
173
174 pub fn push_constraint(&mut self, constraint: Constraint) {
175 self.clauses.push(Clause::Field(constraint));
176 }
177
178 /// Conjoin another query into this one.
179 ///
180 /// Splicing the other query's clauses in rather than nesting an `And` keeps the common case
181 /// flat, which matters because the compiled Mongo document is snapshot-compared. Nesting
182 /// would be equally correct and would change every fixture.
183 ///
184 /// **Only safe when the two queries cannot name the same field.** Two constraints on one
185 /// field spliced side by side either merge, which silently drops one, or collide and fail;
186 /// [`Query::conjoin`] is the one to reach for when a server-imposed predicate meets a
187 /// client-supplied one.
188 pub fn extend(&mut self, other: Query) {
189 self.clauses.extend(other.clauses);
190 }
191
192 /// Conjoin another query into this one **without letting either predicate be lost**.
193 ///
194 /// A field the receiver already constrains at top level is nested under `And` instead of
195 /// spliced in beside the existing constraint. Upstream does the same thing and for the same
196 /// reason: `addPointerPermissions` tests `hasOwnProperty(query, key)` and falls back to
197 /// `reduceAndOperation({$and: [queryClause, query]})` when it holds
198 /// (`DatabaseController.js:1807-1811`).
199 ///
200 /// Splicing instead is not a cosmetic difference. A client that queries `owner` explicitly on
201 /// a class whose `find` CLP names `owner` as a pointer field produces two equalities on one
202 /// field, which `merge_constraint` reports as `INVALID_QUERY` rather than answering the query.
203 ///
204 /// Top level only, matching `hasOwnProperty`: a field named inside an `$or` is a different
205 /// key as far as the compiled document is concerned and cannot collide.
206 pub fn conjoin(&mut self, other: Query) {
207 let mut nested = Vec::new();
208 for clause in other.clauses {
209 match &clause {
210 Clause::Field(constraint) if self.constrains_field(&constraint.field) => {
211 nested.push(Query {
212 clauses: vec![clause],
213 });
214 }
215 _ => self.clauses.push(clause),
216 }
217 }
218 if !nested.is_empty() {
219 self.clauses.push(Clause::And(nested));
220 }
221 }
222
223 /// Does a top-level clause constrain this field?
224 pub fn constrains_field(&self, field: &str) -> bool {
225 self.top_level_constraints().any(|c| c.field == field)
226 }
227
228 /// A disjunction of alternatives, simplified the way `reduceOrOperation` does
229 /// (`DatabaseController.js:1657-1724`): an `$or` with a single element collapses into that
230 /// element rather than staying wrapped.
231 pub fn any_of(alternatives: Vec<Query>) -> Query {
232 let mut alternatives: Vec<Query> =
233 alternatives.into_iter().filter(|q| !q.is_empty()).collect();
234 match alternatives.len() {
235 0 => Query::new(),
236 1 => alternatives.remove(0),
237 _ => {
238 let mut q = Query::new();
239 q.push(Clause::Or(alternatives));
240 q
241 }
242 }
243 }
244
245 pub fn from_constraints(constraints: Vec<Constraint>) -> Query {
246 Query {
247 clauses: constraints.into_iter().map(Clause::Field).collect(),
248 }
249 }
250
251 /// Every top-level field constraint, ignoring nested logical clauses.
252 ///
253 /// Used by the pieces that need to know whether a query is pinned to one objectId. It is
254 /// deliberately not a general "find the constraint on field X", because inside an `$or` no
255 /// such thing exists.
256 pub fn top_level_constraints(&self) -> impl Iterator<Item = &Constraint> {
257 self.clauses.iter().filter_map(|c| match c {
258 Clause::Field(f) => Some(f),
259 _ => None,
260 })
261 }
262}
263
264impl From<Vec<Constraint>> for Query {
265 fn from(constraints: Vec<Constraint>) -> Self {
266 Query::from_constraints(constraints)
267 }
268}
269
270/// What one field of an update does.
271///
272/// 0.1.0 modelled an update as a row of literal values, which is why `{"__op":"Increment"}`
273/// round-tripped into storage as an object with an `__op` key. Modelling the op set explicitly
274/// makes "the adapter forgot to handle Increment" a missing match arm.
275#[derive(Debug, Clone)]
276pub enum UpdateValue {
277 /// `$setOnInsert`: set the field only if this operation inserts the row
278 /// (`MongoTransform.js:993-998`).
279 ///
280 /// Carried because upstream carries it, not because a REST write can produce one:
281 /// `getObjectType` has no arm for `SetOnInsert` and throws before the write path is reached
282 /// (`SchemaController.js:1652-1653`). See `infer_op_type`.
283 SetOnInsert(ParseValue),
284 /// Replace the field.
285 Set(ParseValue),
286 /// `$inc`.
287 Increment(f64),
288 /// `$push` with `$each`. Duplicates allowed.
289 Add(Vec<ParseValue>),
290 /// `$addToSet` with `$each`.
291 AddUnique(Vec<ParseValue>),
292 /// `$pullAll`. Note the shape difference from `Add`: no `$each` wrapper
293 /// (`MongoTransform.js:1024-1029`).
294 Remove(Vec<ParseValue>),
295 /// `$unset`.
296 Unset,
297}
298
299impl UpdateValue {
300 /// Does applying this need the post-image read back?
301 ///
302 /// Only ops do. A `Set` tells the client nothing it did not already know, which is why
303 /// `_sanitizeDatabaseResult` returns only op keys (`DatabaseController.js:2129-2157`).
304 pub fn echoes_result(&self) -> bool {
305 !matches!(self, UpdateValue::Set(_) | UpdateValue::Unset)
306 }
307}
308
309/// An update: ordered, because the compiled document is snapshot-compared.
310pub type Update = IndexMap<String, UpdateValue>;
311
312/// Sort direction for one key. Parse spells descending with a leading `-`.
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum SortDirection {
315 Ascending,
316 Descending,
317}
318
319/// Parse's default page size when a query does not ask for one.
320///
321/// **Not unlimited.** An omitted `limit` used to produce an unbounded Mongo query, which is both
322/// the wrong answer and a denial-of-service surface: one request could ask for every row in a
323/// collection.
324pub const DEFAULT_LIMIT: u32 = 100;
325
326/// Everything about a read that is not a constraint.
327#[derive(Debug, Clone)]
328pub struct QueryOptions {
329 pub limit: Option<u32>,
330 pub skip: Option<u32>,
331 pub order: Vec<(String, SortDirection)>,
332 /// Projection. `None` means every field; `Some` is the explicit list.
333 ///
334 /// Upstream converts `excludeKeys` into `keys` before it reaches storage, so an adapter only
335 /// ever sees the positive form.
336 pub keys: Option<Vec<String>>,
337 /// Compare strings under upstream's case-insensitive collation rather than byte for byte.
338 ///
339 /// `{caseInsensitive: true}` (`MongoStorageAdapter.js:723`, `:801-803`), which resolves to
340 /// `{locale: "en_US", strength: 2}`. Used by the `_User` username and email uniqueness checks
341 /// and by nothing else, because it is the only place upstream asks for it.
342 ///
343 /// **A case-folding regex is not a substitute and the difference is not academic.** Strength 2
344 /// normalizes as well as folding case, so a precomposed `Café` and a decomposed `Café` are one
345 /// key to the collation and two distinct byte strings to any regex, which means a regex admits
346 /// identities upstream treats as duplicates. It does not fold diacritics: `Café` and `Cafe`
347 /// remain different identities under it.
348 pub case_insensitive: bool,
349}
350
351impl Default for QueryOptions {
352 fn default() -> Self {
353 Self {
354 limit: Some(DEFAULT_LIMIT),
355 skip: None,
356 order: Vec::new(),
357 keys: None,
358 case_insensitive: false,
359 }
360 }
361}
362
363impl QueryOptions {
364 /// Parse Parse's `order` parameter: comma-separated keys, `-` prefix for descending.
365 pub fn parse_order(order: &str) -> Vec<(String, SortDirection)> {
366 order
367 .split(',')
368 .map(str::trim)
369 .filter(|s| !s.is_empty())
370 .map(|k| match k.strip_prefix('-') {
371 Some(rest) => (rest.to_string(), SortDirection::Descending),
372 None => (k.to_string(), SortDirection::Ascending),
373 })
374 .collect()
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
383 fn supported_operators_map() {
384 for op in ["$ne", "$gt", "$gte", "$lt", "$lte"] {
385 assert!(
386 Comparison::from_operator(op, ParseValue::Number(1.0)).is_ok(),
387 "{op}"
388 );
389 }
390 assert!(Comparison::from_operator("$in", ParseValue::Array(vec![])).is_ok());
391 assert!(Comparison::from_operator("$nin", ParseValue::Array(vec![])).is_ok());
392 assert!(Comparison::from_operator("$all", ParseValue::Array(vec![])).is_ok());
393 assert!(Comparison::from_operator("$exists", ParseValue::Bool(true)).is_ok());
394 }
395
396 /// The rule that keeps a dropped constraint from broadening a result set. The list shrank at
397 /// 0.2.0 because some of these landed; what must not change is that the remainder error.
398 #[test]
399 fn an_unsupported_operator_is_an_error_not_a_no_op() {
400 for op in [
401 "$select",
402 "$dontSelect",
403 "$inQuery",
404 "$notInQuery",
405 "$nearSphere",
406 "$text",
407 "$containedBy",
408 "$geoWithin",
409 ] {
410 let e = Comparison::from_operator(op, ParseValue::Null).unwrap_err();
411 assert_eq!(e.code, parse_rust_core::ErrorCode::InvalidQuery, "{op}");
412 assert!(e.message.contains(op), "the message must name the operator");
413 }
414 }
415
416 #[test]
417 fn in_requires_an_array_and_exists_requires_a_boolean() {
418 assert!(Comparison::from_operator("$in", ParseValue::Number(1.0)).is_err());
419 assert!(Comparison::from_operator("$all", ParseValue::Number(1.0)).is_err());
420 assert!(Comparison::from_operator("$exists", ParseValue::Number(1.0)).is_err());
421 }
422
423 #[test]
424 fn the_default_limit_is_a_hundred_not_unlimited() {
425 assert_eq!(QueryOptions::default().limit, Some(DEFAULT_LIMIT));
426 assert_eq!(DEFAULT_LIMIT, 100);
427 }
428
429 #[test]
430 fn order_parsing_handles_the_minus_prefix() {
431 assert_eq!(
432 QueryOptions::parse_order("name,-createdAt, score"),
433 vec![
434 ("name".to_string(), SortDirection::Ascending),
435 ("createdAt".to_string(), SortDirection::Descending),
436 ("score".to_string(), SortDirection::Ascending),
437 ]
438 );
439 assert!(QueryOptions::parse_order("").is_empty());
440 }
441
442 /// `reduceOrOperation` collapses a single-element disjunction. Reproduced so that a class with
443 /// exactly one pointer field compiles to the same document upstream produces.
444 #[test]
445 fn a_single_alternative_disjunction_collapses() {
446 let one = Query::from_constraints(vec![Constraint::equal(
447 "owner",
448 ParseValue::String("u1".into()),
449 )]);
450 let q = Query::any_of(vec![one]);
451 assert_eq!(q.clauses.len(), 1);
452 assert!(matches!(q.clauses[0], Clause::Field(_)));
453
454 let two = Query::any_of(vec![
455 Query::from_constraints(vec![Constraint::equal("a", ParseValue::Null)]),
456 Query::from_constraints(vec![Constraint::equal("b", ParseValue::Null)]),
457 ]);
458 assert!(matches!(two.clauses.as_slice(), [Clause::Or(alts)] if alts.len() == 2));
459 }
460
461 #[test]
462 fn an_empty_alternative_is_dropped_and_an_empty_disjunction_is_unconstrained() {
463 assert!(Query::any_of(vec![]).is_empty());
464 assert!(Query::any_of(vec![Query::new(), Query::new()]).is_empty());
465 }
466
467 #[test]
468 fn conjoin_nests_a_colliding_field_and_splices_everything_else() {
469 let mut client = Query::from_constraints(vec![Constraint::equal(
470 "owner",
471 ParseValue::String("u1".into()),
472 )]);
473 client.conjoin(Query::from_constraints(vec![
474 Constraint::equal("owner", ParseValue::String("u1".into())),
475 Constraint::equal("state", ParseValue::String("open".into())),
476 ]));
477
478 // The client's `owner` survives untouched, `state` is spliced in flat, and the second
479 // `owner` is nested where it cannot merge with or displace the first.
480 assert!(matches!(
481 client.clauses.as_slice(),
482 [Clause::Field(a), Clause::Field(b), Clause::And(nested)]
483 if a.field == "owner" && b.field == "state" && nested.len() == 1
484 ));
485 }
486
487 #[test]
488 fn conjoin_stays_flat_when_no_field_collides() {
489 let mut q = Query::from_constraints(vec![Constraint::equal(
490 "title",
491 ParseValue::String("a".into()),
492 )]);
493 q.conjoin(Query::from_constraints(vec![Constraint::equal(
494 "owner",
495 ParseValue::String("u1".into()),
496 )]));
497 assert!(matches!(
498 q.clauses.as_slice(),
499 [Clause::Field(_), Clause::Field(_)]
500 ));
501 }
502
503 #[test]
504 fn conjoin_only_looks_at_top_level_fields() {
505 // `owner` named inside an `$or` is a different key in the compiled document, so it cannot
506 // collide and must not force the nesting. `hasOwnProperty` upstream behaves the same way.
507 let mut q = Query::new();
508 q.push(Clause::Or(vec![Query::from_constraints(vec![
509 Constraint::equal("owner", ParseValue::String("u2".into())),
510 ])]));
511 q.conjoin(Query::from_constraints(vec![Constraint::equal(
512 "owner",
513 ParseValue::String("u1".into()),
514 )]));
515 assert!(matches!(
516 q.clauses.as_slice(),
517 [Clause::Or(_), Clause::Field(_)]
518 ));
519 }
520
521 #[test]
522 fn only_ops_echo_their_result_back() {
523 assert!(!UpdateValue::Set(ParseValue::Null).echoes_result());
524 assert!(!UpdateValue::Unset.echoes_result());
525 assert!(UpdateValue::Increment(1.0).echoes_result());
526 assert!(UpdateValue::Add(vec![]).echoes_result());
527 assert!(UpdateValue::AddUnique(vec![]).echoes_result());
528 assert!(UpdateValue::Remove(vec![]).echoes_result());
529 }
530}