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
12use bson::{Bson, Document};
13use parse_rust_core::{ParseDate, ParseError, ParseMap, ParseValue};
14use parse_rust_storage::ClassSchema;
15
16/// Keys that are renamed rather than stored under their Parse name (`transformKey`,
17/// `MongoTransform.js:7-30`). The list is closed: everything else keeps its name, except a
18/// declared Pointer field, which takes a `_p_` prefix.
19pub fn storage_key(schema: &ClassSchema, field: &str) -> String {
20    match field {
21        "objectId" => return "_id".into(),
22        "createdAt" => return "_created_at".into(),
23        "updatedAt" => return "_updated_at".into(),
24        "sessionToken" => return "_session_token".into(),
25        "lastUsed" => return "_last_used".into(),
26        "timesUsed" => return "times_used".into(),
27        _ => {}
28    }
29    if schema.is_pointer_field(field) {
30        format!("_p_{field}")
31    } else {
32        field.to_string()
33    }
34}
35
36/// Server-internal columns that are read back under their own names.
37///
38/// **Deliberately not upstream's shape.** `mongoObjectToParseObject` rehydrates
39/// `_hashed_password` onto the object as `password` (`DatabaseController.js:265-267`), so the hash
40/// travels under a user-facing name and one later step has to remove it again. Any path that
41/// forgets that step leaks the hash, so parse-rust does not create the condition: the column keeps
42/// its internal name all the way through.
43/// Keeping the internal name means no code path can mistake the hash for a user-facing field, and
44/// `parse_rust_rest::strip_internal_keys` removes every `_`-prefixed key from responses as the
45/// unconditional backstop, which is what upstream's `filterSensitiveData` also does
46/// (`DatabaseController.js:288-292`).
47///
48/// `_session_token` is absent because it is already renamed to `sessionToken` above, which is
49/// upstream's behavior for that one column.
50const INTERNAL_COLUMNS: [&str; 6] = [
51    "_rperm",
52    "_wperm",
53    "_hashed_password",
54    "_perishable_token",
55    "_email_verify_token",
56    "_failed_login_count",
57];
58
59/// The inverse of [`storage_key`].
60fn untransform_key(field: &str) -> Option<String> {
61    match field {
62        "_id" => Some("objectId".into()),
63        "_created_at" => Some("createdAt".into()),
64        "_updated_at" => Some("updatedAt".into()),
65        "_session_token" => Some("sessionToken".into()),
66        "_last_used" => Some("lastUsed".into()),
67        "times_used" => Some("timesUsed".into()),
68        _ => {
69            if let Some(stripped) = field.strip_prefix("_p_") {
70                return Some(stripped.to_string());
71            }
72            if INTERNAL_COLUMNS.contains(&field) || field.starts_with("_auth_data_") {
73                return Some(field.to_string());
74            }
75            None
76        }
77    }
78}
79
80/// Choose the BSON number type.
81///
82/// **This is the rule that silently corrupts data if it is wrong**, and it is why Gate B of the
83/// data-fidelity gate exists. The rule, measured against a real parse-server: a value that
84/// is integral and fits in `i32` is stored as `Int32`, everything else as `Double`. The Node
85/// driver does the same thing, which is why a database written by parse-server contains a mix of
86/// both for what the client thinks is one numeric field.
87///
88/// Note that this is unrelated to JSON number *formatting*, which is `parse-rust-core::js_number`.
89/// Conflating the two is the mistake this project already made once.
90fn to_bson_number(n: f64) -> Bson {
91    if n.fract() == 0.0 && n >= i32::MIN as f64 && n <= i32::MAX as f64 {
92        // `-0.0` is integral and in range. It stores as Int32 0, losing the sign, which is what
93        // the Node driver does too.
94        Bson::Int32(n as i32)
95    } else {
96        Bson::Double(n)
97    }
98}
99
100/// Lower a value that sits **inside** an array or object.
101///
102/// UPSTREAM-QUIRK: `transformInteriorAtom` (`MongoTransform.js:566`) handles a strictly smaller
103/// set than the top level. Only Pointer, Date and Bytes are recognised; a GeoPoint, Polygon or
104/// File nested inside an array is stored raw, as the plain `__type` object it arrived as. That is
105/// wire-visible on read-back, and it is reproduced deliberately.
106///
107/// A nested Pointer also keeps its full `{__type, className, objectId}` shape rather than
108/// collapsing to `"Class$id"`, because the `_p_` collapse is a *key* transformation and interior
109/// values have no key of their own.
110fn interior_value_to_bson(value: &ParseValue) -> Result<Bson, ParseError> {
111    Ok(match value {
112        ParseValue::Date(d) => date_to_bson(d),
113        ParseValue::Bytes(b) => Bson::Binary(bson::Binary {
114            subtype: bson::spec::BinarySubtype::Generic,
115            bytes: b.clone(),
116        }),
117        ParseValue::Pointer {
118            class_name,
119            object_id,
120        } => {
121            let mut d = Document::new();
122            d.insert("__type", "Pointer");
123            d.insert("className", class_name.clone());
124            d.insert("objectId", object_id.clone());
125            Bson::Document(d)
126        }
127        other => plain_value_to_bson(other)?,
128    })
129}
130
131fn date_to_bson(d: &ParseDate) -> Bson {
132    Bson::DateTime(bson::DateTime::from_millis(d.timestamp_millis()))
133}
134
135/// Everything that is not position-dependent.
136fn plain_value_to_bson(value: &ParseValue) -> Result<Bson, ParseError> {
137    Ok(match value {
138        ParseValue::Null => Bson::Null,
139        ParseValue::Bool(b) => Bson::Boolean(*b),
140        ParseValue::Number(n) => to_bson_number(*n),
141        ParseValue::String(s) => Bson::String(s.clone()),
142        ParseValue::Array(items) => Bson::Array(
143            items
144                .iter()
145                .map(interior_value_to_bson)
146                .collect::<Result<Vec<_>, _>>()?,
147        ),
148        ParseValue::Object(map) => {
149            let mut d = Document::new();
150            for (k, v) in map {
151                d.insert(k.clone(), interior_value_to_bson(v)?);
152            }
153            Bson::Document(d)
154        }
155        ParseValue::Date(d) => date_to_bson(d),
156        ParseValue::Bytes(b) => Bson::Binary(bson::Binary {
157            subtype: bson::spec::BinarySubtype::Generic,
158            bytes: b.clone(),
159        }),
160        ParseValue::GeoPoint {
161            latitude,
162            longitude,
163        } => {
164            // Storage is GeoJSON order, longitude first, the reverse of the wire form.
165            Bson::Array(vec![Bson::Double(*longitude), Bson::Double(*latitude)])
166        }
167        ParseValue::Pointer { .. } => {
168            return Err(ParseError::incorrect_type(
169                "a top-level Pointer is lowered by key, not by value".to_string(),
170            ))
171        }
172        ParseValue::Polygon(coords) => Bson::Document({
173            let mut d = Document::new();
174            d.insert("type", "Polygon");
175            d.insert(
176                "coordinates",
177                Bson::Array(vec![Bson::Array(
178                    coords
179                        .iter()
180                        // Stored longitude-first; `PolygonCoder.databaseToJSON` swaps on the way
181                        // out (`MongoTransform.js:1362-1372`).
182                        .map(|(lat, lng)| Bson::Array(vec![Bson::Double(*lng), Bson::Double(*lat)]))
183                        .collect(),
184                )]),
185            );
186            d
187        }),
188        ParseValue::File { name, .. } => Bson::String(name.clone()),
189        ParseValue::Relation { .. } => {
190            return Err(ParseError::incorrect_type(
191                "Relation fields are not stored on the object".to_string(),
192            ))
193        }
194    })
195}
196
197/// `parseObjectToMongoObjectForCreate`.
198///
199/// Two behaviors that are easy to miss and both wire-visible:
200/// - **Relation values are skipped entirely** (`MongoTransform.js:468-470`). A Relation lives in
201///   a join table, not on the object, so a `{__type:"Relation"}` value in a create body is
202///   dropped rather than stored or rejected.
203/// - **`ACL` becomes three columns**: `_rperm`, `_wperm`, and the legacy `_acl` mirror.
204pub fn parse_object_to_mongo_create(
205    schema: &ClassSchema,
206    object: &ParseMap,
207) -> Result<Document, ParseError> {
208    let mut out = Document::new();
209
210    for (key, value) in object {
211        if matches!(value, ParseValue::Relation { .. }) {
212            continue;
213        }
214        if key == "ACL" {
215            return Err(ParseError::invalid_json(
216                "ACL must be lowered through parse_acl_to_columns, not as a field".to_string(),
217            ));
218        }
219
220        let mongo_key = storage_key(schema, key);
221
222        // A declared Pointer field collapses to "<Class>$<id>" under its `_p_` key.
223        if schema.is_pointer_field(key) {
224            match value {
225                ParseValue::Pointer {
226                    class_name,
227                    object_id,
228                } => {
229                    out.insert(mongo_key, Bson::String(format!("{class_name}${object_id}")));
230                    continue;
231                }
232                ParseValue::Null => {
233                    out.insert(mongo_key, Bson::Null);
234                    continue;
235                }
236                _ => {
237                    return Err(ParseError::incorrect_type(format!(
238                        "schema mismatch for {}.{key}; expected Pointer but got a non-pointer",
239                        schema.class_name
240                    )))
241                }
242            }
243        }
244
245        out.insert(mongo_key, plain_value_to_bson(value)?);
246    }
247
248    Ok(out)
249}
250
251/// `mongoObjectToParseObject`.
252///
253/// UPSTREAM-QUIRK: an unrecognised `_`-prefixed key raises, and it raises a **bare JavaScript
254/// string** rather than a `Parse.Error` (`MongoTransform.js:1236-1237`). On the aggregate path
255/// that surfaces to the client as code 102 with an `undefined` message. Reproduced here as an
256/// error, though parse-rust cannot reproduce the `undefined` message without inventing one.
257pub fn mongo_object_to_parse(doc: &Document) -> Result<ParseMap, ParseError> {
258    let mut out = ParseMap::new();
259
260    for (key, value) in doc {
261        // `_acl` is the legacy write-only mirror and is dropped on read, exactly as upstream does
262        // (`MongoTransform.js:1155-1156`, a bare `break`).
263        //
264        // `_rperm` and `_wperm` are NOT dropped here. They are what `parse_rust_rest::acl::raise_acl`
265        // rebuilds the `ACL` field from, and dropping them meant a stored ACL could never be
266        // returned. The response boundary strips any that survive, so they cannot leak.
267        if key == "_acl" {
268            continue;
269        }
270
271        let parse_key = match untransform_key(key) {
272            Some(k) => k,
273            None if key.starts_with('_') && key != "__type" => {
274                return Err(ParseError::invalid_query(format!(
275                    "bad key in untransform: {key}"
276                )))
277            }
278            None => key.clone(),
279        };
280
281        // A `_p_` field carries "<Class>$<id>".
282        if let Some(stripped) = key.strip_prefix("_p_") {
283            match value {
284                Bson::String(s) => {
285                    let (class_name, object_id) = s.split_once('$').ok_or_else(|| {
286                        ParseError::incorrect_type(format!(
287                            "pointer field {stripped} is malformed: {s}"
288                        ))
289                    })?;
290                    out.insert(
291                        stripped.to_string(),
292                        ParseValue::Pointer {
293                            class_name: class_name.to_string(),
294                            object_id: object_id.to_string(),
295                        },
296                    );
297                }
298                Bson::Null => {
299                    out.insert(stripped.to_string(), ParseValue::Null);
300                }
301                _ => {
302                    return Err(ParseError::incorrect_type(format!(
303                        "pointer field {stripped} is not a string"
304                    )))
305                }
306            }
307            continue;
308        }
309
310        out.insert(parse_key, bson_to_parse_value(value)?);
311    }
312
313    Ok(out)
314}
315
316/// Raise a stored value. Takes no schema: the stored form is self-describing, which is the
317/// asymmetry with lowering, where the schema decides whether a field is a `_p_` pointer.
318fn bson_to_parse_value(value: &Bson) -> Result<ParseValue, ParseError> {
319    Ok(match value {
320        Bson::Null => ParseValue::Null,
321        Bson::Boolean(b) => ParseValue::Bool(*b),
322        // Both integer widths raise to the single JavaScript number type. This is the direction
323        // that is lossless; the lossy direction is `to_bson_number`.
324        Bson::Int32(n) => ParseValue::Number(*n as f64),
325        Bson::Int64(n) => ParseValue::Number(*n as f64),
326        Bson::Double(n) => ParseValue::Number(*n),
327        Bson::String(s) => ParseValue::String(s.clone()),
328        Bson::DateTime(dt) => ParseValue::Date(ParseDate::parse_iso(
329            &dt.try_to_rfc3339_string()
330                .map_err(|e| ParseError::invalid_json(format!("undecodable stored date: {e}")))?,
331        )?),
332        Bson::Binary(b) => ParseValue::Bytes(b.bytes.clone()),
333        Bson::Array(items) => ParseValue::Array(
334            items
335                .iter()
336                .map(bson_to_parse_value)
337                .collect::<Result<Vec<_>, _>>()?,
338        ),
339        Bson::Document(d) => {
340            let mut map = ParseMap::new();
341            for (k, v) in d {
342                map.insert(k.clone(), bson_to_parse_value(v)?);
343            }
344            ParseValue::Object(map)
345        }
346        other => {
347            return Err(ParseError::incorrect_type(format!(
348                "unsupported BSON type in stored document: {other:?}"
349            )))
350        }
351    })
352}
353
354/// Lower a value for use in a query filter on `field`.
355///
356/// Differs from the create path in one way that matters: a declared Pointer field stores
357/// `"Class$id"`, so a query for a pointer has to compare against that string rather than against
358/// the `__type` envelope. Getting this wrong makes every pointer query silently return nothing.
359pub fn value_to_bson_for_query(
360    schema: &ClassSchema,
361    field: &str,
362    value: &ParseValue,
363) -> Result<Bson, ParseError> {
364    if schema.is_pointer_field(field) {
365        if let ParseValue::Pointer {
366            class_name,
367            object_id,
368        } = value
369        {
370            return Ok(Bson::String(format!("{class_name}${object_id}")));
371        }
372    }
373    plain_value_to_bson(value)
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use parse_rust_storage::FieldType;
380
381    fn post_schema() -> ClassSchema {
382        ClassSchema::new("Post")
383            .with_field("title", FieldType::String)
384            .with_field("views", FieldType::Number)
385            .with_field(
386                "author",
387                FieldType::Pointer {
388                    target_class: "_User".into(),
389                },
390            )
391    }
392
393    fn map(pairs: Vec<(&str, ParseValue)>) -> ParseMap {
394        let mut m = ParseMap::new();
395        for (k, v) in pairs {
396            m.insert(k.to_string(), v);
397        }
398        m
399    }
400
401    #[test]
402    fn renamed_keys_round_trip() {
403        let s = post_schema();
404        assert_eq!(storage_key(&s, "objectId"), "_id");
405        assert_eq!(storage_key(&s, "createdAt"), "_created_at");
406        assert_eq!(storage_key(&s, "updatedAt"), "_updated_at");
407        assert_eq!(storage_key(&s, "title"), "title");
408        assert_eq!(storage_key(&s, "author"), "_p_author");
409
410        assert_eq!(untransform_key("_id").as_deref(), Some("objectId"));
411        assert_eq!(untransform_key("_created_at").as_deref(), Some("createdAt"));
412        assert_eq!(untransform_key("_p_author").as_deref(), Some("author"));
413        assert_eq!(untransform_key("title"), None);
414    }
415
416    /// The rule Gate B exists to prove.
417    #[test]
418    fn integral_numbers_in_i32_range_store_as_int32() {
419        assert_eq!(to_bson_number(0.0), Bson::Int32(0));
420        assert_eq!(to_bson_number(42.0), Bson::Int32(42));
421        assert_eq!(to_bson_number(-42.0), Bson::Int32(-42));
422        assert_eq!(to_bson_number(i32::MAX as f64), Bson::Int32(i32::MAX));
423        assert_eq!(to_bson_number(i32::MIN as f64), Bson::Int32(i32::MIN));
424    }
425
426    #[test]
427    fn everything_else_stores_as_double() {
428        assert_eq!(to_bson_number(1.5), Bson::Double(1.5));
429        // Just past the i32 range, still integral.
430        assert_eq!(
431            to_bson_number(i32::MAX as f64 + 1.0),
432            Bson::Double(i32::MAX as f64 + 1.0)
433        );
434        assert_eq!(to_bson_number(1e20), Bson::Double(1e20));
435    }
436
437    #[test]
438    fn a_pointer_field_collapses_to_class_dollar_id() {
439        let doc = parse_object_to_mongo_create(
440            &post_schema(),
441            &map(vec![(
442                "author",
443                ParseValue::Pointer {
444                    class_name: "_User".into(),
445                    object_id: "abc123".into(),
446                },
447            )]),
448        )
449        .expect("transform");
450        assert_eq!(doc.get_str("_p_author").expect("_p_author"), "_User$abc123");
451        assert!(
452            !doc.contains_key("author"),
453            "must not also store the raw key"
454        );
455    }
456
457    /// UPSTREAM-QUIRK. A nested pointer keeps its full shape, because the collapse is a key
458    /// transformation and an interior value has no key.
459    #[test]
460    fn a_nested_pointer_keeps_its_type_envelope() {
461        let doc = parse_object_to_mongo_create(
462            &post_schema(),
463            &map(vec![(
464                "tags",
465                ParseValue::Array(vec![ParseValue::Pointer {
466                    class_name: "Tag".into(),
467                    object_id: "t1".into(),
468                }]),
469            )]),
470        )
471        .expect("transform");
472        let arr = doc.get_array("tags").expect("tags");
473        let nested = arr[0].as_document().expect("document");
474        assert_eq!(nested.get_str("__type").expect("__type"), "Pointer");
475        assert_eq!(nested.get_str("className").expect("className"), "Tag");
476    }
477
478    #[test]
479    fn relation_values_are_dropped_not_stored() {
480        let doc = parse_object_to_mongo_create(
481            &post_schema(),
482            &map(vec![
483                ("title", ParseValue::String("x".into())),
484                (
485                    "comments",
486                    ParseValue::Relation {
487                        class_name: "Comment".into(),
488                    },
489                ),
490            ]),
491        )
492        .expect("transform");
493        assert!(doc.contains_key("title"));
494        assert!(
495            !doc.contains_key("comments"),
496            "a Relation lives in a join table, not on the object"
497        );
498    }
499
500    #[test]
501    fn read_back_restores_keys_and_pointers() {
502        let mut doc = Document::new();
503        doc.insert("_id", "objid1");
504        doc.insert("title", "hello");
505        doc.insert("views", Bson::Int32(7));
506        doc.insert("_p_author", "_User$abc123");
507        doc.insert(
508            "_created_at",
509            Bson::DateTime(bson::DateTime::from_millis(1_700_000_000_000)),
510        );
511
512        let parsed = mongo_object_to_parse(&doc).expect("untransform");
513        assert!(matches!(parsed.get("objectId"), Some(ParseValue::String(s)) if s == "objid1"));
514        assert!(matches!(parsed.get("views"), Some(ParseValue::Number(n)) if *n == 7.0));
515        assert!(matches!(
516            parsed.get("author"),
517            Some(ParseValue::Pointer { class_name, object_id })
518                if class_name == "_User" && object_id == "abc123"
519        ));
520        assert!(matches!(parsed.get("createdAt"), Some(ParseValue::Date(_))));
521    }
522
523    /// Regression: these used to be dropped here, which meant `raise_acl` never saw them and a
524    /// stored ACL could never be returned to a client.
525    #[test]
526    fn permission_columns_survive_for_the_acl_rebuild() {
527        let mut doc = Document::new();
528        doc.insert("title", "x");
529        doc.insert("_rperm", Bson::Array(vec![Bson::String("*".into())]));
530        doc.insert("_wperm", Bson::Array(vec![]));
531        doc.insert("_acl", Document::new());
532
533        let parsed = mongo_object_to_parse(&doc).expect("untransform");
534        assert!(parsed.get("_rperm").is_some(), "raise_acl needs this");
535        assert!(parsed.get("_wperm").is_some(), "raise_acl needs this");
536        assert!(
537            parsed.get("_acl").is_none(),
538            "the legacy mirror is write-only and is dropped on read"
539        );
540    }
541
542    #[test]
543    fn internal_columns_survive_under_their_own_names() {
544        // Login needs to read the hash. It must NOT come back as `password`, which is the name
545        // upstream raises it under and the one a response filter then has to strip again.
546        let mut doc = Document::new();
547        doc.insert("_hashed_password", "$2b$10$abc");
548        doc.insert("_session_token", "r:tok");
549        let parsed = mongo_object_to_parse(&doc).expect("untransform");
550        assert!(parsed.get("_hashed_password").is_some());
551        assert!(
552            parsed.get("password").is_none(),
553            "the hash must never be raised under a user-facing name"
554        );
555        // This one IS renamed, matching upstream.
556        assert!(parsed.get("sessionToken").is_some());
557    }
558
559    #[test]
560    fn an_unknown_underscore_key_is_refused_rather_than_passed_through() {
561        let mut doc = Document::new();
562        doc.insert("_mystery", "x"); // not in INTERNAL_COLUMNS
563        let err = mongo_object_to_parse(&doc).unwrap_err();
564        assert!(err.message.contains("bad key in untransform"));
565    }
566
567    #[test]
568    fn int64_and_int32_both_raise_to_one_number_type() {
569        let mut doc = Document::new();
570        doc.insert("a", Bson::Int32(1));
571        doc.insert("b", Bson::Int64(2));
572        doc.insert("c", Bson::Double(3.5));
573        let parsed = mongo_object_to_parse(&doc).expect("untransform");
574        for (k, expected) in [("a", 1.0), ("b", 2.0), ("c", 3.5)] {
575            assert!(matches!(parsed.get(k), Some(ParseValue::Number(n)) if *n == expected));
576        }
577    }
578}