Skip to main content

parse_rust_schema/
infer.rs

1//! Type inference, name validation, and default columns.
2//!
3//! Upstream: `getType` and `getObjectType` (`SchemaController.js:1555-1640`), plus
4//! `classNameIsValid` and `fieldNameIsValid` (`:446-480`).
5//!
6//! Inference is what makes Parse's schema implicit: the first write that mentions a field decides
7//! its type forever. Everything downstream, including the error a client sees on the *second*
8//! write, follows from getting this exactly right.
9
10use parse_rust_core::{ParseError, ParseValue};
11use parse_rust_storage::FieldType;
12
13/// The four columns every class has (`defaultColumns._Default`).
14///
15/// A client cannot create or redefine these: `fieldNameIsValidForClass` refuses a field name that
16/// collides with a default column.
17pub const DEFAULT_COLUMNS: [(&str, FieldType); 4] = [
18    ("objectId", FieldType::String),
19    ("createdAt", FieldType::Date),
20    ("updatedAt", FieldType::Date),
21    ("ACL", FieldType::Acl),
22];
23
24/// The additional default columns of `_User` (`defaultColumns._User`).
25///
26/// Without these, `email` infers its type from whatever the first write happens to contain, so a
27/// numeric email is accepted and permanently fixes the column as a Number.
28pub const USER_COLUMNS: [(&str, FieldType); 5] = [
29    ("username", FieldType::String),
30    ("password", FieldType::String),
31    ("email", FieldType::String),
32    ("emailVerified", FieldType::Boolean),
33    ("authData", FieldType::Object),
34];
35
36/// Classes Parse defines itself.
37pub const SYSTEM_CLASSES: [&str; 8] = [
38    "_User",
39    "_Installation",
40    "_Role",
41    "_Session",
42    "_Product",
43    "_PushStatus",
44    "_JobStatus",
45    "_JobSchedule",
46];
47
48/// Infer a field type from a value, as `getType` does.
49///
50/// **`None` means "no type", not "unknown".** A literal `null` yields `undefined` upstream, and
51/// the write path skips the field rather than creating it, which is why writing `null` to a new
52/// field never adds a column. Returning `Option` here keeps that distinction at the type level
53/// instead of leaving it to a caller to remember.
54///
55/// A tagged value with its required member missing also yields `None`: upstream's `switch` breaks
56/// out of the case and falls through to returning `undefined`, so `{"__type":"Pointer"}` with no
57/// `className` is not a Pointer and not an error at this stage.
58pub fn infer_type(value: &ParseValue) -> Option<FieldType> {
59    match value {
60        ParseValue::Null => None,
61        ParseValue::Bool(_) => Some(FieldType::Boolean),
62        ParseValue::String(_) => Some(FieldType::String),
63        ParseValue::Number(_) => Some(FieldType::Number),
64        ParseValue::Array(_) => Some(FieldType::Array),
65        ParseValue::Object(_) => Some(FieldType::Object),
66        ParseValue::Date(_) => Some(FieldType::Date),
67        ParseValue::Bytes(_) => Some(FieldType::Bytes),
68        ParseValue::GeoPoint { .. } => Some(FieldType::GeoPoint),
69        ParseValue::Polygon(_) => Some(FieldType::Polygon),
70        ParseValue::File { .. } => Some(FieldType::File),
71        ParseValue::Pointer { class_name, .. } => Some(FieldType::Pointer {
72            target_class: class_name.clone(),
73        }),
74        ParseValue::Relation { class_name } => Some(FieldType::Relation {
75            target_class: class_name.clone(),
76        }),
77    }
78}
79
80/// `classAndFieldRegex`, `/^[A-Za-z][A-Za-z0-9_]*$/`.
81fn matches_class_and_field_regex(s: &str) -> bool {
82    let mut chars = s.chars();
83    match chars.next() {
84        Some(c) if c.is_ascii_alphabetic() => {}
85        _ => return false,
86    }
87    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
88}
89
90/// Is this a class a client may address?
91///
92/// Three ways to be valid: a system class, a join table, or an ordinary name matching the regex.
93/// The join-table form matters because `_Join:` names are the only underscore-prefixed classes a
94/// non-system path constructs.
95pub fn class_name_is_valid(class_name: &str) -> bool {
96    if SYSTEM_CLASSES.contains(&class_name) {
97        return true;
98    }
99    if let Some(rest) = class_name.strip_prefix("_Join:") {
100        // `/^_Join:[A-Za-z0-9_]+:[A-Za-z0-9_]+/`. Note upstream's regex is unanchored at the end,
101        // so trailing content is accepted; reproduce that rather than tightening it.
102        let mut parts = rest.splitn(2, ':');
103        let (a, b) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""));
104        let ok =
105            |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
106        // The second segment only needs a valid prefix, since the regex is unanchored.
107        let b_prefix: String = b
108            .chars()
109            .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
110            .collect();
111        return ok(a) && !b_prefix.is_empty();
112    }
113    matches_class_and_field_regex(class_name)
114}
115
116/// Field names a client may not use at all.
117///
118/// `className` is refused on every class except `_Hooks`, which is the exception upstream carves
119/// out because a hook document legitimately carries one.
120pub fn field_name_is_valid(field_name: &str, class_name: &str) -> bool {
121    if !class_name.is_empty() && class_name != "_Hooks" && field_name == "className" {
122        return false;
123    }
124    matches_class_and_field_regex(field_name)
125}
126
127/// Additionally refuses the default columns, which a client cannot redefine.
128pub fn field_name_is_valid_for_class(field_name: &str, class_name: &str) -> bool {
129    if !field_name_is_valid(field_name, class_name) {
130        return false;
131    }
132    !DEFAULT_COLUMNS.iter().any(|(name, _)| *name == field_name)
133}
134
135/// The `INCORRECT_TYPE` a client sees when a write disagrees with the stored type.
136///
137/// The message is API. `spec/` asserts on it, and the parametric rendering `Pointer<_User>` is
138/// part of the string (`SchemaController.js:1165-1173`, `typeToString` at `:697-705`).
139pub fn schema_mismatch(
140    class_name: &str,
141    field_name: &str,
142    expected: &FieldType,
143    got: &FieldType,
144) -> ParseError {
145    ParseError::incorrect_type(format!(
146        "schema mismatch for {class_name}.{field_name}; expected {} but got {}",
147        expected.to_wire_string(),
148        got.to_wire_string()
149    ))
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use parse_rust_core::{ParseDate, ParseMap};
156
157    #[test]
158    fn null_infers_no_type_at_all() {
159        // The rule behind "writing null never creates a field". Modeled as None rather than as a
160        // Null type so a caller cannot accidentally create a column for it.
161        assert_eq!(infer_type(&ParseValue::Null), None);
162    }
163
164    #[test]
165    fn scalars_infer_as_upstream_does() {
166        assert_eq!(
167            infer_type(&ParseValue::Bool(true)),
168            Some(FieldType::Boolean)
169        );
170        assert_eq!(
171            infer_type(&ParseValue::String("x".into())),
172            Some(FieldType::String)
173        );
174        assert_eq!(
175            infer_type(&ParseValue::Number(1.0)),
176            Some(FieldType::Number)
177        );
178        // Integral versus fractional does not change the inferred type; both are Number. The
179        // Int32/Double split is a storage concern, not a schema one.
180        assert_eq!(
181            infer_type(&ParseValue::Number(1.5)),
182            Some(FieldType::Number)
183        );
184    }
185
186    #[test]
187    fn containers_and_tagged_values() {
188        assert_eq!(
189            infer_type(&ParseValue::Array(vec![])),
190            Some(FieldType::Array)
191        );
192        assert_eq!(
193            infer_type(&ParseValue::Object(ParseMap::new())),
194            Some(FieldType::Object)
195        );
196        assert_eq!(
197            infer_type(&ParseValue::Date(
198                ParseDate::parse_iso("2026-01-01T00:00:00.000Z").expect("date")
199            )),
200            Some(FieldType::Date)
201        );
202        assert_eq!(
203            infer_type(&ParseValue::GeoPoint {
204                latitude: 1.0,
205                longitude: 2.0
206            }),
207            Some(FieldType::GeoPoint)
208        );
209    }
210
211    #[test]
212    fn pointers_and_relations_carry_their_target() {
213        assert_eq!(
214            infer_type(&ParseValue::Pointer {
215                class_name: "_User".into(),
216                object_id: "x".into()
217            }),
218            Some(FieldType::Pointer {
219                target_class: "_User".into()
220            })
221        );
222        assert_eq!(
223            infer_type(&ParseValue::Relation {
224                class_name: "Post".into()
225            }),
226            Some(FieldType::Relation {
227                target_class: "Post".into()
228            })
229        );
230    }
231
232    #[test]
233    fn class_names_follow_the_regex() {
234        assert!(class_name_is_valid("Post"));
235        assert!(class_name_is_valid("A1_b"));
236        assert!(!class_name_is_valid(""));
237        assert!(!class_name_is_valid("1Post"), "must not start with a digit");
238        assert!(!class_name_is_valid("_Custom"), "must not start with _");
239        assert!(!class_name_is_valid("has-dash"));
240        assert!(!class_name_is_valid("has space"));
241    }
242
243    #[test]
244    fn system_and_join_classes_are_valid_despite_the_underscore() {
245        for c in SYSTEM_CLASSES {
246            assert!(class_name_is_valid(c), "{c} should be valid");
247        }
248        assert!(class_name_is_valid("_Join:likes:Post"));
249        assert!(!class_name_is_valid("_Join:likes"), "needs both segments");
250    }
251
252    #[test]
253    fn class_name_is_refused_as_a_field_except_on_hooks() {
254        assert!(!field_name_is_valid("className", "Post"));
255        // The one carve-out upstream makes.
256        assert!(field_name_is_valid("className", "_Hooks"));
257    }
258
259    #[test]
260    fn default_columns_cannot_be_redefined() {
261        for (name, _) in DEFAULT_COLUMNS {
262            assert!(
263                !field_name_is_valid_for_class(name, "Post"),
264                "{name} is a default column"
265            );
266        }
267        assert!(field_name_is_valid_for_class("title", "Post"));
268    }
269
270    #[test]
271    fn the_mismatch_message_is_byte_exact() {
272        let e = schema_mismatch(
273            "Post",
274            "author",
275            &FieldType::Pointer {
276                target_class: "_User".into(),
277            },
278            &FieldType::String,
279        );
280        assert_eq!(
281            e.message,
282            "schema mismatch for Post.author; expected Pointer<_User> but got String"
283        );
284        assert_eq!(e.code, parse_rust_core::ErrorCode::IncorrectType);
285    }
286}