Skip to main content

parse_rust_storage/
schema.rs

1//! Field types, and the per-class shape a transform needs.
2//!
3//! This lives in `parse-rust-storage` rather than `parse-rust-schema` because both adapters need it to
4//! lower a value, and the transform cannot be written without knowing which fields are Pointers.
5//! `parse-rust-schema` will own inference, validation and CLP on top of these types.
6
7use indexmap::IndexMap;
8
9use parse_rust_core::{ClassLevelPermissions, ParseMap};
10
11/// A Parse field type.
12///
13/// `Pointer` and `Relation` carry their target class because the wire form does
14/// (`Pointer<_User>`), and because the Mongo transform needs the target to build the
15/// `"<Class>$<id>"` storage form.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum FieldType {
18    String,
19    Number,
20    Boolean,
21    Date,
22    Object,
23    Array,
24    GeoPoint,
25    File,
26    Bytes,
27    Polygon,
28    Pointer {
29        target_class: String,
30    },
31    Relation {
32        target_class: String,
33    },
34    /// Not a user-settable field type. `ACL` is a real column in the Parse schema, and
35    /// `mongoSchemaFieldsToParseSchemaFields` injects it unconditionally.
36    Acl,
37}
38
39impl FieldType {
40    /// The wire rendering, which is part of the error-message contract.
41    ///
42    /// `typeToString` produces `Pointer<_User>` for parametric types
43    /// (`SchemaController.js:697-705`), and that exact string appears inside
44    /// `schema mismatch for <Class>.<field>; expected <expected> but got <got>`. Getting the
45    /// rendering wrong changes an error a client may match on.
46    pub fn to_wire_string(&self) -> String {
47        match self {
48            FieldType::String => "String".into(),
49            FieldType::Number => "Number".into(),
50            FieldType::Boolean => "Boolean".into(),
51            FieldType::Date => "Date".into(),
52            FieldType::Object => "Object".into(),
53            FieldType::Array => "Array".into(),
54            FieldType::GeoPoint => "GeoPoint".into(),
55            FieldType::File => "File".into(),
56            FieldType::Bytes => "Bytes".into(),
57            FieldType::Polygon => "Polygon".into(),
58            FieldType::Acl => "ACL".into(),
59            FieldType::Pointer { target_class } => format!("Pointer<{target_class}>"),
60            FieldType::Relation { target_class } => format!("Relation<{target_class}>"),
61        }
62    }
63
64    pub fn is_pointer(&self) -> bool {
65        matches!(self, FieldType::Pointer { .. })
66    }
67
68    pub fn is_relation(&self) -> bool {
69        matches!(self, FieldType::Relation { .. })
70    }
71
72    /// The class a parametric type points at.
73    pub fn target_class(&self) -> Option<&str> {
74        match self {
75            FieldType::Pointer { target_class } | FieldType::Relation { target_class } => {
76                Some(target_class)
77            }
78            _ => None,
79        }
80    }
81}
82
83/// The join collection backing one `Relation` field.
84///
85/// `_Join:<key>:<className>` (`DatabaseController.js:319-321`). Note the argument order: the key
86/// comes first and the *owning* class second, so `_Role.users` is `_Join:users:_Role`. Getting it
87/// backwards produces a collection parse-server will never read.
88pub fn join_table_name(class_name: &str, key: &str) -> String {
89    format!("_Join:{key}:{class_name}")
90}
91
92/// The fixed schema every join collection has.
93///
94/// Two string columns and nothing else (`DatabaseController.js:418-420`). Deliberately built
95/// here rather than fetched: join collections have **no `_SCHEMA` row at all** upstream, and
96/// writing one would add a class every parse-server node reading the database would then see.
97pub fn join_schema(class_name: &str, key: &str) -> ClassSchema {
98    ClassSchema::new(join_table_name(class_name, key))
99        .with_field("relatedId", FieldType::String)
100        .with_field("owningId", FieldType::String)
101}
102
103/// The shape of one class: what a transform needs to lower or raise a document.
104///
105/// Order-preserving, because `_SCHEMA` documents are compared in golden files and because the
106/// order fields were added is the order upstream writes them.
107#[derive(Debug, Clone, Default)]
108pub struct ClassSchema {
109    pub class_name: String,
110    pub fields: IndexMap<String, FieldType>,
111    /// `_metadata.class_permissions`, parsed.
112    ///
113    /// **`None` is not "public".** It is "the key is absent", which reads back as `defaultCLPS`,
114    /// a fully public block *including* an `ACL` key that the present-but-partial case never
115    /// carries (`MongoSchemaCollection.js:67-112`). The distinction is preserved rather than
116    /// normalized, because normalizing either way rewrites a block parse-server reads.
117    pub clp: Option<ClassLevelPermissions>,
118    /// `_metadata.indexes`, round-tripped verbatim and never interpreted.
119    pub indexes: Option<ParseMap>,
120    /// `_metadata.fields_options`, round-tripped **as sent**. A schema body is decoded without
121    /// interpreting a `__type` envelope, so an offset instant, unpadded base64 and any key the
122    /// envelope does not declare all survive, which is what a parse-server node reading the same
123    /// row expects. 0.2.0 stores `required` and `defaultValue` for fleet safety and does not
124    /// enforce them.
125    pub field_options: Option<ParseMap>,
126}
127
128impl ClassSchema {
129    pub fn new(class_name: impl Into<String>) -> Self {
130        Self {
131            class_name: class_name.into(),
132            fields: IndexMap::new(),
133            clp: None,
134            indexes: None,
135            field_options: None,
136        }
137    }
138
139    pub fn with_clp(mut self, clp: ClassLevelPermissions) -> Self {
140        self.clp = Some(clp);
141        self
142    }
143
144    /// Every `Relation` field, with its target class.
145    pub fn relation_fields(&self) -> impl Iterator<Item = (&str, &str)> {
146        self.fields.iter().filter_map(|(name, ty)| match ty {
147            FieldType::Relation { target_class } => Some((name.as_str(), target_class.as_str())),
148            _ => None,
149        })
150    }
151
152    pub fn with_field(mut self, name: impl Into<String>, ty: FieldType) -> Self {
153        self.fields.insert(name.into(), ty);
154        self
155    }
156
157    pub fn field(&self, name: &str) -> Option<&FieldType> {
158        self.fields.get(name)
159    }
160
161    /// Is this field stored under a `_p_` prefix?
162    ///
163    /// Note the deliberate narrowness: only a *declared* Pointer field is prefixed. A pointer
164    /// value written to a field the schema does not know about is not prefixed by
165    /// `transformKey`, because that function consults the schema and nothing else.
166    pub fn is_pointer_field(&self, name: &str) -> bool {
167        self.field(name).is_some_and(FieldType::is_pointer)
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn parametric_types_render_with_angle_brackets() {
177        assert_eq!(
178            FieldType::Pointer {
179                target_class: "_User".into()
180            }
181            .to_wire_string(),
182            "Pointer<_User>"
183        );
184        assert_eq!(
185            FieldType::Relation {
186                target_class: "Post".into()
187            }
188            .to_wire_string(),
189            "Relation<Post>"
190        );
191        assert_eq!(FieldType::String.to_wire_string(), "String");
192    }
193
194    #[test]
195    fn only_declared_pointer_fields_are_prefixed() {
196        let s = ClassSchema::new("Post").with_field(
197            "author",
198            FieldType::Pointer {
199                target_class: "_User".into(),
200            },
201        );
202        assert!(s.is_pointer_field("author"));
203        assert!(!s.is_pointer_field("undeclared"));
204    }
205}