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}