Skip to main content

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}