Skip to main content

parse_rust_schema/
storage_format.rs

1//! The `_SCHEMA` document format.
2//!
3//! **This is the one place where a mistake breaks a mixed fleet silently rather than loudly**, so
4//! it gets its own module and its own round-trip tests.
5//!
6//! A `_SCHEMA` document is `{_id: <className>, <field>: <short type string>, ...}` plus
7//! `_metadata` and `_client_permissions`. The type strings are terse and lossy-looking but exact:
8//! `parseFieldTypeToMongoFieldType` (`MongoSchemaCollection.js:126-152`) going down, and
9//! `mongoFieldToParseSchemaField` (`:4-39`) coming back.
10//!
11//! Two hazards, both silent:
12//!
13//! - **Never add a key.** parse-server reads an unknown top-level `_SCHEMA` key as a phantom
14//!   field, so a key parse-rust invents for bookkeeping becomes a real column on every
15//!   parse-server node reading the same database.
16//! - **Never invent a type string.** `mongoFieldToParseSchemaField` is a `switch` with **no
17//!   default case**, so an unrecognised type falls off the end and the field's parsed entry
18//!   becomes `undefined`, with no error anywhere.
19
20use parse_rust_storage::FieldType;
21
22/// Keys in a `_SCHEMA` document that are not fields (`nonFieldSchemaKeys`).
23pub const NON_FIELD_KEYS: [&str; 3] = ["_id", "_metadata", "_client_permissions"];
24
25/// Lower a field type to its `_SCHEMA` string.
26///
27/// Total on purpose: every variant is named, so adding a `FieldType` fails to compile here rather
28/// than silently writing a string parse-server cannot read.
29pub fn field_type_to_storage(ty: &FieldType) -> String {
30    match ty {
31        FieldType::Pointer { target_class } => format!("*{target_class}"),
32        FieldType::Relation { target_class } => format!("relation<{target_class}>"),
33        FieldType::Number => "number".into(),
34        FieldType::String => "string".into(),
35        FieldType::Boolean => "boolean".into(),
36        FieldType::Date => "date".into(),
37        FieldType::Object => "object".into(),
38        FieldType::Array => "array".into(),
39        FieldType::GeoPoint => "geopoint".into(),
40        FieldType::File => "file".into(),
41        FieldType::Bytes => "bytes".into(),
42        FieldType::Polygon => "polygon".into(),
43        // `ACL` is never written into `_SCHEMA`. Measured: parse-server stores `objectId`,
44        // `createdAt` and `updatedAt` as ordinary keys but not `ACL`, because
45        // `mongoSchemaFieldsToParseSchemaFields` injects it on read. Emitting a string here would
46        // create precisely the phantom column the rule forbids, so this returns the empty string
47        // and `write_schema_document` skips it. Asserted by the format differential.
48        FieldType::Acl => String::new(),
49    }
50}
51
52/// Raise a `_SCHEMA` string back to a field type.
53///
54/// `None` for anything unrecognised, mirroring upstream's missing default case. The caller
55/// decides what to do with that: upstream produces an `undefined` field entry, which is the
56/// behavior a mixed fleet has to survive.
57pub fn storage_to_field_type(s: &str) -> Option<FieldType> {
58    if let Some(target) = s.strip_prefix('*') {
59        return Some(FieldType::Pointer {
60            target_class: target.to_string(),
61        });
62    }
63    if let Some(rest) = s.strip_prefix("relation<") {
64        return rest.strip_suffix('>').map(|target| FieldType::Relation {
65            target_class: target.to_string(),
66        });
67    }
68    Some(match s {
69        "number" => FieldType::Number,
70        "string" => FieldType::String,
71        "boolean" => FieldType::Boolean,
72        "date" => FieldType::Date,
73        // Upstream maps both `map` and `object` to Object. `map` is a legacy spelling that still
74        // exists in old databases, which is exactly the kind of value a migration will meet.
75        "map" | "object" => FieldType::Object,
76        "array" => FieldType::Array,
77        "geopoint" => FieldType::GeoPoint,
78        "file" => FieldType::File,
79        "bytes" => FieldType::Bytes,
80        "polygon" => FieldType::Polygon,
81        _ => return None,
82    })
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn acl_is_not_storable() {
91        assert!(
92            field_type_to_storage(&FieldType::Acl).is_empty(),
93            "ACL must never be written into _SCHEMA"
94        );
95    }
96
97    #[test]
98    fn every_type_round_trips() {
99        let types = [
100            FieldType::Number,
101            FieldType::String,
102            FieldType::Boolean,
103            FieldType::Date,
104            FieldType::Object,
105            FieldType::Array,
106            FieldType::GeoPoint,
107            FieldType::File,
108            FieldType::Bytes,
109            FieldType::Polygon,
110            FieldType::Pointer {
111                target_class: "_User".into(),
112            },
113            FieldType::Relation {
114                target_class: "Post".into(),
115            },
116        ];
117        for ty in types {
118            let s = field_type_to_storage(&ty);
119            assert_eq!(
120                storage_to_field_type(&s).as_ref(),
121                Some(&ty),
122                "{ty:?} did not round trip through {s:?}"
123            );
124        }
125    }
126
127    /// The exact strings parse-server writes. If any of these changes, a parse-server node reading
128    /// the same database sees a different schema.
129    #[test]
130    fn storage_strings_are_byte_exact() {
131        assert_eq!(field_type_to_storage(&FieldType::String), "string");
132        assert_eq!(field_type_to_storage(&FieldType::Number), "number");
133        assert_eq!(field_type_to_storage(&FieldType::Boolean), "boolean");
134        assert_eq!(field_type_to_storage(&FieldType::Date), "date");
135        assert_eq!(field_type_to_storage(&FieldType::Object), "object");
136        assert_eq!(field_type_to_storage(&FieldType::GeoPoint), "geopoint");
137        assert_eq!(
138            field_type_to_storage(&FieldType::Pointer {
139                target_class: "_User".into()
140            }),
141            "*_User",
142            "a pointer is an asterisk and the class name, with no separator"
143        );
144        assert_eq!(
145            field_type_to_storage(&FieldType::Relation {
146                target_class: "Post".into()
147            }),
148            "relation<Post>"
149        );
150    }
151
152    #[test]
153    fn the_legacy_map_spelling_still_reads() {
154        // Old databases contain `map` where new ones contain `object`. A migration meets both.
155        assert_eq!(storage_to_field_type("map"), Some(FieldType::Object));
156        assert_eq!(storage_to_field_type("object"), Some(FieldType::Object));
157    }
158
159    /// The mechanism behind "never invent a type string".
160    #[test]
161    fn an_unknown_type_string_reads_as_nothing_rather_than_erroring() {
162        assert_eq!(storage_to_field_type("vector"), None);
163        assert_eq!(storage_to_field_type("Number"), None, "case matters");
164        assert_eq!(storage_to_field_type(""), None);
165        // A malformed relation is unrecognised rather than a Relation with a broken target.
166        assert_eq!(storage_to_field_type("relation<unterminated"), None);
167    }
168
169    #[test]
170    fn non_field_keys_are_the_upstream_set() {
171        assert_eq!(NON_FIELD_KEYS, ["_id", "_metadata", "_client_permissions"]);
172    }
173}