parse_rust_mongo/transform.rs
1//! Parse JSON to BSON and back.
2//!
3//! Upstream: `src/Adapters/Storage/Mongo/MongoTransform.js`. It is full of load-bearing special
4//! cases, and those special cases are the whole job: the naive transform is trivial and wrong.
5//!
6//! The pair implemented here mirrors `parseObjectToMongoObjectForCreate` and
7//! `mongoObjectToParseObject`, which are the two functions upstream exports and therefore the
8//! two this can be differentially tested against. See `tests/transform_differential.rs`.
9//!
10//! Scope is the 0.1.0 field set: String, Number, Boolean, Date, Array, Object, Pointer, ACL,
11//! plus the 0.2.0 additions: the `$or`/`$and`/`$nor` query tree, `$all`, `$regex`, the update
12//! operator set, and Relation fields (which have no column at all).
13
14use bson::{Bson, Document};
15use parse_rust_core::{recognize_atom, AtomPosition, ParseDate, ParseError, ParseMap, ParseValue};
16use parse_rust_storage::{
17 ClassSchema, Clause, Comparison, Constraint, FieldType, Query, Update, UpdateValue,
18};
19
20/// Keys that are renamed rather than stored under their Parse name (`transformKey`,
21/// `MongoTransform.js:7-30`). The list is closed: everything else keeps its name, except a
22/// declared Pointer field, which takes a `_p_` prefix.
23pub fn storage_key(schema: &ClassSchema, field: &str) -> String {
24 match field {
25 "objectId" => return "_id".into(),
26 "createdAt" => return "_created_at".into(),
27 "updatedAt" => return "_updated_at".into(),
28 "sessionToken" => return "_session_token".into(),
29 "lastUsed" => return "_last_used".into(),
30 "timesUsed" => return "times_used".into(),
31 _ => {}
32 }
33 if schema.is_pointer_field(field) {
34 format!("_p_{field}")
35 } else {
36 field.to_string()
37 }
38}
39
40/// Server-internal columns that are read back under their own names.
41///
42/// **Deliberately not upstream's shape.** `mongoObjectToParseObject` rehydrates
43/// `_hashed_password` onto the object as `password` (`DatabaseController.js:266-267`), so the hash
44/// travels under a user-facing name and one later step has to remove it again. Any path that
45/// forgets that step leaks the hash, so parse-rust does not create the condition: the column keeps
46/// its internal name all the way through.
47/// Keeping the internal name means no code path can mistake the hash for a user-facing field, and
48/// `parse_rust_rest::strip_internal_keys` removes every `_`-prefixed key from responses as the
49/// unconditional backstop, which is what upstream's `filterSensitiveData` also does
50/// (`DatabaseController.js:288-292`).
51///
52/// `_session_token` is absent because it is already renamed to `sessionToken` above, which is
53/// upstream's behavior for that one column.
54/// **This list is a mixed-fleet requirement, not a list of what parse-rust writes.** An unknown
55/// `_`-prefixed column fails the whole read with `INVALID_QUERY` below, so a column parse-rust
56/// never writes but parse-server does still has to be listed here or every row carrying one becomes
57/// unreadable. Password reset, email verification, account lockout and a password policy are all
58/// unimplemented here and all write columns onto `_User` upstream, so a fleet running one of those
59/// features on the parse-server side produces rows this server would otherwise refuse to return:
60/// login, `GET /users/me` and any query matching that user would all fail.
61///
62/// Kept in step with the `switch` at `MongoTransform.js:1152-1168`, which is the authority. Adding
63/// a column here is safe by construction, because `strip_internal_keys` removes every `_`-prefixed
64/// key from the response afterwards; omitting one is what breaks.
65const INTERNAL_COLUMNS: [&str; 12] = [
66 "_rperm",
67 "_wperm",
68 "_hashed_password",
69 "_perishable_token",
70 "_perishable_token_expires_at",
71 "_email_verify_token",
72 "_email_verify_token_expires_at",
73 "_account_lockout_expires_at",
74 "_failed_login_count",
75 "_password_changed_at",
76 "_password_history",
77 "_tombstone",
78];
79
80/// The inverse of [`storage_key`].
81fn untransform_key(field: &str) -> Option<String> {
82 match field {
83 "_id" => Some("objectId".into()),
84 "_created_at" => Some("createdAt".into()),
85 "_updated_at" => Some("updatedAt".into()),
86 "_session_token" => Some("sessionToken".into()),
87 // The legacy spelling, which upstream still accepts beside the plain one
88 // (`MongoTransform.js:1177-1181`). parse-rust only ever writes `expiresAt`, so this is
89 // read-side compatibility with a database an older Parse wrote.
90 "_expiresAt" => Some("expiresAt".into()),
91 "_last_used" => Some("lastUsed".into()),
92 "times_used" => Some("timesUsed".into()),
93 _ => {
94 if let Some(stripped) = field.strip_prefix("_p_") {
95 return Some(stripped.to_string());
96 }
97 if INTERNAL_COLUMNS.contains(&field) || field.starts_with("_auth_data_") {
98 return Some(field.to_string());
99 }
100 None
101 }
102 }
103}
104
105/// Choose the BSON number type.
106///
107/// **This is the rule that silently corrupts data if it is wrong**, and it is why Gate B of the
108/// data-fidelity gate exists. The rule, measured against a real parse-server: a value that
109/// is integral and fits in `i32` is stored as `Int32`, everything else as `Double`. The Node
110/// driver does the same thing, which is why a database written by parse-server contains a mix of
111/// both for what the client thinks is one numeric field.
112///
113/// Note that this is unrelated to JSON number *formatting*, which is `parse-rust-core::js_number`.
114/// Conflating the two is the mistake this project already made once.
115fn to_bson_number(n: f64) -> Bson {
116 if n.fract() == 0.0 && n >= i32::MIN as f64 && n <= i32::MAX as f64 {
117 // `-0.0` is integral and in range. It stores as Int32 0, losing the sign, which is what
118 // the Node driver does too.
119 Bson::Int32(n as i32)
120 } else {
121 Bson::Double(n)
122 }
123}
124
125/// Lower a value that sits **inside** an array or object.
126///
127/// UPSTREAM-QUIRK: `transformInteriorAtom` (`MongoTransform.js:566`) handles a strictly smaller
128/// set than the top level. Only Pointer, Date and Bytes are recognised; a GeoPoint, Polygon or
129/// File nested inside an array is stored raw, as the plain `__type` object it arrived as. That is
130/// wire-visible on read-back, and it is reproduced deliberately.
131///
132/// A nested Pointer also keeps its full `{__type, className, objectId}` shape rather than
133/// collapsing to `"Class$id"`, because the `_p_` collapse is a *key* transformation and interior
134/// values have no key of their own.
135///
136/// **No regex arm here.** A `{"$regex": ...}` element compiles to a BSON regular expression only on
137/// the query side, in [`interior_query_atom_to_bson`]; on a write it is refused as a nested `$`
138/// key. This function is the shape shared by both and holds neither policy.
139/// Lower an interior value with **no policy applied**: no nested-key guard, no regex compile.
140///
141/// The shape of every interior lowering, and nothing else. Three callers need three different
142/// policies on top of it, and each of the three has at some point been served by a function
143/// carrying somebody else's:
144///
145/// - a **row write** must refuse a `$` or `.` key ([`interior_value_to_bson`]);
146/// - a **query atom** must compile `{"$regex": ...}` and must *not* refuse it
147/// ([`interior_query_atom_to_bson`]);
148/// - **stored metadata** must do neither ([`parse_map_to_bson_document`]).
149///
150/// Sharing one function across two of those put a compiled regex into a stored array and made the
151/// row unreadable; sharing it across the other two turned a legitimate `containsAllStartingWith`
152/// query and a valid `defaultValue` into `INVALID_NESTED_KEY`. The policies are the difference, so
153/// the policies live in the wrappers and this stays free of them.
154fn interior_atom_core(value: &ParseValue) -> Result<Bson, ParseError> {
155 Ok(match value {
156 ParseValue::Date(d) => date_to_bson(d),
157 ParseValue::Bytes(b) => Bson::Binary(bson::Binary {
158 subtype: bson::spec::BinarySubtype::Generic,
159 bytes: b.clone(),
160 }),
161 ParseValue::Pointer {
162 class_name,
163 object_id,
164 } => {
165 let mut d = Document::new();
166 d.insert("__type", "Pointer");
167 d.insert("className", class_name.clone());
168 d.insert("objectId", object_id.clone());
169 Bson::Document(d)
170 }
171 // The four types `transformInteriorAtom` does **not** recognise. Upstream falls off the
172 // end of its `if` chain and does `return atom` (`MongoTransform.js:583`), so the `__type`
173 // envelope is what lands in the column. Delegating to `plain_value_to_bson` here instead
174 // converted them to their top-level storage forms, which is a stored-format divergence a
175 // mixed fleet sees as a disagreement about what the column contains.
176 //
177 // **Relation belongs here too**, and its absence was not a formatting difference. A
178 // Relation has no column of its own, so `plain_value_to_bson` refuses it, and that refusal
179 // is right at the top level and wrong inside an array: upstream stores the envelope and
180 // parse-rust answered `INCORRECT_TYPE` for a document a parse-server node writes happily.
181 ParseValue::GeoPoint { .. }
182 | ParseValue::Polygon(_)
183 | ParseValue::File { .. }
184 | ParseValue::Relation { .. } => raw_typed_value(value)?,
185 other => plain_value_to_bson(other)?,
186 })
187}
188
189/// The interior transform **as a row write uses it**.
190///
191/// `$` and `.` are refused in a nested key before anything else looks at the value
192/// (`transformInteriorValue`, `MongoTransform.js:177-187`). MongoDB gives both characters meaning
193/// inside a document key, so a value carrying them is a write parse-server refuses with
194/// `INVALID_NESTED_KEY` and parse-rust was storing.
195///
196/// **Only row writes want this.** A query operand of `{"$regex": "^ab"}` is what
197/// `containsAllStartingWith` sends, and a stored `defaultValue` may legitimately contain a `$`
198/// key; both go through their own wrapper.
199fn interior_value_to_bson(value: &ParseValue) -> Result<Bson, ParseError> {
200 if let ParseValue::Object(map) = value {
201 if map.keys().any(|k| k.contains('$') || k.contains('.')) {
202 return Err(ParseError::new(
203 parse_rust_core::ErrorCode::InvalidNestedKey,
204 "Nested keys should not contain the '$' or '.' characters",
205 ));
206 }
207 }
208 interior_atom_core(value)
209}
210
211/// The interior atom transform **as the query path uses it**, which additionally compiles a
212/// `{"$regex": "..."}` atom into a real BSON regular expression (`MongoTransform.js:580-581`).
213///
214/// **Separate from [`interior_value_to_bson`], and the separation is the whole point.** Upstream
215/// reaches `transformInteriorAtom` from two directions and they are not equivalent. A *query*
216/// reaches it directly, from `$all` and from `transformConstraint`, and a regex there is the
217/// operand the SDK's `containsAllStartingWith` sends. A *write* reaches it through
218/// `transformInteriorValue`, which refuses any object carrying a `$` or `.` key with
219/// `INVALID_NESTED_KEY` **before** delegating (`MongoTransform.js:177-189`), so a regex can never
220/// be compiled on a write path there.
221///
222/// parse-rust reproduces that guard in [`interior_value_to_bson`] now, but the two functions still
223/// must not merge: this one additionally compiles the regex and, below, keeps a generic object
224/// unchanged. Putting the regex arm in the shared function once stored a BSON regular expression in
225/// an ordinary array, which `bson_to_parse_value` cannot decode, so the row became permanently
226/// unreadable and poisoned every query that returned it.
227fn interior_query_atom_to_bson(value: &ParseValue) -> Result<Bson, ParseError> {
228 if let ParseValue::Object(map) = value {
229 if let Some(pattern) = interior_regex(map) {
230 return Ok(Bson::RegularExpression(bson::Regex {
231 pattern,
232 options: String::new(),
233 }));
234 }
235 }
236 // **A generic object or array is returned unchanged, not converted.** `transformInteriorAtom`
237 // is shallow: its final arm is `return atom` (`MongoTransform.js:583`), so a nested
238 // `{"__type": "Date", ...}` inside a query operand stays a literal subdocument and does not
239 // become a BSON date. That is why `{"tags": {"$in": [{"at": <Date>}]}}` matches nothing
240 // upstream even against a row written from the same body: the stored element holds a real BSON
241 // date and the operand holds three string keys.
242 //
243 // Recursing here instead converted the nested atom and **matched a row upstream does not
244 // return**, which is the direction that matters. A query that returns more than upstream would
245 // is the failure this project treats as an authorization concern rather than a formatting one.
246 match value {
247 ParseValue::Object(_) | ParseValue::Array(_) => unchanged_atom_to_bson(value),
248 other => interior_atom_core(other),
249 }
250}
251
252/// A value as it arrived, with no Parse decoding applied to anything inside it.
253///
254/// The `return atom` arm of `transformInteriorAtom`, expressed for a type system that has already
255/// decoded the JSON. Upstream never looks inside a generic object here, so its nested envelopes
256/// survive verbatim; parse-rust has to re-emit them, and [`ParseValue::to_json`] is exactly the
257/// envelope form they arrived in.
258fn unchanged_atom_to_bson(value: &ParseValue) -> Result<Bson, ParseError> {
259 Ok(match value {
260 ParseValue::Object(map) => {
261 let mut d = Document::new();
262 for (k, v) in map {
263 d.insert(k.clone(), unchanged_atom_to_bson(v)?);
264 }
265 Bson::Document(d)
266 }
267 ParseValue::Array(items) => Bson::Array(
268 items
269 .iter()
270 .map(unchanged_atom_to_bson)
271 .collect::<Result<Vec<_>, _>>()?,
272 ),
273 // The three the storage form would otherwise convert. Kept as the objects they arrived as.
274 ParseValue::Date(d) => {
275 let mut e = Document::new();
276 e.insert("__type", "Date");
277 e.insert("iso", d.to_iso());
278 Bson::Document(e)
279 }
280 ParseValue::Bytes(b) => {
281 let mut e = Document::new();
282 e.insert("__type", "Bytes");
283 e.insert("base64", parse_rust_core::base64_encode(b));
284 Bson::Document(e)
285 }
286 ParseValue::Pointer {
287 class_name,
288 object_id,
289 } => {
290 let mut e = Document::new();
291 e.insert("__type", "Pointer");
292 e.insert("className", class_name.clone());
293 e.insert("objectId", object_id.clone());
294 Bson::Document(e)
295 }
296 ParseValue::GeoPoint { .. }
297 | ParseValue::Polygon(_)
298 | ParseValue::File { .. }
299 | ParseValue::Relation { .. } => raw_typed_value(value)?,
300 // **Scalars still go through `plain_value_to_bson`, and that matters for numbers.** Going
301 // via JSON instead, which this did first, stored `1` as Int64 where every other path stores
302 // Int32: the rule Gate B exists to protect, broken by a serializer chosen for convenience.
303 other => plain_value_to_bson(other)?,
304 })
305}
306
307/// The `$regex` pattern of an interior `{"$regex": "..."}` atom, if that is what this object is.
308///
309/// Upstream tests `atom.$regex !== undefined` and nothing else, so an object carrying `$regex`
310/// beside other keys is still a regex and the other keys are dropped. A non-string `$regex` is
311/// coerced rather than refused, which is what `new RegExp(String(v))` does; see the arms below.
312fn interior_regex(map: &parse_rust_core::ParseMap) -> Option<String> {
313 // **`new RegExp(atom.$regex)` coerces**, so the value need not be a string
314 // (`MongoTransform.js:581`): `new RegExp(7)` is `/7/`. Matching only `String` here left every
315 // other shape to fall through to the generic path and, for a number, to a 500. Upstream serves
316 // the row.
317 //
318 // `undefined` is the only value upstream treats as absent, so a `null` still coerces, to
319 // `/null/`. The rendering follows `ParseValue`'s own JSON spelling, which is what a client sent.
320 match map.get("$regex")? {
321 ParseValue::String(pattern) => Some(pattern.clone()),
322 ParseValue::Number(n) => Some(parse_rust_core::js_number::to_ecma_string(*n)),
323 ParseValue::Bool(b) => Some(b.to_string()),
324 ParseValue::Null => Some("null".to_string()),
325 // **Arrays and objects coerce too, and leaving them out was a 500.** The previous comment
326 // claimed they fell through to "at least not a 500"; measured, all four of `[7]`, `[]`,
327 // `{}` and `[1,2]` answered 500 here and 200 upstream. `new RegExp(String(v))` is the whole
328 // rule, so an array joins its elements and an object is `[object Object]`, both of which
329 // are then read as patterns. `[7]` matches `a7b` upstream, which is the case that shows
330 // this is a real answer rather than a degenerate one.
331 other => Some(parse_rust_core::js_number::to_ecma_display(other)),
332 }
333}
334
335/// A tagged value stored as the `__type` object it arrived as, which is upstream's `return atom`.
336///
337/// Key order matches `ParseValue`'s own JSON rendering, because that is the shape the value had on
338/// the way in and the one a reader will compare a stored document against. Latitude precedes
339/// longitude here, which is the **wire** order: the GeoJSON swap belongs to the top-level storage
340/// form and does not apply to an atom upstream never converts.
341fn raw_typed_value(value: &ParseValue) -> Result<Bson, ParseError> {
342 let mut d = Document::new();
343 match value {
344 ParseValue::GeoPoint {
345 latitude,
346 longitude,
347 } => {
348 d.insert("__type", "GeoPoint");
349 d.insert("latitude", Bson::Double(*latitude));
350 d.insert("longitude", Bson::Double(*longitude));
351 }
352 ParseValue::Polygon(coords) => {
353 d.insert("__type", "Polygon");
354 d.insert(
355 "coordinates",
356 Bson::Array(
357 coords
358 .iter()
359 .map(|(lat, lng)| Bson::Array(vec![Bson::Double(*lat), Bson::Double(*lng)]))
360 .collect(),
361 ),
362 );
363 }
364 ParseValue::File { name, url } => {
365 d.insert("__type", "File");
366 d.insert("name", name.clone());
367 if let Some(url) = url {
368 d.insert("url", url.clone());
369 }
370 }
371 ParseValue::Relation { class_name } => {
372 d.insert("__type", "Relation");
373 d.insert("className", class_name.clone());
374 }
375 other => return plain_value_to_bson(other),
376 }
377 Ok(Bson::Document(d))
378}
379
380/// One element of an array as JavaScript's `Array.prototype.join` renders it.
381///
382/// Only used to build the `$all` mixed-regex message, whose upstream form is string concatenation
383/// of the array. `null` and `undefined` join as the empty string; an object joins as
384/// `[object Object]`; a regex atom is still the `{"$regex": ...}` object at this point.
385fn js_join_element(value: &ParseValue) -> String {
386 match value {
387 ParseValue::String(s) => s.clone(),
388 ParseValue::Number(n) => parse_rust_core::js_number::to_ecma_string(*n),
389 ParseValue::Bool(b) => b.to_string(),
390 ParseValue::Null => String::new(),
391 _ => "[object Object]".to_string(),
392 }
393}
394
395fn date_to_bson(d: &ParseDate) -> Bson {
396 Bson::DateTime(bson::DateTime::from_millis(d.timestamp_millis()))
397}
398
399/// Everything that is not position-dependent.
400fn plain_value_to_bson(value: &ParseValue) -> Result<Bson, ParseError> {
401 Ok(match value {
402 ParseValue::Null => Bson::Null,
403 ParseValue::Bool(b) => Bson::Boolean(*b),
404 ParseValue::Number(n) => to_bson_number(*n),
405 ParseValue::String(s) => Bson::String(s.clone()),
406 ParseValue::Array(items) => Bson::Array(
407 items
408 .iter()
409 .map(interior_value_to_bson)
410 .collect::<Result<Vec<_>, _>>()?,
411 ),
412 ParseValue::Object(map) => {
413 let mut d = Document::new();
414 for (k, v) in map {
415 d.insert(k.clone(), interior_value_to_bson(v)?);
416 }
417 Bson::Document(d)
418 }
419 ParseValue::Date(d) => date_to_bson(d),
420 ParseValue::Bytes(b) => Bson::Binary(bson::Binary {
421 subtype: bson::spec::BinarySubtype::Generic,
422 bytes: b.clone(),
423 }),
424 ParseValue::GeoPoint {
425 latitude,
426 longitude,
427 } => {
428 // Storage is GeoJSON order, longitude first, the reverse of the wire form.
429 Bson::Array(vec![Bson::Double(*longitude), Bson::Double(*latitude)])
430 }
431 ParseValue::Pointer { .. } => {
432 return Err(ParseError::incorrect_type(
433 "a top-level Pointer is lowered by key, not by value".to_string(),
434 ))
435 }
436 ParseValue::Polygon(coords) => {
437 // **The stored ring is closed and the client's is not, so the write appends the first
438 // vertex** (`PolygonCoder.JSONToDatabase`). Storing the ring as sent round-trips
439 // perfectly against parse-rust and is one vertex short of what a parse-server node
440 // reads back from the same document, which is precisely the class of bug a
441 // read-your-own-write test cannot see. Gate B caught it once the type was covered.
442 let mut ring = coords.clone();
443 match (ring.first(), ring.last()) {
444 (Some(first), Some(last)) if first != last => ring.push(*first),
445 _ => {}
446 }
447 // `unique.length < 3` after deduplication, raising `INTERNAL_SERVER_ERROR`
448 // `GeoJSON: Loop must have at least 3 different vertices`. Upstream's filter compares
449 // by value and keeps first occurrences, so the closing vertex it just appended is not
450 // counted twice.
451 let mut distinct: Vec<(f64, f64)> = Vec::new();
452 for point in &ring {
453 if !distinct.contains(point) {
454 distinct.push(*point);
455 }
456 }
457 if distinct.len() < 3 {
458 return Err(ParseError::new(
459 parse_rust_core::ErrorCode::InternalServerError,
460 "GeoJSON: Loop must have at least 3 different vertices",
461 ));
462 }
463 Bson::Document({
464 let mut d = Document::new();
465 d.insert("type", "Polygon");
466 d.insert(
467 "coordinates",
468 Bson::Array(vec![Bson::Array(
469 ring.iter()
470 // Stored longitude-first; `PolygonCoder.databaseToJSON` swaps on the
471 // way out (`MongoTransform.js:1362-1372`).
472 .map(|(lat, lng)| {
473 Bson::Array(vec![Bson::Double(*lng), Bson::Double(*lat)])
474 })
475 .collect(),
476 )]),
477 );
478 d
479 })
480 }
481 ParseValue::File { name, .. } => Bson::String(name.clone()),
482 ParseValue::Relation { .. } => {
483 return Err(ParseError::incorrect_type(
484 "Relation fields are not stored on the object".to_string(),
485 ))
486 }
487 })
488}
489
490/// `parseObjectToMongoObjectForCreate`.
491///
492/// Two behaviors that are easy to miss and both wire-visible:
493/// - **Relation values are skipped entirely** (`MongoTransform.js:468-470`). A Relation lives in
494/// a join table, not on the object, so a `{__type:"Relation"}` value in a create body is
495/// dropped rather than stored or rejected.
496/// - **`ACL` becomes three columns**: `_rperm`, `_wperm`, and the legacy `_acl` mirror.
497pub fn parse_object_to_mongo_create(
498 schema: &ClassSchema,
499 object: &ParseMap,
500) -> Result<Document, ParseError> {
501 let mut out = Document::new();
502
503 for (key, value) in object {
504 if let Some((mongo_key, bson)) = field_to_column(schema, key, value)? {
505 out.insert(mongo_key, bson);
506 }
507 }
508
509 Ok(out)
510}
511
512/// Lower one top-level field to its column and stored value.
513///
514/// `Ok(None)` means the field has no column at all, which is true of exactly one thing: a
515/// Relation. Both write paths skip it rather than storing or rejecting it
516/// (`MongoTransform.js:467-470` on create, `:510-513` on update), because the memberships live in
517/// `_Join:<key>:<class>`.
518///
519/// Shared by create and by an update's `Set`, because upstream's two key transforms
520/// (`parseObjectKeyValueToMongoObjectKeyValue` and `transformKeyValueForUpdate`) agree on every
521/// key parse-rust supports. Sharing it is what stops the two paths from drifting into writing one
522/// field under two different column names.
523fn field_to_column(
524 schema: &ClassSchema,
525 key: &str,
526 value: &ParseValue,
527) -> Result<Option<(String, Bson)>, ParseError> {
528 if matches!(value, ParseValue::Relation { .. }) {
529 return Ok(None);
530 }
531 if key == "ACL" {
532 return Err(ParseError::invalid_json(
533 "ACL must be lowered through parse_acl_to_columns, not as a field".to_string(),
534 ));
535 }
536
537 let mongo_key = storage_key(schema, key);
538
539 // A declared Pointer field collapses to "<Class>$<id>" under its `_p_` key.
540 if schema.is_pointer_field(key) {
541 return match value {
542 ParseValue::Pointer {
543 class_name,
544 object_id,
545 } => Ok(Some((
546 mongo_key,
547 Bson::String(format!("{class_name}${object_id}")),
548 ))),
549 ParseValue::Null => Ok(Some((mongo_key, Bson::Null))),
550 _ => Err(ParseError::incorrect_type(format!(
551 "schema mismatch for {}.{key}; expected Pointer but got a non-pointer",
552 schema.class_name
553 ))),
554 };
555 }
556
557 // `expiresAt` keeps its name and is coerced to a BSON Date even when it arrives as a string
558 // (`MongoTransform.js:378-382`). A `_Session` row whose `expiresAt` is stored as a string
559 // never expires, because parse-server compares it as a Date.
560 if key == "expiresAt" {
561 if let ParseValue::String(s) = value {
562 return Ok(Some((mongo_key, date_to_bson(&ParseDate::parse_iso(s)?))));
563 }
564 }
565
566 Ok(Some((mongo_key, plain_value_to_bson(value)?)))
567}
568
569/// `mongoObjectToParseObject`.
570///
571/// UPSTREAM-QUIRK: an unrecognised `_`-prefixed key raises, and it raises a **bare JavaScript
572/// string** rather than a `Parse.Error` (`MongoTransform.js:1236-1237`). On the aggregate path
573/// that surfaces to the client as code 102 with an `undefined` message. Reproduced here as an
574/// error, though parse-rust cannot reproduce the `undefined` message without inventing one.
575///
576/// UPSTREAM-QUIRK: the timestamp columns do not all raise to the same wire form.
577/// `createdAt`, `updatedAt` and `lastUsed` become bare ISO strings while `expiresAt` keeps a full
578/// `{"__type":"Date"}` envelope, all at the top level of the same object
579/// (`MongoTransform.js:1172-1187`). Every one of them is a `ParseValue::Date` here, and the
580/// position-dependent flattening happens once at the response boundary, so there is a single place
581/// to audit rather than a rule spread across the transform.
582pub fn mongo_object_to_parse(schema: &ClassSchema, doc: &Document) -> Result<ParseMap, ParseError> {
583 let mut out = ParseMap::new();
584
585 for (key, value) in doc {
586 // `_acl` is the legacy write-only mirror and is dropped on read, exactly as upstream does
587 // (`MongoTransform.js:1155-1156`, a bare `break`).
588 //
589 // `_rperm` and `_wperm` are NOT dropped here. They are what `parse_rust_rest::acl::raise_acl`
590 // rebuilds the `ACL` field from, and dropping them meant a stored ACL could never be
591 // returned. The response boundary strips any that survive, so they cannot leak.
592 if key == "_acl" {
593 continue;
594 }
595
596 // `_id` is stringified, not decoded. Upstream writes `restObject['objectId'] = '' +
597 // mongoObject[key]` (`MongoTransform.js:1149-1150`), and the coercion is load-bearing
598 // rather than incidental: parse-server's own `addRelation` writes join documents with no
599 // explicit `_id`, so MongoDB generates a BSON ObjectId, and every production database has
600 // them throughout `_Join:*`. Decoding `_id` as an ordinary value rejects those documents
601 // and makes role expansion return nothing at all.
602 if key == "_id" {
603 out.insert(
604 "objectId".to_string(),
605 ParseValue::String(bson_id_string(value)),
606 );
607 continue;
608 }
609
610 let parse_key = match untransform_key(key) {
611 Some(k) => k,
612 None if key.starts_with('_') && key != "__type" => {
613 return Err(ParseError::invalid_query(format!(
614 "bad key in untransform: {key}"
615 )))
616 }
617 None => key.clone(),
618 };
619
620 // A `_p_` field carries "<Class>$<id>".
621 if let Some(stripped) = key.strip_prefix("_p_") {
622 match value {
623 Bson::String(s) => {
624 let (class_name, object_id) = s.split_once('$').ok_or_else(|| {
625 ParseError::incorrect_type(format!(
626 "pointer field {stripped} is malformed: {s}"
627 ))
628 })?;
629 out.insert(
630 stripped.to_string(),
631 ParseValue::Pointer {
632 class_name: class_name.to_string(),
633 object_id: object_id.to_string(),
634 },
635 );
636 }
637 Bson::Null => {
638 out.insert(stripped.to_string(), ParseValue::Null);
639 }
640 _ => {
641 return Err(ParseError::incorrect_type(format!(
642 "pointer field {stripped} is not a string"
643 )))
644 }
645 }
646 continue;
647 }
648
649 // **Three of the stored forms are ambiguous and only the schema resolves them.** A
650 // GeoPoint is a two-element array, a File is a string and a Polygon is a GeoJSON document,
651 // so nothing about the value says what it was. Upstream consults `schema.fields[key].type`
652 // for exactly four types and raises only when the stored value also has the right shape
653 // (`MongoTransform.js:1136-1166`).
654 match schema_raised_value(schema, &parse_key, value) {
655 Some(raised) => out.insert(parse_key, raised),
656 None => out.insert(parse_key, bson_to_parse_value(value)?),
657 };
658 }
659
660 // A Relation has no column, so it is synthesized from the schema on every read
661 // (`MongoTransform.js:1277-1288`). Upstream spreads the synthesized fields *after* the
662 // document, so a stray stored key of the same name is overwritten rather than winning.
663 // `IndexMap::insert` keeps an existing key's position and replaces its value, which is what
664 // the JavaScript spread does too, so field order survives.
665 for (name, target_class) in schema.relation_fields() {
666 out.insert(
667 name.to_string(),
668 ParseValue::Relation {
669 class_name: target_class.to_string(),
670 },
671 );
672 }
673
674 Ok(out)
675}
676
677/// Raise one column using its **declared type**, for the forms the stored value cannot describe.
678///
679/// `mongoObjectToParseObject` consults `schema.fields[key].type` for File, GeoPoint, Polygon and
680/// Bytes, each gated on the stored value passing that coder's `isValidDatabaseObject`
681/// (`MongoTransform.js:1136-1166`). Returns `None` when no rule applies, which is upstream's
682/// fall-through to the ordinary raise.
683///
684/// **The comment on [`bson_to_parse_value`] claimed the stored form is self-describing, and that is
685/// true of exactly the types that do not need this.** A Date is a BSON date, a Bytes is BSON
686/// Binary, a Pointer is under a `_p_` key. The other three are stored as ordinary values, so
687/// without the schema a client that saved a GeoPoint read back a bare two-element array, a File
688/// read back as its bare name, and a Polygon as raw GeoJSON with its coordinates still in
689/// longitude-first order.
690///
691/// **Bytes needs an arm here even though BSON Binary raises without a schema.** `BytesCoder`'s
692/// `isValidDatabaseObject` is `object instanceof mongodb.Binary || this.isBase64Value(object)`, so
693/// a Bytes column holding a **base64 string** is raised to the envelope too. That is not a
694/// hypothetical shape: it is what a document written by an older parse-server holds, and running
695/// against a database an existing deployment already populated is a stated requirement rather than
696/// an aspiration. Leaving it out returned `"aGk="` as a plain string where upstream returns
697/// `{"__type":"Bytes","base64":"aGk="}`, and the data-fidelity gate could not see it because
698/// everything that gate writes is Binary.
699fn schema_raised_value(schema: &ClassSchema, field: &str, value: &Bson) -> Option<ParseValue> {
700 let field_type = schema.field(field)?;
701 match (field_type, value) {
702 // `isBase64Value`: a string matching upstream's own pattern. A Bytes column holding a
703 // string that is *not* valid base64 fails the guard and falls through to the ordinary
704 // raise, which is upstream's behavior rather than an error.
705 //
706 // **Returned verbatim, as the envelope, rather than decoded into bytes.** `databaseToJSON`
707 // is `if (this.isBase64Value(object)) { value = object; }`, so the stored string is passed
708 // through untouched; only the Binary branch encodes. The distinction is invisible for a
709 // canonical string and lossy for anything else, because the pattern accepts strings whose
710 // final characters carry bits that decoding discards: `AB==` decodes and re-encodes to
711 // `AA==`, and `AAB=` to `AAA=`. Both were measured coming back unchanged from a server at
712 // the pin. A `ParseValue::Bytes` has nowhere to keep the original, so the raise builds the
713 // envelope the wire form needs directly. This is the one place the read path deliberately
714 // produces an untyped object.
715 (FieldType::Bytes, Bson::String(s)) if parse_rust_core::is_base64_value(s) => {
716 let mut envelope = ParseMap::new();
717 envelope.insert(
718 "__type".to_string(),
719 ParseValue::String("Bytes".to_string()),
720 );
721 envelope.insert("base64".to_string(), ParseValue::String(s.clone()));
722 Some(ParseValue::Object(envelope))
723 }
724 // `typeof object === 'string'`. The url is not stored and is not synthesized here;
725 // upstream's `databaseToJSON` returns the name alone.
726 (FieldType::File, Bson::String(name)) => Some(ParseValue::File {
727 name: name.clone(),
728 url: None,
729 }),
730 // `Array.isArray(object) && object.length == 2`, stored longitude first, so the raise
731 // swaps them back. Upstream does not check that the two elements are numbers and will
732 // happily return a GeoPoint whose latitude is a string; that shape has no representation
733 // here, so it falls through to the ordinary array raise instead.
734 (FieldType::GeoPoint, Bson::Array(items)) if items.len() == 2 => {
735 match (bson_f64(&items[0]), bson_f64(&items[1])) {
736 (Some(longitude), Some(latitude)) => Some(ParseValue::GeoPoint {
737 latitude,
738 longitude,
739 }),
740 _ => None,
741 }
742 }
743 // `object.type !== 'Polygon' || !Array.isArray(object.coordinates[0])` rejects, then every
744 // point must itself be a two-element array. Only the **first ring** is read, and the
745 // closing point that `JSONToDatabase` appended is not removed, so a polygon read back
746 // carries one more vertex than the client sent. That is upstream's shape, not a rounding
747 // of it.
748 (FieldType::Polygon, Bson::Document(d)) => {
749 if d.get_str("type").ok()? != "Polygon" {
750 return None;
751 }
752 let ring = d.get_array("coordinates").ok()?.first()?.as_array()?;
753 let mut points = Vec::with_capacity(ring.len());
754 for point in ring {
755 let pair = point.as_array()?;
756 if pair.len() != 2 {
757 return None;
758 }
759 // Stored longitude first, raised latitude first.
760 points.push((bson_f64(&pair[1])?, bson_f64(&pair[0])?));
761 }
762 Some(ParseValue::Polygon(points))
763 }
764 _ => None,
765 }
766}
767
768/// A stored coordinate, which may be any BSON number width.
769fn bson_f64(value: &Bson) -> Option<f64> {
770 match value {
771 Bson::Double(n) => Some(*n),
772 Bson::Int32(n) => Some(*n as f64),
773 Bson::Int64(n) => Some(*n as f64),
774 _ => None,
775 }
776}
777
778/// Raise a stored sub-document into Parse form, verbatim.
779///
780/// Used for the `_metadata` sub-keys, which parse-rust round-trips without interpreting.
781pub fn bson_document_to_parse_map(doc: &Document) -> Result<ParseMap, ParseError> {
782 let mut out = ParseMap::new();
783 for (key, value) in doc {
784 out.insert(key.clone(), bson_to_parse_value(value)?);
785 }
786 Ok(out)
787}
788
789/// Lower a Parse map into a stored sub-document, applying no policy.
790///
791/// **Verbatim, and no longer only as far as this layer can be.** This note used to say that a
792/// value carrying a `__type` envelope had been decoded before it arrived, so an offset instant was
793/// already UTC and an extra envelope key already gone, and that the loss was an open parity gap
794/// above this function. That gap was closed: a schema body is now decoded raw, so the envelope
795/// reaches here as the client sent it and is stored that way. The claim outlived the fix.
796///
797/// The inverse of [`bson_document_to_parse_map`], and the way a CLP block reaches
798/// `_metadata.class_permissions` without passing through the column transform: a CLP has keys like
799/// `role:Admin` and `*` that are not fields and must never be renamed or `_p_`-prefixed.
800pub fn parse_map_to_bson_document(map: &ParseMap) -> Result<Document, ParseError> {
801 let mut out = Document::new();
802 for (key, value) in map {
803 out.insert(key.clone(), raw_metadata_value(value)?);
804 }
805 Ok(out)
806}
807
808/// Lower a metadata value with **no policy at any depth**.
809///
810/// Metadata is not a row, and the nested-key guard is a rule about rows. `_metadata` legitimately
811/// holds keys the guard forbids: a CLP has `role:Admin` and `*`, and a stored `defaultValue` is an
812/// arbitrary client-supplied object that upstream stores and echoes verbatim, `$` keys included.
813///
814/// **Recursion is the point of having this at all.** Applying the guard only at the top and then
815/// delegating downward is what a first attempt did, and it still answered 121 for
816/// `{"defaultValue": {"$regex": "literal"}}`, because the guarded wrapper was one level down. The
817/// write path recurses through its guard deliberately, matching upstream's `transformInteriorValue`
818/// which re-enters itself for every array element and object value (`MongoTransform.js:196-199`);
819/// this one has to recurse through no guard for the same reason, in the other direction.
820fn raw_metadata_value(value: &ParseValue) -> Result<Bson, ParseError> {
821 // **Verbatim means the envelope, not the storage form.** Delegating a leaf to
822 // `interior_atom_core` turned a `Date` default into a BSON date and a `Bytes` default into BSON
823 // Binary, which round-trips perfectly here and is wrong on the wire: a parse-server node
824 // reading the same `_SCHEMA` renders them as a bare ISO string and a bare base64 string,
825 // because it never decoded them in the first place. The pin stores what the client sent.
826 //
827 // A local read-back test cannot see this, which is why the one written for the `$regex` case
828 // did not: both sides of it were parse-rust.
829 unchanged_atom_to_bson(value)
830}
831
832/// Raise a stored value. Takes no schema: the stored form is self-describing, which is the
833/// asymmetry with lowering, where the schema decides whether a field is a `_p_` pointer.
834pub fn bson_to_parse_value(value: &Bson) -> Result<ParseValue, ParseError> {
835 Ok(match value {
836 Bson::Null => ParseValue::Null,
837 Bson::Boolean(b) => ParseValue::Bool(*b),
838 // Both integer widths raise to the single JavaScript number type. This is the direction
839 // that is lossless; the lossy direction is `to_bson_number`.
840 Bson::Int32(n) => ParseValue::Number(*n as f64),
841 Bson::Int64(n) => ParseValue::Number(*n as f64),
842 Bson::Double(n) => ParseValue::Number(*n),
843 Bson::String(s) => ParseValue::String(s.clone()),
844 Bson::DateTime(dt) => ParseValue::Date(ParseDate::parse_iso(
845 // The driver's own text quotes the out-of-range millisecond value, and a stored value
846 // does not belong in a client-visible message.
847 &dt.try_to_rfc3339_string()
848 .map_err(|_| ParseError::invalid_json("undecodable stored date"))?,
849 )?),
850 Bson::Binary(b) => ParseValue::Bytes(b.bytes.clone()),
851 Bson::Array(items) => ParseValue::Array(
852 items
853 .iter()
854 .map(bson_to_parse_value)
855 .collect::<Result<Vec<_>, _>>()?,
856 ),
857 Bson::Document(d) => {
858 let mut map = ParseMap::new();
859 for (k, v) in d {
860 map.insert(k.clone(), bson_to_parse_value(v)?);
861 }
862 ParseValue::Object(map)
863 }
864 other => {
865 return Err(ParseError::incorrect_type(format!(
866 "unsupported BSON type in stored document: {other:?}"
867 )))
868 }
869 })
870}
871
872/// JavaScript's `'' + value` for the values an `_id` can hold.
873///
874/// A string is itself, which is the ordinary Parse case. An ObjectId renders as its 24 hex
875/// characters, which is what `String(objectId)` gives in Node and therefore what a client sees
876/// for a document parse-server created without an explicit id. Numbers matter too:
877/// `_GlobalConfig` and `_GraphQLConfig` store an integer `_id` (`MongoTransform.js:254-259`).
878fn bson_id_string(value: &Bson) -> String {
879 match value {
880 Bson::String(s) => s.clone(),
881 Bson::ObjectId(oid) => oid.to_hex(),
882 Bson::Int32(n) => n.to_string(),
883 Bson::Int64(n) => n.to_string(),
884 Bson::Double(n) => parse_rust_core::js_number::to_ecma_string(*n),
885 other => other.to_string(),
886 }
887}
888
889/// Lower a value for use in a query filter on `field`.
890///
891/// Differs from the create path in one way that matters: a declared Pointer field stores
892/// `"Class$id"`, so a query for a pointer has to compare against that string rather than against
893/// the `__type` envelope. Getting this wrong makes every pointer query silently return nothing.
894///
895/// **A bare objectId string is prefixed too, and only the schema knows what to prefix it with.**
896/// `transformTopLevelAtom`'s string case is `if (field && field.type === 'Pointer') return
897/// `${field.targetClass}$${atom}`` (`MongoTransform.js:600-603`), so `{"author": {"$in":
898/// ["abc123"]}}` compares against `"Post$abc123"` upstream. Handling only the `__type` envelope
899/// left the raw-string form comparing against the unprefixed id, which matches no stored value:
900/// the query answers 200 with no results rather than erroring, so nothing indicates the constraint
901/// was meaningless. The class comes from the **schema**, not from the value, because a bare string
902/// carries none.
903pub fn value_to_bson_for_query(
904 schema: &ClassSchema,
905 field: &str,
906 value: &ParseValue,
907) -> Result<Bson, ParseError> {
908 match value {
909 // **A Pointer atom collapses wherever it appears at the top level, and upstream does not
910 // consult the field to do it** (`MongoTransform.js:619-621`: `if (atom.__type == 'Pointer')
911 // return \`${atom.className}$${atom.objectId}\``, outside any check on `field`). Gating it
912 // on a declared Pointer field answered 111 `a top-level Pointer is lowered by key, not by
913 // value` for a query upstream runs, and that sentence is internal vocabulary a client
914 // should never see. The class comes from the operand, not from the schema, so a pointer
915 // compared against an `Object` field or an undeclared one lowers the same way.
916 ParseValue::Pointer {
917 class_name,
918 object_id,
919 } => Ok(Bson::String(format!("{class_name}${object_id}"))),
920 // The bare-string shorthand is the one case that *does* consult the field
921 // (`:601-603`), because a string carries no class of its own.
922 ParseValue::String(object_id) if schema.is_pointer_field(field) => {
923 match schema.field(field).and_then(FieldType::target_class) {
924 Some(target) => Ok(Bson::String(format!("{target}${object_id}"))),
925 None => plain_value_to_bson(value),
926 }
927 }
928 _ => plain_value_to_bson(value),
929 }
930}
931
932/// Lower one entry of an index key document.
933///
934/// Deliberately narrow. Mongo accepts a number for a sort direction and a string for an index
935/// type, and nothing else belongs in a key document. Anything else is refused rather than passed
936/// through, because `createIndexes` would answer with a driver message this layer must not put on
937/// the wire, and because the value is about to be written into `_metadata.indexes` where a
938/// parse-server node reads it back.
939///
940/// Upstream does no validation here at all: `setIndexesWithSchemaFormat` checks the *field names*
941/// against the schema (`MongoStorageAdapter.js:377-390`) and hands the values straight to the
942/// driver. The direction of this divergence is refusing something upstream would have let the
943/// driver refuse, and the code and message are the same either way.
944pub fn index_key_to_bson(index: &str, field: &str, value: &ParseValue) -> Result<Bson, ParseError> {
945 match value {
946 ParseValue::Number(n) => Ok(to_bson_number(*n)),
947 ParseValue::String(s) => Ok(Bson::String(s.clone())),
948 _ => Err(ParseError::invalid_query(format!(
949 "Index {index} has an invalid value for {field}"
950 ))),
951 }
952}
953
954/// `transformWhere`: lower a query tree into a Mongo filter document.
955///
956/// Every sub-query of a logical clause is lowered with the **same schema**, so `_p_` prefixing and
957/// `objectId` -> `_id` apply inside a branch exactly as they do at the top level
958/// (`MongoTransform.js:290-296`).
959pub fn transform_where(schema: &ClassSchema, query: &Query) -> Result<Document, ParseError> {
960 let mut out = Document::new();
961 for clause in &query.clauses {
962 match clause {
963 Clause::Field(constraint) => {
964 let key = storage_key(schema, &constraint.field);
965 let entry = comparison_to_bson(schema, constraint)?;
966 // Several constraints on one field must merge rather than overwrite. Overwriting
967 // is the bug the `tbraun96/parse-rs` query builder shipped, and it silently drops
968 // a constraint, which broadens the result set.
969 merge_constraint(&mut out, key, entry)?;
970 }
971 Clause::Or(branches) => {
972 insert_logical(&mut out, "$or", lower_branches(schema, branches)?)
973 }
974 Clause::And(branches) => {
975 insert_logical(&mut out, "$and", lower_branches(schema, branches)?)
976 }
977 Clause::Nor(branches) => {
978 insert_logical(&mut out, "$nor", lower_branches(schema, branches)?)
979 }
980 }
981 }
982 Ok(out)
983}
984
985fn lower_branches(schema: &ClassSchema, branches: &[Query]) -> Result<Vec<Bson>, ParseError> {
986 branches
987 .iter()
988 .map(|q| transform_where(schema, q).map(Bson::Document))
989 .collect()
990}
991
992/// Insert a logical operator without letting a second one of the same name overwrite the first.
993///
994/// A query document is a map, so two producers of `$or` at one level would collide and one would
995/// silently vanish, which broadens the result set. That is not hypothetical here: pointer
996/// permissions compose disjunctively and can arrive alongside a client's own `$or`. Combining
997/// under `$and` is the only lowering that preserves both.
998fn insert_logical(filter: &mut Document, key: &str, branches: Vec<Bson>) {
999 // Two `$and`s at one level are the same conjunction, so their branch lists concatenate.
1000 if key == "$and" {
1001 if let Some(Bson::Array(mut existing)) = filter.remove("$and") {
1002 existing.extend(branches);
1003 filter.insert("$and", Bson::Array(existing));
1004 return;
1005 }
1006 filter.insert("$and", Bson::Array(branches));
1007 return;
1008 }
1009
1010 let Some(existing) = filter.remove(key) else {
1011 filter.insert(key, Bson::Array(branches));
1012 return;
1013 };
1014
1015 let mut conjuncts: Vec<Bson> = match filter.remove("$and") {
1016 Some(Bson::Array(items)) => items,
1017 Some(other) => vec![other],
1018 None => Vec::new(),
1019 };
1020 let mut first = Document::new();
1021 first.insert(key, existing);
1022 let mut second = Document::new();
1023 second.insert(key, Bson::Array(branches));
1024 conjuncts.push(Bson::Document(first));
1025 conjuncts.push(Bson::Document(second));
1026 filter.insert("$and", Bson::Array(conjuncts));
1027}
1028
1029/// Merge a new constraint into an existing filter entry for the same field.
1030fn merge_constraint(filter: &mut Document, key: String, entry: Bson) -> Result<(), ParseError> {
1031 match filter.remove(&key) {
1032 None => {
1033 filter.insert(key, entry);
1034 }
1035 Some(existing) => match (existing, entry) {
1036 // Two operator documents merge key-wise: `{$gt: 1}` plus `{$lt: 5}` is a range.
1037 (Bson::Document(mut a), Bson::Document(b)) => {
1038 for (k, v) in b {
1039 a.insert(k, v);
1040 }
1041 filter.insert(key, Bson::Document(a));
1042 }
1043 // Anything involving a bare equality cannot merge: Mongo has no way to express
1044 // "equals 1 and equals 2", and silently keeping one would drop the other.
1045 _ => {
1046 return Err(ParseError::invalid_query(format!(
1047 "conflicting constraints on field {key}"
1048 )))
1049 }
1050 },
1051 }
1052 Ok(())
1053}
1054
1055/// Lower one comparison. Total over [`Comparison`], so adding a variant fails to compile here
1056/// rather than silently matching everything.
1057fn comparison_to_bson(schema: &ClassSchema, constraint: &Constraint) -> Result<Bson, ParseError> {
1058 // **Which converter an operand goes through is decided by the field, not by the operator**
1059 // (`MongoTransform.js:656-662`): `(inArray || isNestedKey) ? transformInteriorAtom :
1060 // transformTopLevelAtom`. An `Array`-typed field and a dotted key both hold *interior* values,
1061 // where a Pointer keeps its `__type` envelope instead of collapsing to `Class$id`.
1062 //
1063 // Using the top-level converter for everything is what made `{"who": {"$in": [<pointer>]}}` on
1064 // an array-of-pointers field answer 111 `a top-level Pointer is lowered by key, not by value`,
1065 // which is both a wrong answer to an ordinary `containedIn` and an internal sentence on the
1066 // wire. The stored elements carry the envelope, so the query operand has to as well or it
1067 // matches nothing even when it does not error.
1068 //
1069 // **There are three rules here, not one, and they disagree.** Reading only the constraint rule
1070 // and applying it everywhere is how shorthand equality on an `Array` field ended up interior
1071 // when upstream has it top-level:
1072 //
1073 // | position | rule | upstream |
1074 // |---|---|---|
1075 // | an operator inside a constraint document | `inArray \|\| isNestedKey` | `:660-662` |
1076 // | shorthand equality | `isNestedKey` **alone** | `:346-348` |
1077 // | `$all` | interior unconditionally | `:743` |
1078 //
1079 // The middle row looks like an oversight upstream and is not. The `Array` case never reaches
1080 // it: a non-array value on an `Array` field is taken by the `$all` wrap at `:341-343` above,
1081 // so by the time control arrives at `:346` the only array-field values left are arrays, and
1082 // those go top-level.
1083 let dotted = constraint.field.contains('.');
1084 let in_array = schema
1085 .field(&constraint.field)
1086 .is_some_and(|f| matches!(f, FieldType::Array));
1087 let constraint_position = if in_array || dotted {
1088 AtomPosition::Interior
1089 } else {
1090 AtomPosition::TopLevel
1091 };
1092 let shorthand_position = if dotted {
1093 AtomPosition::Interior
1094 } else {
1095 AtomPosition::TopLevel
1096 };
1097 // **Recognition happens here, not in the parser.** The operand arrives raw, with no `__type`
1098 // envelope interpreted at any depth, because which envelopes count depends on the field and
1099 // only this layer knows it. See `parse_rust_core::AtomPosition`.
1100 //
1101 // **`interior_query_atom_to_bson`, not `interior_value_to_bson`.** The two differ by the
1102 // nested-key guard, which belongs to writes alone: a query operand of `{"$regex": "^xy"}` is
1103 // what `containsAllStartingWith` sends, and refusing it with `INVALID_NESTED_KEY` turns a
1104 // legitimate query into a 121. Reaching for the write converter here is the same
1105 // shared-function mistake that put a compiled regex into a stored array, made in the opposite
1106 // direction: one function refused what the other must accept.
1107 let lower = |v: &ParseValue, position: AtomPosition| -> Result<Bson, ParseError> {
1108 let atom = recognize_atom(v.clone(), position);
1109 match position {
1110 AtomPosition::Interior => interior_query_atom_to_bson(&atom),
1111 AtomPosition::TopLevel => {
1112 // **The top-level position refuses a non-atom, and it is a different refusal from
1113 // the shorthand one.** `transformConstraint` wraps its chosen transform in
1114 // `transformer`, which turns `CannotTransform` into `bad atom: ${JSON.stringify}`
1115 // (`MongoTransform.js:663-669`). Shorthand equality reaches its own throw site
1116 // instead and says `You cannot use ${value} as a query parameter.` Same code, two
1117 // messages, decided by which of the two call sites the value arrived through.
1118 //
1119 // Note that the empty-collection exemption above does **not** apply here. That
1120 // exemption comes from `transformConstraint` returning early when the *constraint
1121 // document* has no keys, which says nothing about an operand: `{"$ne": {}}` is a
1122 // `bad atom: {}`. Measured at the pin.
1123 if matches!(atom, ParseValue::Object(_) | ParseValue::Array(_)) {
1124 return Err(ParseError::invalid_json(format!(
1125 "bad atom: {}",
1126 atom.to_json()
1127 )));
1128 }
1129 value_to_bson_for_query(schema, &constraint.field, &atom)
1130 }
1131 }
1132 };
1133 let value = |v: &ParseValue| -> Result<Bson, ParseError> { lower(v, constraint_position) };
1134 // `$in` and `$nin` flatten one level (`MongoTransform.js:721-735`): an element that is itself
1135 // an array contributes its own elements rather than nesting. Nothing else flattens.
1136 let flatten_each = |items: &Vec<ParseValue>| -> Result<Vec<Bson>, ParseError> {
1137 let mut out = Vec::with_capacity(items.len());
1138 for item in items {
1139 match item {
1140 ParseValue::Array(inner) => {
1141 for nested in inner {
1142 out.push(value(nested)?);
1143 }
1144 }
1145 other => out.push(value(other)?),
1146 }
1147 }
1148 Ok(out)
1149 };
1150
1151 Ok(match &constraint.comparison {
1152 // **An empty collection is neither an atom nor an error, and it is answered before either
1153 // of the two arms below.** Upstream reaches shorthand equality only after
1154 // `transformConstraint` declines, and for `[]` or `{}` it does not decline: its key loop
1155 // simply does not run and it returns the empty answer document it started with
1156 // (`MongoTransform.js:672-676`, `:960`). So `{"tags": []}` and `{"meta": {}}` both lower to
1157 // `{field: {}}`, which is an **equality against an empty document**: it matches a row whose
1158 // field holds `{}` and nothing else. Not an absent constraint. An earlier version of this
1159 // note called it "matches every row", which is what an empty *constraint document* would
1160 // do if Mongo read it that way, and Mongo does not: probed against a live server, `{"meta":
1161 // {}}` returned only the row storing an empty object and `{"tags": []}` returned none.
1162 //
1163 // Order is the whole of it. Put this after the `$all` wrap and an empty object on an
1164 // `Array` field becomes `{$all: [{}]}`; lower it as an ordinary value and an empty array
1165 // becomes `{field: []}`, which matches only rows holding an empty array. Both narrow a
1166 // query upstream answers with everything. Measured at the pin.
1167 Comparison::Equal(ParseValue::Array(items)) if items.is_empty() => {
1168 Bson::Document(Document::new())
1169 }
1170 Comparison::Equal(ParseValue::Object(map)) if map.is_empty() => {
1171 Bson::Document(Document::new())
1172 }
1173 // **A non-array value equated with an Array-typed field means "the array contains it"**,
1174 // and upstream spells that out as `{$all: [transformInteriorAtom(value)]}`
1175 // (`MongoTransform.js:341-343`). For a scalar the wrap is equivalent to a bare equality,
1176 // because MongoDB already matches an array element against a scalar. For a **Pointer** it
1177 // is not equivalent at all: the interior transform keeps the `__type` envelope, which is
1178 // what an array of pointers actually stores, where the top-level path would try to lower
1179 // it to `"Class$id"` and refuse. `query.equalTo('tags', someObject)` errored with 111 here
1180 // for exactly that reason.
1181 Comparison::Equal(v)
1182 if matches!(schema.field(&constraint.field), Some(FieldType::Array))
1183 && !matches!(v, ParseValue::Array(_)) =>
1184 {
1185 operator("$all", Bson::Array(vec![lower(v, AtomPosition::Interior)?]))
1186 }
1187 // Shorthand equality takes the middle rule: the dotted-key test alone.
1188 //
1189 // **And in the top-level position it must be an atom.** `transformTopLevelAtom` returns
1190 // `CannotTransform` for a generic object or an array, and the caller turns that into
1191 // `INVALID_JSON` rather than a query (`MongoTransform.js:350-354`). The interior position
1192 // has no such refusal: its final arm is `return atom`, so a dotted key compares whatever it
1193 // was given.
1194 //
1195 // Accepting it instead is not a harmless extra: `{"meta": {"a": 1}}` upstream is a 107, and
1196 // here it was a query that ran and returned rows. A client testing for the error saw a
1197 // result set.
1198 Comparison::Equal(v) => {
1199 let atom = recognize_atom(v.clone(), shorthand_position);
1200 if shorthand_position == AtomPosition::TopLevel
1201 && matches!(atom, ParseValue::Object(_) | ParseValue::Array(_))
1202 {
1203 // `${value}` in a template literal, so an array joins on commas and any object
1204 // renders as `[object Object]`. `js_string` is the same coercion the `$regex`
1205 // path needs.
1206 return Err(ParseError::invalid_json(format!(
1207 "You cannot use {} as a query parameter.",
1208 parse_rust_core::js_number::to_ecma_display(&atom)
1209 )));
1210 }
1211 match shorthand_position {
1212 AtomPosition::Interior => interior_query_atom_to_bson(&atom)?,
1213 AtomPosition::TopLevel => {
1214 value_to_bson_for_query(schema, &constraint.field, &atom)?
1215 }
1216 }
1217 }
1218 // Keeps the wrapper, which is what lets it share a field with another operator.
1219 Comparison::EqualOperator(v) => operator("$eq", value(v)?),
1220 Comparison::NotEqual(v) => operator("$ne", value(v)?),
1221 Comparison::GreaterThan(v) => operator("$gt", value(v)?),
1222 Comparison::GreaterThanOrEqual(v) => operator("$gte", value(v)?),
1223 Comparison::LessThan(v) => operator("$lt", value(v)?),
1224 Comparison::LessThanOrEqual(v) => operator("$lte", value(v)?),
1225 Comparison::In(items) => operator("$in", Bson::Array(flatten_each(items)?)),
1226 Comparison::NotIn(items) => operator("$nin", Bson::Array(flatten_each(items)?)),
1227 Comparison::Exists(b) => operator("$exists", Bson::Boolean(*b)),
1228 // `$all` maps its values through the *interior* atom transform, not the top-level one
1229 // (`MongoTransform.js:743`), so a nested pointer keeps its `__type` envelope here even
1230 // though the same pointer compared with `=` would collapse to `"Class$id"`.
1231 //
1232 // Upstream also raises `INVALID_JSON` `All $all values must be of regex type or none:
1233 // <values>` when some but not all of the values are regexes (`:746-751`). That is
1234 // reachable: an element is a regex when it is a `{"$regex": ...}` object, which is what
1235 // `containsAllStartingWith` sends. An earlier version of this comment called the branch
1236 // unreachable on the grounds that `ParseValue` has no regex variant, which is true of the
1237 // *variant* and irrelevant to the *shape*.
1238 Comparison::All(items) => {
1239 let lowered = items
1240 .iter()
1241 .map(|v| lower(v, AtomPosition::Interior))
1242 .collect::<Result<Vec<_>, _>>()?;
1243 let regexes = lowered
1244 .iter()
1245 .filter(|b| matches!(b, Bson::RegularExpression(_)))
1246 .count();
1247 if regexes > 0 && regexes != lowered.len() {
1248 // Upstream appends the values through JavaScript string concatenation, so the
1249 // message ends with the array rendered by `Array.prototype.join`
1250 // (`MongoTransform.js:746-751`). Dropping them made the message a prefix of
1251 // upstream's rather than upstream's.
1252 return Err(ParseError::invalid_json(format!(
1253 "All $all values must be of regex type or none: {}",
1254 items
1255 .iter()
1256 .map(js_join_element)
1257 .collect::<Vec<_>>()
1258 .join(",")
1259 )));
1260 }
1261 operator("$all", Bson::Array(lowered))
1262 }
1263 // The pattern stays a **string**, not a compiled regex (`MongoTransform.js:755-761`), and
1264 // `$options` is a sibling key rather than a flag folded into it (`:773-775`). Compiling it
1265 // here would change the BSON type the driver sends.
1266 Comparison::Regex { pattern, options } => {
1267 let mut d = Document::new();
1268 d.insert("$regex", Bson::String(pattern.clone()));
1269 if let Some(options) = options {
1270 d.insert("$options", Bson::String(options.clone()));
1271 }
1272 Bson::Document(d)
1273 }
1274 })
1275}
1276
1277fn operator(op: &str, value: Bson) -> Bson {
1278 let mut d = Document::new();
1279 d.insert(op, value);
1280 Bson::Document(d)
1281}
1282
1283/// `transformUpdate`: lower an update AST into a Mongo update document.
1284///
1285/// The operator mapping is `transformUpdateOperator` (`MongoTransform.js:974-1033`). Note the
1286/// asymmetry between `Add`/`AddUnique`, which wrap their values in `$each`, and `Remove`, which
1287/// does not: `$pullAll` takes a bare array.
1288pub fn transform_update(schema: &ClassSchema, update: &Update) -> Result<Document, ParseError> {
1289 let mut out = Document::new();
1290
1291 for (key, op) in update {
1292 match op {
1293 UpdateValue::Set(value) => {
1294 // A Relation has no column; `field_to_column` returns `None` for it.
1295 if let Some((mongo_key, bson)) = field_to_column(schema, key, value)? {
1296 push_op(&mut out, "$set", mongo_key, bson);
1297 }
1298 }
1299 UpdateValue::Increment(amount) => {
1300 push_op(
1301 &mut out,
1302 "$inc",
1303 storage_key(schema, key),
1304 to_bson_number(*amount),
1305 );
1306 }
1307 UpdateValue::SetOnInsert(value) => {
1308 // Through `field_to_column` like `Set`, because the value is an ordinary one and
1309 // a pointer written this way still belongs in its `_p_` column.
1310 if let Some((mongo_key, bson)) = field_to_column(schema, key, value)? {
1311 push_op(&mut out, "$setOnInsert", mongo_key, bson);
1312 }
1313 }
1314 UpdateValue::Add(values) => {
1315 push_op(&mut out, "$push", storage_key(schema, key), each(values)?);
1316 }
1317 UpdateValue::AddUnique(values) => {
1318 push_op(
1319 &mut out,
1320 "$addToSet",
1321 storage_key(schema, key),
1322 each(values)?,
1323 );
1324 }
1325 UpdateValue::Remove(values) => {
1326 push_op(
1327 &mut out,
1328 "$pullAll",
1329 storage_key(schema, key),
1330 Bson::Array(interior_atoms(values)?),
1331 );
1332 }
1333 // The argument is the empty string, not `null` or `true`
1334 // (`MongoTransform.js:976-981`).
1335 UpdateValue::Unset => {
1336 push_op(
1337 &mut out,
1338 "$unset",
1339 storage_key(schema, key),
1340 Bson::String(String::new()),
1341 );
1342 }
1343 }
1344 }
1345
1346 Ok(out)
1347}
1348
1349fn interior_atoms(values: &[ParseValue]) -> Result<Vec<Bson>, ParseError> {
1350 values.iter().map(interior_value_to_bson).collect()
1351}
1352
1353/// `{$each: [...]}`, the argument shape `$push` and `$addToSet` take.
1354fn each(values: &[ParseValue]) -> Result<Bson, ParseError> {
1355 let mut d = Document::new();
1356 d.insert("$each", Bson::Array(interior_atoms(values)?));
1357 Ok(Bson::Document(d))
1358}
1359
1360/// Put one field under one Mongo update operator, creating the operator's sub-document once.
1361///
1362/// Order matters and is preserved on purpose: upstream assigns into `mongoUpdate[op][key]`
1363/// (`MongoTransform.js:524-530`), so an operator keeps the position of its first use rather than
1364/// moving to the end each time a field is added to it.
1365fn push_op(out: &mut Document, op: &str, key: String, value: Bson) {
1366 if let Ok(existing) = out.get_document_mut(op) {
1367 existing.insert(key, value);
1368 return;
1369 }
1370 let mut sub = Document::new();
1371 sub.insert(key, value);
1372 out.insert(op, sub);
1373}
1374
1375#[cfg(test)]
1376mod tests {
1377 use super::*;
1378 use parse_rust_storage::FieldType;
1379
1380 /// **The four columns whose stored form does not describe itself**, raised by declared type.
1381 ///
1382 /// The legacy `Bytes` row is the one no gate can reach: the data-fidelity differential writes
1383 /// through the SDK, which always produces BSON Binary, so a column holding the *string* form
1384 /// that an older parse-server wrote is only reachable by constructing the document. Since
1385 /// running against a database an existing deployment already populated is a requirement, the
1386 /// corpus that matters most is the one this project cannot generate for itself.
1387 #[test]
1388 fn ambiguous_columns_are_raised_by_their_declared_type() {
1389 let schema = ClassSchema::new("M")
1390 .with_field("pic", FieldType::File)
1391 .with_field("spot", FieldType::GeoPoint)
1392 .with_field("bin", FieldType::Bytes)
1393 .with_field("label", FieldType::String);
1394
1395 let mut doc = Document::new();
1396 doc.insert("_id", "abc");
1397 doc.insert("pic", "avatar.png");
1398 doc.insert(
1399 "spot",
1400 Bson::Array(vec![Bson::Double(2.0), Bson::Double(1.0)]),
1401 );
1402 // The legacy shape: a base64 **string**, not BSON Binary. Deliberately *non-canonical*:
1403 // `AB==` is accepted by upstream's pattern and decodes to a byte whose re-encoding is
1404 // `AA==`, so a test using a canonical string like `aGk=` passes whether the string is
1405 // preserved or round-tripped through bytes and cannot tell the two apart.
1406 doc.insert("bin", "AB==");
1407 doc.insert("label", "avatar.png");
1408
1409 let out = mongo_object_to_parse(&schema, &doc).expect("raise");
1410 assert!(
1411 matches!(out.get("pic"), Some(ParseValue::File { name, url: None }) if name == "avatar.png"),
1412 "{:?}",
1413 out.get("pic")
1414 );
1415 assert!(
1416 matches!(
1417 out.get("spot"),
1418 Some(ParseValue::GeoPoint { latitude, longitude })
1419 if *latitude == 1.0 && *longitude == 2.0
1420 ),
1421 "{:?}",
1422 out.get("spot")
1423 );
1424 // The envelope, carrying the stored string exactly as written.
1425 assert_eq!(
1426 out.get("bin").map(ParseValue::to_json).as_deref(),
1427 Some(r#"{"__type":"Bytes","base64":"AB=="}"#),
1428 "a legacy Bytes string is preserved, not canonicalized"
1429 );
1430 // The control: an identical string in a `String` column stays a string, so the assertions
1431 // above are about the schema and not about the value.
1432 assert!(
1433 matches!(out.get("label"), Some(ParseValue::String(s)) if s == "avatar.png"),
1434 "{:?}",
1435 out.get("label")
1436 );
1437
1438 // A `Bytes` column holding a string that is not valid base64 fails `isBase64Value` and
1439 // falls through to the ordinary raise rather than erroring, which is upstream's behavior.
1440 let mut doc = Document::new();
1441 doc.insert("_id", "abc");
1442 doc.insert("bin", "not base64!");
1443 let out = mongo_object_to_parse(&schema, &doc).expect("raise");
1444 assert!(
1445 matches!(out.get("bin"), Some(ParseValue::String(s)) if s == "not base64!"),
1446 "{:?}",
1447 out.get("bin")
1448 );
1449 }
1450
1451 fn post_schema() -> ClassSchema {
1452 ClassSchema::new("Post")
1453 .with_field("title", FieldType::String)
1454 .with_field("views", FieldType::Number)
1455 .with_field(
1456 "author",
1457 FieldType::Pointer {
1458 target_class: "_User".into(),
1459 },
1460 )
1461 }
1462
1463 fn map(pairs: Vec<(&str, ParseValue)>) -> ParseMap {
1464 let mut m = ParseMap::new();
1465 for (k, v) in pairs {
1466 m.insert(k.to_string(), v);
1467 }
1468 m
1469 }
1470
1471 #[test]
1472 fn renamed_keys_round_trip() {
1473 let s = post_schema();
1474 assert_eq!(storage_key(&s, "objectId"), "_id");
1475 assert_eq!(storage_key(&s, "createdAt"), "_created_at");
1476 assert_eq!(storage_key(&s, "updatedAt"), "_updated_at");
1477 assert_eq!(storage_key(&s, "title"), "title");
1478 assert_eq!(storage_key(&s, "author"), "_p_author");
1479
1480 assert_eq!(untransform_key("_id").as_deref(), Some("objectId"));
1481 assert_eq!(untransform_key("_created_at").as_deref(), Some("createdAt"));
1482 assert_eq!(untransform_key("_p_author").as_deref(), Some("author"));
1483 assert_eq!(untransform_key("title"), None);
1484 }
1485
1486 /// The rule Gate B exists to prove.
1487 #[test]
1488 fn integral_numbers_in_i32_range_store_as_int32() {
1489 assert_eq!(to_bson_number(0.0), Bson::Int32(0));
1490 assert_eq!(to_bson_number(42.0), Bson::Int32(42));
1491 assert_eq!(to_bson_number(-42.0), Bson::Int32(-42));
1492 assert_eq!(to_bson_number(i32::MAX as f64), Bson::Int32(i32::MAX));
1493 assert_eq!(to_bson_number(i32::MIN as f64), Bson::Int32(i32::MIN));
1494 }
1495
1496 #[test]
1497 fn everything_else_stores_as_double() {
1498 assert_eq!(to_bson_number(1.5), Bson::Double(1.5));
1499 // Just past the i32 range, still integral.
1500 assert_eq!(
1501 to_bson_number(i32::MAX as f64 + 1.0),
1502 Bson::Double(i32::MAX as f64 + 1.0)
1503 );
1504 assert_eq!(to_bson_number(1e20), Bson::Double(1e20));
1505 }
1506
1507 #[test]
1508 fn a_pointer_field_collapses_to_class_dollar_id() {
1509 let doc = parse_object_to_mongo_create(
1510 &post_schema(),
1511 &map(vec![(
1512 "author",
1513 ParseValue::Pointer {
1514 class_name: "_User".into(),
1515 object_id: "abc123".into(),
1516 },
1517 )]),
1518 )
1519 .expect("transform");
1520 assert_eq!(doc.get_str("_p_author").expect("_p_author"), "_User$abc123");
1521 assert!(
1522 !doc.contains_key("author"),
1523 "must not also store the raw key"
1524 );
1525 }
1526
1527 /// UPSTREAM-QUIRK. A nested pointer keeps its full shape, because the collapse is a key
1528 /// transformation and an interior value has no key.
1529 #[test]
1530 fn a_nested_pointer_keeps_its_type_envelope() {
1531 let doc = parse_object_to_mongo_create(
1532 &post_schema(),
1533 &map(vec![(
1534 "tags",
1535 ParseValue::Array(vec![ParseValue::Pointer {
1536 class_name: "Tag".into(),
1537 object_id: "t1".into(),
1538 }]),
1539 )]),
1540 )
1541 .expect("transform");
1542 let arr = doc.get_array("tags").expect("tags");
1543 let nested = arr[0].as_document().expect("document");
1544 assert_eq!(nested.get_str("__type").expect("__type"), "Pointer");
1545 assert_eq!(nested.get_str("className").expect("className"), "Tag");
1546 }
1547
1548 #[test]
1549 fn relation_values_are_dropped_not_stored() {
1550 let doc = parse_object_to_mongo_create(
1551 &post_schema(),
1552 &map(vec![
1553 ("title", ParseValue::String("x".into())),
1554 (
1555 "comments",
1556 ParseValue::Relation {
1557 class_name: "Comment".into(),
1558 },
1559 ),
1560 ]),
1561 )
1562 .expect("transform");
1563 assert!(doc.contains_key("title"));
1564 assert!(
1565 !doc.contains_key("comments"),
1566 "a Relation lives in a join table, not on the object"
1567 );
1568 }
1569
1570 #[test]
1571 fn read_back_restores_keys_and_pointers() {
1572 let mut doc = Document::new();
1573 doc.insert("_id", "objid1");
1574 doc.insert("title", "hello");
1575 doc.insert("views", Bson::Int32(7));
1576 doc.insert("_p_author", "_User$abc123");
1577 doc.insert(
1578 "_created_at",
1579 Bson::DateTime(bson::DateTime::from_millis(1_700_000_000_000)),
1580 );
1581
1582 let parsed = mongo_object_to_parse(&post_schema(), &doc).expect("untransform");
1583 assert!(matches!(parsed.get("objectId"), Some(ParseValue::String(s)) if s == "objid1"));
1584 assert!(matches!(parsed.get("views"), Some(ParseValue::Number(n)) if *n == 7.0));
1585 assert!(matches!(
1586 parsed.get("author"),
1587 Some(ParseValue::Pointer { class_name, object_id })
1588 if class_name == "_User" && object_id == "abc123"
1589 ));
1590 assert!(matches!(parsed.get("createdAt"), Some(ParseValue::Date(_))));
1591 }
1592
1593 /// Regression: these used to be dropped here, which meant `raise_acl` never saw them and a
1594 /// stored ACL could never be returned to a client.
1595 #[test]
1596 fn permission_columns_survive_for_the_acl_rebuild() {
1597 let mut doc = Document::new();
1598 doc.insert("title", "x");
1599 doc.insert("_rperm", Bson::Array(vec![Bson::String("*".into())]));
1600 doc.insert("_wperm", Bson::Array(vec![]));
1601 doc.insert("_acl", Document::new());
1602
1603 let parsed = mongo_object_to_parse(&post_schema(), &doc).expect("untransform");
1604 assert!(parsed.get("_rperm").is_some(), "raise_acl needs this");
1605 assert!(parsed.get("_wperm").is_some(), "raise_acl needs this");
1606 assert!(
1607 parsed.get("_acl").is_none(),
1608 "the legacy mirror is write-only and is dropped on read"
1609 );
1610 }
1611
1612 #[test]
1613 fn internal_columns_survive_under_their_own_names() {
1614 // Login needs to read the hash. It must NOT come back as `password`, which is the name
1615 // upstream raises it under and the one a response filter then has to strip again.
1616 let mut doc = Document::new();
1617 doc.insert("_hashed_password", "$2b$10$abc");
1618 doc.insert("_session_token", "r:tok");
1619 let parsed = mongo_object_to_parse(&post_schema(), &doc).expect("untransform");
1620 assert!(parsed.get("_hashed_password").is_some());
1621 assert!(
1622 parsed.get("password").is_none(),
1623 "the hash must never be raised under a user-facing name"
1624 );
1625 // This one IS renamed, matching upstream.
1626 assert!(parsed.get("sessionToken").is_some());
1627 }
1628
1629 #[test]
1630 fn an_unknown_underscore_key_is_refused_rather_than_passed_through() {
1631 let mut doc = Document::new();
1632 doc.insert("_mystery", "x"); // not in INTERNAL_COLUMNS
1633 let err = mongo_object_to_parse(&post_schema(), &doc).unwrap_err();
1634 assert!(err.message.contains("bad key in untransform"));
1635 }
1636
1637 #[test]
1638 fn int64_and_int32_both_raise_to_one_number_type() {
1639 let mut doc = Document::new();
1640 doc.insert("a", Bson::Int32(1));
1641 doc.insert("b", Bson::Int64(2));
1642 doc.insert("c", Bson::Double(3.5));
1643 let parsed = mongo_object_to_parse(&post_schema(), &doc).expect("untransform");
1644 for (k, expected) in [("a", 1.0), ("b", 2.0), ("c", 3.5)] {
1645 assert!(matches!(parsed.get(k), Some(ParseValue::Number(n)) if *n == expected));
1646 }
1647 }
1648}
1649
1650#[cfg(test)]
1651mod query_tree_tests {
1652 use super::*;
1653 use bson::doc;
1654 use parse_rust_storage::{Constraint, FieldType};
1655
1656 fn post_schema() -> ClassSchema {
1657 ClassSchema::new("Post")
1658 .with_field("title", FieldType::String)
1659 .with_field("tags", FieldType::Array)
1660 .with_field(
1661 "author",
1662 FieldType::Pointer {
1663 target_class: "_User".into(),
1664 },
1665 )
1666 }
1667
1668 fn map(pairs: Vec<(&str, ParseValue)>) -> ParseMap {
1669 let mut m = ParseMap::new();
1670 for (k, v) in pairs {
1671 m.insert(k.to_string(), v);
1672 }
1673 m
1674 }
1675
1676 fn eq(field: &str, value: &str) -> Query {
1677 Query::from_constraints(vec![Constraint::equal(
1678 field,
1679 ParseValue::String(value.into()),
1680 )])
1681 }
1682
1683 #[test]
1684 fn a_disjunction_lowers_to_dollar_or() {
1685 let mut q = Query::new();
1686 q.push(Clause::Or(vec![eq("title", "a"), eq("title", "b")]));
1687 let out = transform_where(&post_schema(), &q).expect("lower");
1688 assert_eq!(out, doc! { "$or": [ { "title": "a" }, { "title": "b" } ] });
1689 }
1690
1691 #[test]
1692 fn and_and_nor_lower_to_their_own_operators() {
1693 let mut q = Query::new();
1694 q.push(Clause::And(vec![eq("title", "a")]));
1695 assert_eq!(
1696 transform_where(&post_schema(), &q).expect("lower"),
1697 doc! { "$and": [ { "title": "a" } ] }
1698 );
1699
1700 let mut q = Query::new();
1701 q.push(Clause::Nor(vec![eq("title", "a")]));
1702 assert_eq!(
1703 transform_where(&post_schema(), &q).expect("lower"),
1704 doc! { "$nor": [ { "title": "a" } ] }
1705 );
1706 }
1707
1708 /// The key transform applies inside a branch exactly as it does at the top level. Getting this
1709 /// wrong makes a pointer permission's `$or` match nothing while looking correct.
1710 #[test]
1711 fn a_branch_gets_the_same_key_and_value_transform() {
1712 let author = ParseValue::Pointer {
1713 class_name: "_User".into(),
1714 object_id: "u1".into(),
1715 };
1716 let mut q = Query::new();
1717 q.push(Clause::Or(vec![
1718 Query::from_constraints(vec![Constraint::equal("author", author)]),
1719 Query::from_constraints(vec![Constraint::equal(
1720 "objectId",
1721 ParseValue::String("oid1".into()),
1722 )]),
1723 ]));
1724 assert_eq!(
1725 transform_where(&post_schema(), &q).expect("lower"),
1726 doc! { "$or": [ { "_p_author": "_User$u1" }, { "_id": "oid1" } ] }
1727 );
1728 }
1729
1730 /// A query document is a map, so two `$or`s at one level would collide. Losing one broadens
1731 /// the result set, which is an authorization failure rather than a cosmetic difference.
1732 #[test]
1733 fn two_disjunctions_at_one_level_combine_rather_than_overwrite() {
1734 let mut q = Query::new();
1735 q.push(Clause::Or(vec![eq("title", "a"), eq("title", "b")]));
1736 q.push(Clause::Or(vec![eq("title", "c"), eq("title", "d")]));
1737 let out = transform_where(&post_schema(), &q).expect("lower");
1738
1739 assert!(
1740 !out.contains_key("$or"),
1741 "neither disjunction may survive alone"
1742 );
1743 let conjuncts = out.get_array("$and").expect("$and");
1744 assert_eq!(conjuncts.len(), 2);
1745 assert_eq!(
1746 conjuncts[0],
1747 Bson::Document(doc! { "$or": [ { "title": "a" }, { "title": "b" } ] })
1748 );
1749 assert_eq!(
1750 conjuncts[1],
1751 Bson::Document(doc! { "$or": [ { "title": "c" }, { "title": "d" } ] })
1752 );
1753 }
1754
1755 #[test]
1756 fn two_conjunctions_at_one_level_concatenate() {
1757 let mut q = Query::new();
1758 q.push(Clause::And(vec![eq("title", "a")]));
1759 q.push(Clause::And(vec![eq("title", "b")]));
1760 assert_eq!(
1761 transform_where(&post_schema(), &q).expect("lower"),
1762 doc! { "$and": [ { "title": "a" }, { "title": "b" } ] }
1763 );
1764 }
1765
1766 /// An existing `$and` must absorb the collided pair rather than be replaced by it.
1767 #[test]
1768 fn a_collided_disjunction_joins_an_existing_conjunction() {
1769 let mut q = Query::new();
1770 q.push(Clause::And(vec![eq("title", "keep")]));
1771 q.push(Clause::Or(vec![eq("title", "a"), eq("title", "b")]));
1772 q.push(Clause::Or(vec![eq("title", "c"), eq("title", "d")]));
1773 let out = transform_where(&post_schema(), &q).expect("lower");
1774 let conjuncts = out.get_array("$and").expect("$and");
1775 assert_eq!(conjuncts.len(), 3, "the original conjunct must survive");
1776 assert_eq!(conjuncts[0], Bson::Document(doc! { "title": "keep" }));
1777 }
1778
1779 #[test]
1780 fn repeated_constraints_on_one_field_still_merge() {
1781 let mut q = Query::new();
1782 q.push_constraint(Constraint {
1783 field: "views".into(),
1784 comparison: Comparison::GreaterThan(ParseValue::Number(1.0)),
1785 });
1786 q.push_constraint(Constraint {
1787 field: "views".into(),
1788 comparison: Comparison::LessThan(ParseValue::Number(9.0)),
1789 });
1790 assert_eq!(
1791 transform_where(&post_schema(), &q).expect("lower"),
1792 doc! { "views": { "$gt": 1, "$lt": 9 } }
1793 );
1794 }
1795
1796 #[test]
1797 fn conflicting_equalities_on_one_field_are_an_error_not_a_silent_drop() {
1798 let mut q = Query::new();
1799 q.push_constraint(Constraint::equal("title", ParseValue::String("a".into())));
1800 q.push_constraint(Constraint::equal("title", ParseValue::String("b".into())));
1801 assert!(transform_where(&post_schema(), &q).is_err());
1802 }
1803
1804 /// `$all` values go through the interior transform, so a nested pointer keeps its envelope.
1805 #[test]
1806 fn all_lowers_to_dollar_all_with_interior_atoms() {
1807 let mut q = Query::new();
1808 q.push_constraint(Constraint {
1809 field: "tags".into(),
1810 comparison: Comparison::All(vec![
1811 ParseValue::String("a".into()),
1812 ParseValue::Pointer {
1813 class_name: "Tag".into(),
1814 object_id: "t1".into(),
1815 },
1816 ]),
1817 });
1818 assert_eq!(
1819 transform_where(&post_schema(), &q).expect("lower"),
1820 doc! { "tags": { "$all": [
1821 "a",
1822 { "__type": "Pointer", "className": "Tag", "objectId": "t1" }
1823 ] } }
1824 );
1825 }
1826
1827 /// **Which envelopes an operand may be rebuilt from is chosen by the field, not by the parser,
1828 /// and there are three rules rather than one.**
1829 ///
1830 /// This is the test the previous arrangement could not express, because the choice was made in
1831 /// `query_parse` where no schema exists. Picking one rule there is wrong in both directions,
1832 /// and the two directions fail differently: a top-level guess over-matches, an interior guess
1833 /// under-matches.
1834 ///
1835 /// A GeoPoint is the probe because it is on exactly one of the two lists. `transformInteriorAtom`
1836 /// recognizes Pointer, Date and Bytes (`MongoTransform.js:566-584`); `transformTopLevelAtom`
1837 /// recognizes every type (`:594-652`). So the same operand, with the same extra key, must be
1838 /// rebuilt in one position and compared whole in the other.
1839 #[test]
1840 fn the_atom_list_is_chosen_by_the_field_not_by_the_parser() {
1841 // As the parser now produces it: raw, no envelope interpreted, extra key intact.
1842 let raw_geo = || {
1843 ParseValue::Object(map(vec![
1844 ("__type", ParseValue::String("GeoPoint".into())),
1845 ("latitude", ParseValue::Number(1.0)),
1846 ("longitude", ParseValue::Number(2.0)),
1847 ("extra", ParseValue::Number(7.0)),
1848 ]))
1849 };
1850 let schema = ClassSchema::new("Place")
1851 .with_field("spot", FieldType::GeoPoint)
1852 .with_field("spots", FieldType::Array);
1853
1854 // Top-level position: GeoPoint is on the list, so upstream rebuilds it from its declared
1855 // keys and `extra` is discarded. The stored form is the `[lng, lat]` pair.
1856 let mut q = Query::new();
1857 q.push_constraint(Constraint {
1858 field: "spot".into(),
1859 comparison: Comparison::NotEqual(raw_geo()),
1860 });
1861 assert_eq!(
1862 transform_where(&schema, &q).expect("lower"),
1863 doc! { "spot": { "$ne": [2.0, 1.0] } },
1864 "a GeoPoint operand on a GeoPoint field is rebuilt, so the extra key cannot affect it"
1865 );
1866
1867 // Interior position, reached here by the field being an Array. GeoPoint is **not** on the
1868 // interior list, so upstream leaves the object alone and compares it whole, `extra`
1869 // included. Recognizing it here would match a row upstream does not return.
1870 let mut q = Query::new();
1871 q.push_constraint(Constraint {
1872 field: "spots".into(),
1873 comparison: Comparison::NotEqual(raw_geo()),
1874 });
1875 assert_eq!(
1876 transform_where(&schema, &q).expect("lower"),
1877 doc! { "spots": { "$ne": {
1878 "__type": "GeoPoint", "latitude": 1, "longitude": 2, "extra": 7
1879 } } },
1880 "an Array field takes the interior list, which has no GeoPoint on it"
1881 );
1882 }
1883
1884 /// Shorthand equality follows the **dotted-key test alone**, not the constraint rule, and the
1885 /// two positions disagree about what is even allowed.
1886 ///
1887 /// `MongoTransform.js:346-348` is `key.includes('.') ? interior : topLevel`, with no `inArray`
1888 /// term, where the constraint rule at `:660-662` has one. That reads like an upstream oversight
1889 /// and is not: a non-array value on an `Array` field never reaches `:346`, because the `$all`
1890 /// wrap at `:341-343` takes it first.
1891 ///
1892 /// Every row below was measured against `transformWhere` at the pin, because the reasoning
1893 /// alone produced the wrong answer twice: an array under shorthand equality is not lowered
1894 /// top-level, it is **refused**, and an *empty* array is neither.
1895 #[test]
1896 fn shorthand_equality_refuses_what_is_not_an_atom() {
1897 let schema = ClassSchema::new("P")
1898 .with_field("tags", FieldType::Array)
1899 .with_field("meta", FieldType::Object);
1900 let lower = |field: &str, v: ParseValue| {
1901 let mut q = Query::new();
1902 q.push_constraint(Constraint {
1903 field: field.into(),
1904 comparison: Comparison::Equal(v),
1905 });
1906 transform_where(&schema, &q)
1907 };
1908 let obj = || ParseValue::Object(map(vec![("a", ParseValue::Number(1.0))]));
1909
1910 // A generic object and a non-empty array are both `CannotTransform`, which the caller turns
1911 // into 107. The message renders the value the way a JS template literal does.
1912 for (field, value, rendered) in [
1913 ("meta", obj(), "[object Object]"),
1914 (
1915 "tags",
1916 ParseValue::Array(vec![ParseValue::Number(1.0)]),
1917 "1",
1918 ),
1919 (
1920 "meta",
1921 ParseValue::Array(vec![
1922 ParseValue::Array(vec![ParseValue::Number(1.0), ParseValue::Number(2.0)]),
1923 ParseValue::Number(3.0),
1924 ]),
1925 // JS flattens on the way to a string, so a nested array is not `1,2,3` by accident.
1926 "1,2,3",
1927 ),
1928 ("meta", ParseValue::Array(vec![obj()]), "[object Object]"),
1929 ] {
1930 let err = lower(field, value).expect_err("upstream refuses this");
1931 assert_eq!(err.code, parse_rust_core::ErrorCode::InvalidJson, "{err:?}");
1932 assert_eq!(
1933 err.message,
1934 format!("You cannot use {rendered} as a query parameter.")
1935 );
1936 }
1937
1938 // The empty cases are answered before any of that, by a key loop that does not run. Both
1939 // spellings, on both field types, lower to `{field: {}}`, which is an equality against an
1940 // empty document rather than an absent constraint.
1941 for (field, value) in [
1942 ("tags", ParseValue::Array(Vec::new())),
1943 ("meta", ParseValue::Array(Vec::new())),
1944 ("tags", ParseValue::Object(ParseMap::new())),
1945 ("meta", ParseValue::Object(ParseMap::new())),
1946 ] {
1947 assert_eq!(
1948 lower(field, value).expect("an empty collection is not refused"),
1949 doc! { field: {} },
1950 "field {field}"
1951 );
1952 }
1953
1954 // A dotted key takes the interior position, whose last arm is `return atom`. The same
1955 // object that is a 107 above is a legitimate comparison here.
1956 assert_eq!(
1957 lower("meta.a", obj()).expect("the interior position has no refusal"),
1958 doc! { "meta.a": { "a": 1 } }
1959 );
1960
1961 // **`Relation` is not on the top-level list**, which is six tags and not seven.
1962 // `transformTopLevelAtom` has no `Relation` arm and no `RelationCoder`, so the envelope is
1963 // an ordinary object there and falls to the same refusal as any other one. Calling that
1964 // list "every Parse type" is wrong by exactly this entry, and this entry is reachable.
1965 let relation = ParseValue::Object(map(vec![
1966 ("__type", ParseValue::String("Relation".into())),
1967 ("className", ParseValue::String("X".into())),
1968 ]));
1969 let err = lower("meta", relation.clone()).expect_err("not an atom at the top level");
1970 assert_eq!(
1971 err.message,
1972 "You cannot use [object Object] as a query parameter."
1973 );
1974 // And under an operator, where the other throw site gives the other message.
1975 let mut q = Query::new();
1976 q.push_constraint(Constraint {
1977 field: "meta".into(),
1978 comparison: Comparison::NotEqual(relation),
1979 });
1980 let err = transform_where(&schema, &q).expect_err("not an atom under an operator either");
1981 assert_eq!(
1982 err.message,
1983 r#"bad atom: {"__type":"Relation","className":"X"}"#
1984 );
1985 }
1986
1987 /// The pattern stays a BSON string. A compiled regex is a different BSON type and a different
1988 /// set of supported flags.
1989 #[test]
1990 fn regex_lowers_to_a_string_pattern_and_a_string_options() {
1991 let mut q = Query::new();
1992 q.push_constraint(Constraint {
1993 field: "title".into(),
1994 comparison: Comparison::Regex {
1995 pattern: "^foo".into(),
1996 options: Some("i".into()),
1997 },
1998 });
1999 let out = transform_where(&post_schema(), &q).expect("lower");
2000 assert_eq!(out, doc! { "title": { "$regex": "^foo", "$options": "i" } });
2001 assert!(matches!(
2002 out.get_document("title").expect("title").get("$regex"),
2003 Some(Bson::String(_))
2004 ));
2005 }
2006
2007 #[test]
2008 fn regex_without_options_emits_no_options_key() {
2009 let mut q = Query::new();
2010 q.push_constraint(Constraint {
2011 field: "title".into(),
2012 comparison: Comparison::Regex {
2013 pattern: "^foo".into(),
2014 options: None,
2015 },
2016 });
2017 assert_eq!(
2018 transform_where(&post_schema(), &q).expect("lower"),
2019 doc! { "title": { "$regex": "^foo" } }
2020 );
2021 }
2022
2023 #[test]
2024 fn an_empty_query_lowers_to_an_empty_document() {
2025 assert!(transform_where(&post_schema(), &Query::new())
2026 .expect("lower")
2027 .is_empty());
2028 }
2029}
2030
2031#[cfg(test)]
2032mod update_tests {
2033 use super::*;
2034 use bson::doc;
2035 use parse_rust_storage::FieldType;
2036 use parse_rust_storage::{Update, UpdateValue};
2037
2038 fn session_schema() -> ClassSchema {
2039 ClassSchema::new("_Session")
2040 .with_field("sessionToken", FieldType::String)
2041 .with_field("expiresAt", FieldType::Date)
2042 .with_field("timesUsed", FieldType::Number)
2043 .with_field("counts", FieldType::Array)
2044 .with_field(
2045 "user",
2046 FieldType::Pointer {
2047 target_class: "_User".into(),
2048 },
2049 )
2050 }
2051
2052 fn update(pairs: Vec<(&str, UpdateValue)>) -> Update {
2053 let mut u = Update::new();
2054 for (k, v) in pairs {
2055 u.insert(k.to_string(), v);
2056 }
2057 u
2058 }
2059
2060 #[test]
2061 fn set_lowers_to_dollar_set_under_the_storage_key() {
2062 let out = transform_update(
2063 &session_schema(),
2064 &update(vec![
2065 (
2066 "sessionToken",
2067 UpdateValue::Set(ParseValue::String("r:tok".into())),
2068 ),
2069 (
2070 "user",
2071 UpdateValue::Set(ParseValue::Pointer {
2072 class_name: "_User".into(),
2073 object_id: "u1".into(),
2074 }),
2075 ),
2076 ]),
2077 )
2078 .expect("lower");
2079 assert_eq!(
2080 out,
2081 doc! { "$set": { "_session_token": "r:tok", "_p_user": "_User$u1" } }
2082 );
2083 }
2084
2085 #[test]
2086 fn increment_lowers_to_dollar_inc() {
2087 let out = transform_update(
2088 &session_schema(),
2089 &update(vec![("timesUsed", UpdateValue::Increment(1.0))]),
2090 )
2091 .expect("lower");
2092 // `times_used` has no leading underscore. That looks like a typo upstream and is not
2093 // (`MongoTransform.js:7-31`); a corrected name would be a column parse-server never reads.
2094 assert_eq!(out, doc! { "$inc": { "times_used": 1 } });
2095 }
2096
2097 #[test]
2098 fn add_and_add_unique_wrap_their_values_in_each() {
2099 let out = transform_update(
2100 &session_schema(),
2101 &update(vec![(
2102 "counts",
2103 UpdateValue::Add(vec![ParseValue::Number(1.0), ParseValue::Number(2.0)]),
2104 )]),
2105 )
2106 .expect("lower");
2107 assert_eq!(out, doc! { "$push": { "counts": { "$each": [1, 2] } } });
2108
2109 let out = transform_update(
2110 &session_schema(),
2111 &update(vec![(
2112 "counts",
2113 UpdateValue::AddUnique(vec![ParseValue::Number(1.0)]),
2114 )]),
2115 )
2116 .expect("lower");
2117 assert_eq!(out, doc! { "$addToSet": { "counts": { "$each": [1] } } });
2118 }
2119
2120 /// The asymmetry worth a test: `$pullAll` takes a bare array, with no `$each` wrapper.
2121 #[test]
2122 fn remove_lowers_to_pull_all_with_no_each_wrapper() {
2123 let out = transform_update(
2124 &session_schema(),
2125 &update(vec![(
2126 "counts",
2127 UpdateValue::Remove(vec![ParseValue::Number(1.0)]),
2128 )]),
2129 )
2130 .expect("lower");
2131 assert_eq!(out, doc! { "$pullAll": { "counts": [1] } });
2132 }
2133
2134 #[test]
2135 fn unset_lowers_to_the_empty_string_argument() {
2136 let out = transform_update(
2137 &session_schema(),
2138 &update(vec![("counts", UpdateValue::Unset)]),
2139 )
2140 .expect("lower");
2141 assert_eq!(out, doc! { "$unset": { "counts": "" } });
2142 }
2143
2144 #[test]
2145 fn several_ops_group_under_their_operators_in_first_use_order() {
2146 let out = transform_update(
2147 &session_schema(),
2148 &update(vec![
2149 ("timesUsed", UpdateValue::Increment(1.0)),
2150 (
2151 "sessionToken",
2152 UpdateValue::Set(ParseValue::String("r:tok".into())),
2153 ),
2154 ("counts", UpdateValue::Add(vec![ParseValue::Number(1.0)])),
2155 ]),
2156 )
2157 .expect("lower");
2158 let keys: Vec<&str> = out.keys().map(String::as_str).collect();
2159 assert_eq!(keys, vec!["$inc", "$set", "$push"]);
2160 }
2161
2162 /// A Relation lives in a join table, so an update naming one writes no column at all.
2163 #[test]
2164 fn a_relation_set_is_skipped_rather_than_stored() {
2165 let out = transform_update(
2166 &session_schema(),
2167 &update(vec![
2168 (
2169 "members",
2170 UpdateValue::Set(ParseValue::Relation {
2171 class_name: "_User".into(),
2172 }),
2173 ),
2174 (
2175 "sessionToken",
2176 UpdateValue::Set(ParseValue::String("r:tok".into())),
2177 ),
2178 ]),
2179 )
2180 .expect("lower");
2181 assert_eq!(out, doc! { "$set": { "_session_token": "r:tok" } });
2182 }
2183
2184 /// A `_Session` row whose `expiresAt` is stored as a string never expires, because
2185 /// parse-server compares it as a Date.
2186 #[test]
2187 fn expires_at_is_coerced_to_a_bson_date_even_from_a_string() {
2188 let out = transform_update(
2189 &session_schema(),
2190 &update(vec![(
2191 "expiresAt",
2192 UpdateValue::Set(ParseValue::String("2026-08-14T13:34:33.581Z".into())),
2193 )]),
2194 )
2195 .expect("lower");
2196 let set = out.get_document("$set").expect("$set");
2197 assert!(
2198 matches!(set.get("expiresAt"), Some(Bson::DateTime(_))),
2199 "expiresAt must be a BSON Date, not a string"
2200 );
2201 }
2202}
2203
2204#[cfg(test)]
2205mod session_and_relation_tests {
2206 use super::*;
2207 use parse_rust_storage::FieldType;
2208
2209 fn session_schema() -> ClassSchema {
2210 ClassSchema::new("_Session")
2211 .with_field("sessionToken", FieldType::String)
2212 .with_field("expiresAt", FieldType::Date)
2213 .with_field("createdWith", FieldType::Object)
2214 .with_field("installationId", FieldType::String)
2215 .with_field("lastUsed", FieldType::Date)
2216 .with_field("timesUsed", FieldType::Number)
2217 .with_field(
2218 "user",
2219 FieldType::Pointer {
2220 target_class: "_User".into(),
2221 },
2222 )
2223 }
2224
2225 fn map(pairs: Vec<(&str, ParseValue)>) -> ParseMap {
2226 let mut m = ParseMap::new();
2227 for (k, v) in pairs {
2228 m.insert(k.to_string(), v);
2229 }
2230 m
2231 }
2232
2233 /// Every `_Session` column, in both directions. A name that is wrong here produces a row
2234 /// parse-server reads as a session with no token, which fails open on nothing and fails closed
2235 /// on everything.
2236 #[test]
2237 fn session_columns_round_trip() {
2238 let schema = session_schema();
2239 let date = ParseDate::parse_iso("2026-08-14T13:34:33.581Z").expect("date");
2240 let row = map(vec![
2241 ("objectId", ParseValue::String("s000000001".into())),
2242 ("sessionToken", ParseValue::String("r:tok".into())),
2243 (
2244 "user",
2245 ParseValue::Pointer {
2246 class_name: "_User".into(),
2247 object_id: "u1".into(),
2248 },
2249 ),
2250 ("expiresAt", ParseValue::Date(date)),
2251 ("lastUsed", ParseValue::Date(date)),
2252 ("timesUsed", ParseValue::Number(3.0)),
2253 (
2254 "createdWith",
2255 ParseValue::Object(map(vec![("action", ParseValue::String("login".into()))])),
2256 ),
2257 ("installationId", ParseValue::String("inst1".into())),
2258 ]);
2259
2260 let doc = parse_object_to_mongo_create(&schema, &row).expect("lower");
2261 assert_eq!(doc.get_str("_session_token").expect("token"), "r:tok");
2262 assert_eq!(doc.get_str("_p_user").expect("user"), "_User$u1");
2263 assert!(matches!(doc.get("expiresAt"), Some(Bson::DateTime(_))));
2264 assert!(matches!(doc.get("_last_used"), Some(Bson::DateTime(_))));
2265 assert!(doc.contains_key("times_used"), "no leading underscore");
2266 // Plain, no rename.
2267 assert!(doc.contains_key("createdWith"));
2268 assert!(doc.contains_key("installationId"));
2269
2270 let back = mongo_object_to_parse(&schema, &doc).expect("raise");
2271 assert!(matches!(back.get("sessionToken"), Some(ParseValue::String(s)) if s == "r:tok"));
2272 assert!(matches!(
2273 back.get("user"),
2274 Some(ParseValue::Pointer { object_id, .. }) if object_id == "u1"
2275 ));
2276 assert!(matches!(back.get("lastUsed"), Some(ParseValue::Date(_))));
2277 assert!(matches!(back.get("timesUsed"), Some(ParseValue::Number(n)) if *n == 3.0));
2278 // UPSTREAM-QUIRK: `expiresAt` comes back as a full `{"__type":"Date"}` envelope while
2279 // `createdAt`, `updatedAt` and `lastUsed` come back as bare ISO strings
2280 // (`MongoTransform.js:1169-1187`). Both are `ParseValue::Date` here; the position-dependent
2281 // flattening happens once, at the response boundary, so there is one place to audit.
2282 assert!(matches!(back.get("expiresAt"), Some(ParseValue::Date(_))));
2283 }
2284
2285 #[test]
2286 fn a_relation_field_is_synthesized_on_read_and_has_no_column() {
2287 let schema = ClassSchema::new("_Role")
2288 .with_field("name", FieldType::String)
2289 .with_field(
2290 "users",
2291 FieldType::Relation {
2292 target_class: "_User".into(),
2293 },
2294 );
2295
2296 let doc = parse_object_to_mongo_create(
2297 &schema,
2298 &map(vec![
2299 ("name", ParseValue::String("Admins".into())),
2300 (
2301 "users",
2302 ParseValue::Relation {
2303 class_name: "_User".into(),
2304 },
2305 ),
2306 ]),
2307 )
2308 .expect("lower");
2309 assert!(!doc.contains_key("users"), "a Relation has no column");
2310
2311 let back = mongo_object_to_parse(&schema, &doc).expect("raise");
2312 assert!(matches!(
2313 back.get("users"),
2314 Some(ParseValue::Relation { class_name }) if class_name == "_User"
2315 ));
2316 }
2317
2318 /// The synthesized Relation is spread *after* the document, so a stray stored key of the same
2319 /// name loses.
2320 #[test]
2321 fn a_stray_stored_column_does_not_beat_the_synthesized_relation() {
2322 let schema = ClassSchema::new("_Role").with_field(
2323 "users",
2324 FieldType::Relation {
2325 target_class: "_User".into(),
2326 },
2327 );
2328 let mut doc = Document::new();
2329 doc.insert("users", "leftover");
2330 let back = mongo_object_to_parse(&schema, &doc).expect("raise");
2331 assert!(matches!(
2332 back.get("users"),
2333 Some(ParseValue::Relation { class_name }) if class_name == "_User"
2334 ));
2335 }
2336}
2337
2338#[cfg(test)]
2339mod eq_operator_tests {
2340 use super::*;
2341 use parse_rust_storage::{Clause, Comparison, Constraint, FieldType, Query};
2342
2343 fn schema() -> ClassSchema {
2344 ClassSchema::new("Post")
2345 .with_field("views", FieldType::Number)
2346 .with_field("meta", FieldType::Object)
2347 }
2348
2349 /// `containsAllStartingWith`, which is the SDK method behind `$all` full of `$regex` atoms
2350 /// (`ParseQuery.js:1162-1172`). `transformInteriorAtom` compiles each one into a real regular
2351 /// expression (`MongoTransform.js:580-581`); storing the `{"$regex": ...}` envelope as a
2352 /// subdocument instead matches nothing and answers 200, so nothing tells the caller the
2353 /// constraint was meaningless.
2354 #[test]
2355 fn an_all_of_regex_atoms_compiles_to_regular_expressions() {
2356 let mut regex = ParseMap::new();
2357 regex.insert("$regex".to_string(), ParseValue::String("^ba".to_string()));
2358
2359 let mut query = Query::default();
2360 query.push(Clause::Field(Constraint {
2361 field: "tags".into(),
2362 comparison: Comparison::All(vec![ParseValue::Object(regex)]),
2363 }));
2364
2365 let doc = transform_where(&schema(), &query).expect("lowers");
2366 let all = doc
2367 .get_document("tags")
2368 .expect("tags")
2369 .get_array("$all")
2370 .expect("$all");
2371 match &all[0] {
2372 Bson::RegularExpression(r) => {
2373 assert_eq!(r.pattern, "^ba");
2374 // `new RegExp(atom.$regex)` passes no flags, so neither does this.
2375 assert_eq!(r.options, "");
2376 }
2377 other => panic!("expected a regex, got {other:?}"),
2378 }
2379 }
2380
2381 /// A `$`-carrying nested key is refused on a write, and the query path still accepts one.
2382 ///
2383 /// Two properties in one test because they are the same decision seen from both sides. Putting
2384 /// the regex compile in the shared interior transform stored a BSON regular expression in an
2385 /// ordinary array, which `bson_to_parse_value` cannot decode, so the row became permanently
2386 /// unreadable and poisoned every query that returned it. Splitting the paths fixed the
2387 /// corruption; refusing the key is what matches upstream, whose `transformInteriorValue` throws
2388 /// `INVALID_NESTED_KEY` before any atom conversion (`MongoTransform.js:177-187`).
2389 ///
2390 /// This asserted only that the written value round-tripped, which was the safe half while the
2391 /// refusal was unimplemented. It now asserts the refusal itself.
2392 #[test]
2393 fn a_dollar_key_is_refused_on_a_write_and_accepted_on_a_query() {
2394 let mut regex = ParseMap::new();
2395 regex.insert("$regex".to_string(), ParseValue::String("^x".to_string()));
2396 let written = ParseValue::Array(vec![ParseValue::Object(regex.clone())]);
2397
2398 let refused = interior_value_to_bson(&written).expect_err("a write must refuse it");
2399 assert_eq!(refused.code, parse_rust_core::ErrorCode::InvalidNestedKey);
2400 assert_eq!(
2401 refused.message,
2402 "Nested keys should not contain the '$' or '.' characters"
2403 );
2404
2405 // A dotted key is the other half of the same rule.
2406 let mut dotted = ParseMap::new();
2407 dotted.insert("a.b".to_string(), ParseValue::Number(1.0));
2408 assert_eq!(
2409 interior_value_to_bson(&ParseValue::Object(dotted))
2410 .expect_err("a dotted key is refused too")
2411 .code,
2412 parse_rust_core::ErrorCode::InvalidNestedKey
2413 );
2414
2415 // The query path must still compile it: that is what a constraint looks like, and it is
2416 // why the guard cannot live in a function the two share.
2417 let queried = interior_query_atom_to_bson(&ParseValue::Object(regex)).expect("compiles");
2418 assert!(
2419 matches!(queried, Bson::RegularExpression(_)),
2420 "a query atom still becomes a regex: {queried:?}"
2421 );
2422 }
2423
2424 /// Upstream's all-or-none rule (`MongoTransform.js:746-751`), reachable now that an element
2425 /// can be a regex.
2426 #[test]
2427 fn an_all_mixing_regexes_and_plain_values_is_refused() {
2428 let mut regex = ParseMap::new();
2429 regex.insert("$regex".to_string(), ParseValue::String("^ba".to_string()));
2430
2431 let mut query = Query::default();
2432 query.push(Clause::Field(Constraint {
2433 field: "tags".into(),
2434 comparison: Comparison::All(vec![
2435 ParseValue::Object(regex),
2436 ParseValue::String("plain".to_string()),
2437 ]),
2438 }));
2439
2440 let err = transform_where(&schema(), &query).expect_err("mixed $all");
2441 assert_eq!(err.code, parse_rust_core::ErrorCode::InvalidJson);
2442 }
2443
2444 /// A bare objectId string against a Pointer field has to acquire the class prefix from the
2445 /// **schema**, since the string carries none (`MongoTransform.js:600-603`). Without it the
2446 /// comparison is against an unprefixed id, which matches no stored value.
2447 #[test]
2448 fn a_bare_object_id_is_prefixed_for_a_pointer_field() {
2449 let schema = ClassSchema::new("Comment").with_field(
2450 "author",
2451 FieldType::Pointer {
2452 target_class: "_User".to_string(),
2453 },
2454 );
2455
2456 for comparison in [
2457 Comparison::Equal(ParseValue::String("abc123".into())),
2458 Comparison::NotEqual(ParseValue::String("abc123".into())),
2459 Comparison::In(vec![ParseValue::String("abc123".into())]),
2460 ] {
2461 let mut query = Query::default();
2462 query.push(Clause::Field(Constraint {
2463 field: "author".into(),
2464 comparison,
2465 }));
2466 let doc = transform_where(&schema, &query).expect("lowers");
2467 let rendered = format!("{doc:?}");
2468 assert!(
2469 rendered.contains("_User$abc123"),
2470 "the id must be prefixed with the declared target class: {rendered}"
2471 );
2472 }
2473 }
2474
2475 /// The three types `transformInteriorAtom` does not recognise keep their `__type` envelope,
2476 /// because upstream's chain falls through to `return atom`. Converting them to their
2477 /// top-level storage forms is a stored-format divergence a mixed fleet reads as disagreement
2478 /// about the column's contents.
2479 #[test]
2480 fn a_nested_geopoint_is_stored_as_its_envelope_rather_than_a_coordinate_pair() {
2481 let nested = ParseValue::Array(vec![ParseValue::GeoPoint {
2482 latitude: 1.0,
2483 longitude: 2.0,
2484 }]);
2485 let Bson::Array(items) = interior_value_to_bson(&nested).expect("lowers") else {
2486 panic!("expected an array");
2487 };
2488 let doc = match &items[0] {
2489 Bson::Document(d) => d,
2490 other => panic!("a nested GeoPoint must stay an object, got {other:?}"),
2491 };
2492 assert_eq!(doc.get_str("__type").ok(), Some("GeoPoint"));
2493 }
2494
2495 /// `equalTo` on an Array-typed field means "the array contains this", which upstream writes as
2496 /// `$all` with one element (`MongoTransform.js:341-343`). The Pointer case is the one that was
2497 /// broken: the top-level path refuses a Pointer by value, so this errored 111 rather than
2498 /// running the query the SDK asked for.
2499 #[test]
2500 fn equality_against_an_array_field_becomes_a_single_element_all() {
2501 let schema = ClassSchema::new("Post").with_field("tags", FieldType::Array);
2502
2503 let mut query = Query::default();
2504 query.push(Clause::Field(Constraint {
2505 field: "tags".into(),
2506 comparison: Comparison::Equal(ParseValue::Pointer {
2507 class_name: "Tag".into(),
2508 object_id: "t1".into(),
2509 }),
2510 }));
2511 let doc = transform_where(&schema, &query).expect("a pointer against an array field");
2512 let all = doc
2513 .get_document("tags")
2514 .expect("tags")
2515 .get_array("$all")
2516 .expect("$all");
2517 // The interior form, so the envelope survives: an array of pointers stores the envelope
2518 // rather than the `Class$id` collapse, which is a key transformation.
2519 let Bson::Document(pointer) = &all[0] else {
2520 panic!("expected the pointer envelope, got {:?}", all[0]);
2521 };
2522 assert_eq!(pointer.get_str("__type").ok(), Some("Pointer"));
2523 assert_eq!(pointer.get_str("objectId").ok(), Some("t1"));
2524
2525 // **An array value is refused, not lowered.** This assertion used to read "an array value
2526 // is left alone: it is an equality against the whole array", which is the inference the
2527 // shape invites and is not what upstream does. The `$all` wrap above is reached only for a
2528 // non-array value; an array falls through to `transformTopLevelAtom`, which cannot
2529 // transform it, and the caller raises 107 (`MongoTransform.js:346-354`). Measured at the
2530 // pin: `{tags: ["a"]}` answers `You cannot use a as a query parameter.`
2531 let mut query = Query::default();
2532 query.push(Clause::Field(Constraint {
2533 field: "tags".into(),
2534 comparison: Comparison::Equal(ParseValue::Array(vec![ParseValue::String("a".into())])),
2535 }));
2536 let err = transform_where(&schema, &query).expect_err("upstream refuses this");
2537 assert_eq!(err.code, parse_rust_core::ErrorCode::InvalidJson);
2538 assert_eq!(err.message, "You cannot use a as a query parameter.");
2539 }
2540
2541 /// The layer the mixed-constraint rewrite was being undone at.
2542 ///
2543 /// `replaceEquality` turns `{"meta": {"foo": 1, "$gt": 0}}` into an `$eq` plus a `$gt`. If
2544 /// `$eq` lowers to a bare value the way ordinary equality does, the two constraints become
2545 /// `{meta: {foo: 1}}` and `{meta: {$gt: 0}}`, and merging them for one field rebuilds the very
2546 /// document the rewrite existed to take apart. The parse-level test cannot see that, because
2547 /// everything is still correct when it runs.
2548 #[test]
2549 fn an_eq_keeps_its_wrapper_and_composes_with_another_operator() {
2550 let mut query = Query::default();
2551 query.push(Clause::Field(Constraint {
2552 field: "views".into(),
2553 comparison: Comparison::EqualOperator(ParseValue::Number(5.0)),
2554 }));
2555 query.push(Clause::Field(Constraint {
2556 field: "views".into(),
2557 comparison: Comparison::GreaterThan(ParseValue::Number(1.0)),
2558 }));
2559
2560 let doc = transform_where(&schema(), &query).expect("lowers");
2561 let views = doc
2562 .get_document("views")
2563 .expect("one document for the field");
2564 assert_eq!(
2565 views
2566 .get_i32("$eq")
2567 .or(views.get_f64("$eq").map(|v| v as i32))
2568 .ok(),
2569 Some(5)
2570 );
2571 assert!(
2572 views.contains_key("$gt"),
2573 "both operators survive: {views:?}"
2574 );
2575 }
2576
2577 /// Bare equality still lowers to the value itself, with no wrapper. This is the half that must
2578 /// not change: upstream emits the raw value for the shorthand form.
2579 #[test]
2580 fn bare_equality_still_lowers_without_a_wrapper() {
2581 let mut query = Query::default();
2582 query.push(Clause::Field(Constraint {
2583 field: "views".into(),
2584 comparison: Comparison::Equal(ParseValue::Number(5.0)),
2585 }));
2586
2587 let doc = transform_where(&schema(), &query).expect("lowers");
2588 assert!(
2589 doc.get_document("views").is_err(),
2590 "shorthand equality is a plain value, not an operator document: {doc:?}"
2591 );
2592 }
2593}