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::{Op, 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/// Field names no class may use (`invalidColumns`, `SchemaController.js:163`).
37///
38/// One entry, and it is not arbitrary: `length` collides with `Array.prototype.length` on the
39/// JavaScript side, so upstream refuses it everywhere `fieldNameIsValid` is consulted, which
40/// includes class names.
41pub const INVALID_COLUMNS: [&str; 1] = ["length"];
42
43/// Classes Parse defines itself (`systemClasses`, `SchemaController.js:165-176`).
44///
45/// **Not the same list as [`VOLATILE_CLASSES`]**, and the two are frequently conflated. See that
46/// constant for the difference.
47pub const SYSTEM_CLASSES: [&str; 10] = [
48    "_User",
49    "_Installation",
50    "_Role",
51    "_Session",
52    "_Product",
53    "_PushStatus",
54    "_JobStatus",
55    "_JobSchedule",
56    "_Audience",
57    "_Idempotency",
58];
59
60/// Classes held in memory rather than loaded from `_SCHEMA` (`volatileClasses`,
61/// `SchemaController.js:178-187`).
62///
63/// The two lists overlap but neither contains the other, and the difference is the point.
64///
65/// - `_Hooks`, `_GlobalConfig` and `_GraphQLConfig` are volatile but **not** system classes, so
66///   `classNameIsValid` refuses them: a client cannot address `/classes/_Hooks`. They are reached
67///   only through their own routers.
68/// - `_User`, `_Installation`, `_Role`, `_Session` and `_Product` are system but **not**
69///   volatile: they are real, persisted, client-addressable classes.
70/// - `_JobStatus`, `_PushStatus`, `_JobSchedule`, `_Audience` and `_Idempotency` are both.
71///
72/// `SchemaData` skips a volatile class when reading `_SCHEMA` and injects a synthetic entry
73/// instead (`SchemaController.js:566-568`, `:596-614`), so a stored document for one of these is
74/// ignored rather than merged.
75pub const VOLATILE_CLASSES: [&str; 8] = [
76    "_JobStatus",
77    "_PushStatus",
78    "_Hooks",
79    "_GlobalConfig",
80    "_GraphQLConfig",
81    "_JobSchedule",
82    "_Audience",
83    "_Idempotency",
84];
85
86/// The additional default columns of one class, or empty for a class that has none.
87///
88/// `defaultColumns` is a table of thirteen classes upstream. Only the four parse-rust writes to
89/// today are modelled, because a table entry that is never exercised is a transcription that
90/// nothing checks. Adding a class here is required before that class can be served.
91///
92/// Not a `const`, because `Relation`/`Pointer` carry an owned target class.
93pub fn default_columns_for(class_name: &str) -> Vec<(&'static str, FieldType)> {
94    let pointer = |target: &str| FieldType::Pointer {
95        target_class: target.to_string(),
96    };
97    let relation = |target: &str| FieldType::Relation {
98        target_class: target.to_string(),
99    };
100    match class_name {
101        "_User" => USER_COLUMNS.iter().map(|(n, t)| (*n, t.clone())).collect(),
102        // `SchemaController.js:64-69`.
103        "_Role" => vec![
104            ("name", FieldType::String),
105            ("users", relation("_User")),
106            ("roles", relation("_Role")),
107        ],
108        // `SchemaController.js:70-77`. Note `user` is a Pointer while `_Role`'s memberships are
109        // Relations, so a session has a column and a role has a join collection.
110        "_Session" => vec![
111            ("user", pointer("_User")),
112            ("installationId", FieldType::String),
113            ("sessionToken", FieldType::String),
114            ("expiresAt", FieldType::Date),
115            ("createdWith", FieldType::Object),
116        ],
117        _ => Vec::new(),
118    }
119}
120
121/// Is this field a default column of this class, counting both `_Default` and the class's own?
122pub fn is_default_column(class_name: &str, field_name: &str) -> bool {
123    DEFAULT_COLUMNS.iter().any(|(n, _)| *n == field_name)
124        || default_columns_for(class_name)
125            .iter()
126            .any(|(n, _)| *n == field_name)
127}
128
129/// Columns that must be present for a **write** to a class to be accepted
130/// (`requiredColumns.write`, `SchemaController.js:157-160`).
131///
132/// `_Role`'s `ACL` entry is load-bearing rather than cosmetic: a role saved without an ACL is
133/// world-writable, so any client could add itself to it.
134pub fn required_write_columns(class_name: &str) -> &'static [&'static str] {
135    match class_name {
136        "_Product" => &["productIdentifier", "icon", "order", "title", "subtitle"],
137        "_Role" => &["name", "ACL"],
138        _ => &[],
139    }
140}
141
142/// Columns that must be present for a **read** of a class (`requiredColumns.read`,
143/// `SchemaController.js:154-156`).
144///
145/// Carried for completeness of the table. Upstream exports `requiredColumns` whole and only the
146/// write half is consulted by `validateRequiredColumns`; the read half is used by the GraphQL
147/// schema builder, which is out of scope until M7.
148pub fn required_read_columns(class_name: &str) -> &'static [&'static str] {
149    match class_name {
150        "_User" => &["username"],
151        _ => &[],
152    }
153}
154
155/// `invalidClassNameMessage` (`SchemaController.js:483-489`).
156///
157/// **Note the trailing space.** It is in the upstream string literal, it reaches the client, and
158/// a client matching on the message would not match without it.
159pub fn invalid_class_name_message(class_name: &str) -> String {
160    format!(
161        "Invalid classname: {class_name}, classnames can only have alphanumeric characters and _, \
162         and must start with an alpha character "
163    )
164}
165
166/// Infer a field type from a value, as `getType` does.
167///
168/// **`None` means "no type", not "unknown".** A literal `null` yields `undefined` upstream, and
169/// the write path skips the field rather than creating it, which is why writing `null` to a new
170/// field never adds a column. Returning `Option` here keeps that distinction at the type level
171/// instead of leaving it to a caller to remember.
172///
173/// A tagged value with its required member missing also yields `None`: upstream's `switch` breaks
174/// out of the case and falls through to returning `undefined`, so `{"__type":"Pointer"}` with no
175/// `className` is not a Pointer and not an error at this stage.
176pub fn infer_type(value: &ParseValue) -> Option<FieldType> {
177    match value {
178        ParseValue::Null => None,
179        ParseValue::Bool(_) => Some(FieldType::Boolean),
180        ParseValue::String(_) => Some(FieldType::String),
181        ParseValue::Number(_) => Some(FieldType::Number),
182        ParseValue::Array(_) => Some(FieldType::Array),
183        ParseValue::Object(_) => Some(FieldType::Object),
184        ParseValue::Date(_) => Some(FieldType::Date),
185        ParseValue::Bytes(_) => Some(FieldType::Bytes),
186        ParseValue::GeoPoint { .. } => Some(FieldType::GeoPoint),
187        ParseValue::Polygon(_) => Some(FieldType::Polygon),
188        ParseValue::File { .. } => Some(FieldType::File),
189        ParseValue::Pointer { class_name, .. } => Some(FieldType::Pointer {
190            target_class: class_name.clone(),
191        }),
192        ParseValue::Relation { class_name } => Some(FieldType::Relation {
193            target_class: class_name.clone(),
194        }),
195    }
196}
197
198/// Infer a field type from an **operation**, as `getObjectType`'s `__op` arm does
199/// (`SchemaController.js:1634-1655`).
200///
201/// `None` means "no type", exactly as in [`infer_type`], so the field is skipped and no column is
202/// created. `Delete` is upstream's own `None` case (`:1638-1639`).
203///
204/// The relation ops are the interesting ones: the type comes from the *first pointer in the
205/// payload*, not from the op, so `AddRelation` with `[Pointer<Post>]` reserves
206/// `Relation<Post>` on the field. `Batch` recurses into its first op (`:1650-1651`), which is
207/// how the SDK's add-then-remove batch still infers a target class.
208///
209/// **Divergence, deliberate.** Upstream's arm reads `obj.objects[0].className` directly, so an
210/// empty `objects` array and a non-pointer first element have no defined type there. Neither is
211/// reachable here: both cases yield `None`, which routes the field down the same path `Delete`
212/// takes. Inventing an error code for either would be worse.
213///
214/// `SetOnInsert` is the case that returns `Err`. `getObjectType` has arms for every other op and
215/// a `default: throw` (`SchemaController.js:1652-1653`), and `SetOnInsert` is not among them, so
216/// upstream refuses it at that point too.
217///
218/// The op is nonetheless plumbed through the rest of upstream, flattening on create
219/// (`DatabaseController.js:333-335`), lowering to `$setOnInsert` (`MongoTransform.js:993-998`) and
220/// echoing its result back (`DatabaseController.js:2141`), because internal callers reach
221/// `DatabaseController` without passing `validateSchema`. parse-rust carries the same plumbing for
222/// the same reason and refuses it at the same place, so a client cannot get a write past
223/// parse-rust that parse-server would have rejected.
224pub fn infer_op_type(op: &Op) -> Result<Option<FieldType>, ParseError> {
225    Ok(match op {
226        Op::Increment(_) => Some(FieldType::Number),
227        Op::Delete => None,
228        Op::Add(_) | Op::AddUnique(_) | Op::Remove(_) => Some(FieldType::Array),
229        Op::AddRelation(objects) | Op::RemoveRelation(objects) => match objects.first() {
230            Some(ParseValue::Pointer { class_name, .. }) => Some(FieldType::Relation {
231                target_class: class_name.clone(),
232            }),
233            _ => None,
234        },
235        Op::Batch(ops) => match ops.first() {
236            Some(first) => infer_op_type(first)?,
237            None => None,
238        },
239        Op::SetOnInsert(_) => {
240            return Err(ParseError::internal(format!(
241                "unexpected op: {}",
242                op.name()
243            )))
244        }
245    })
246}
247
248/// `classAndFieldRegex`, `/^[A-Za-z][A-Za-z0-9_]*$/`.
249fn matches_class_and_field_regex(s: &str) -> bool {
250    let mut chars = s.chars();
251    match chars.next() {
252        Some(c) if c.is_ascii_alphabetic() => {}
253        _ => return false,
254    }
255    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
256}
257
258/// Is this a class a client may address?
259///
260/// Three ways to be valid: a system class, a join table, or an ordinary name matching the regex.
261/// The join-table form matters because `_Join:` names are the only underscore-prefixed classes a
262/// non-system path constructs.
263///
264/// The third branch is `fieldNameIsValid(className, className)` upstream
265/// (`SchemaController.js:454`), not the bare regex, which is why a class called `length` is
266/// refused: `invalidColumns` is consulted for class names too.
267pub fn class_name_is_valid(class_name: &str) -> bool {
268    if SYSTEM_CLASSES.contains(&class_name) {
269        return true;
270    }
271    if let Some(rest) = class_name.strip_prefix("_Join:") {
272        // `/^_Join:[A-Za-z0-9_]+:[A-Za-z0-9_]+/`. Note upstream's regex is unanchored at the end,
273        // so trailing content is accepted; reproduce that rather than tightening it.
274        let mut parts = rest.splitn(2, ':');
275        let (a, b) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""));
276        let ok =
277            |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
278        // The second segment only needs a valid prefix, since the regex is unanchored.
279        let b_prefix: String = b
280            .chars()
281            .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
282            .collect();
283        return ok(a) && !b_prefix.is_empty();
284    }
285    field_name_is_valid(class_name, class_name)
286}
287
288/// Field names a client may not use at all.
289///
290/// `className` is refused on every class except `_Hooks`, which is the exception upstream carves
291/// out because a hook document legitimately carries one.
292pub fn field_name_is_valid(field_name: &str, class_name: &str) -> bool {
293    if !class_name.is_empty() && class_name != "_Hooks" && field_name == "className" {
294        return false;
295    }
296    matches_class_and_field_regex(field_name) && !INVALID_COLUMNS.contains(&field_name)
297}
298
299/// Additionally refuses the default columns, which a client cannot redefine.
300///
301/// Both tables are consulted, `_Default` and the class's own (`SchemaController.js:474-479`).
302/// The second is what makes `name` un-addable on `_Role` while the same name is ordinary on any
303/// other class, and it only reaches as far as [`default_columns_for`] models: a class absent from
304/// that table has no class-specific columns to protect, so `_Installation`'s
305/// `field localeIdentifier cannot be added` is not reachable yet.
306pub fn field_name_is_valid_for_class(field_name: &str, class_name: &str) -> bool {
307    if !field_name_is_valid(field_name, class_name) {
308        return false;
309    }
310    !is_default_column(class_name, field_name)
311}
312
313/// The `INCORRECT_TYPE` a client sees when a write disagrees with the stored type.
314///
315/// The message is API. `spec/` asserts on it, and the parametric rendering `Pointer<_User>` is
316/// part of the string (`SchemaController.js:1165-1173`, `typeToString` at `:697-705`).
317pub fn schema_mismatch(
318    class_name: &str,
319    field_name: &str,
320    expected: &FieldType,
321    got: &FieldType,
322) -> ParseError {
323    ParseError::incorrect_type(format!(
324        "schema mismatch for {class_name}.{field_name}; expected {} but got {}",
325        expected.to_wire_string(),
326        got.to_wire_string()
327    ))
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use parse_rust_core::{ParseDate, ParseMap};
334
335    #[test]
336    fn null_infers_no_type_at_all() {
337        // The rule behind "writing null never creates a field". Modeled as None rather than as a
338        // Null type so a caller cannot accidentally create a column for it.
339        assert_eq!(infer_type(&ParseValue::Null), None);
340    }
341
342    #[test]
343    fn scalars_infer_as_upstream_does() {
344        assert_eq!(
345            infer_type(&ParseValue::Bool(true)),
346            Some(FieldType::Boolean)
347        );
348        assert_eq!(
349            infer_type(&ParseValue::String("x".into())),
350            Some(FieldType::String)
351        );
352        assert_eq!(
353            infer_type(&ParseValue::Number(1.0)),
354            Some(FieldType::Number)
355        );
356        // Integral versus fractional does not change the inferred type; both are Number. The
357        // Int32/Double split is a storage concern, not a schema one.
358        assert_eq!(
359            infer_type(&ParseValue::Number(1.5)),
360            Some(FieldType::Number)
361        );
362    }
363
364    #[test]
365    fn containers_and_tagged_values() {
366        assert_eq!(
367            infer_type(&ParseValue::Array(vec![])),
368            Some(FieldType::Array)
369        );
370        assert_eq!(
371            infer_type(&ParseValue::Object(ParseMap::new())),
372            Some(FieldType::Object)
373        );
374        assert_eq!(
375            infer_type(&ParseValue::Date(
376                ParseDate::parse_iso("2026-01-01T00:00:00.000Z").expect("date")
377            )),
378            Some(FieldType::Date)
379        );
380        assert_eq!(
381            infer_type(&ParseValue::GeoPoint {
382                latitude: 1.0,
383                longitude: 2.0
384            }),
385            Some(FieldType::GeoPoint)
386        );
387    }
388
389    #[test]
390    fn pointers_and_relations_carry_their_target() {
391        assert_eq!(
392            infer_type(&ParseValue::Pointer {
393                class_name: "_User".into(),
394                object_id: "x".into()
395            }),
396            Some(FieldType::Pointer {
397                target_class: "_User".into()
398            })
399        );
400        assert_eq!(
401            infer_type(&ParseValue::Relation {
402                class_name: "Post".into()
403            }),
404            Some(FieldType::Relation {
405                target_class: "Post".into()
406            })
407        );
408    }
409
410    #[test]
411    fn class_names_follow_the_regex() {
412        assert!(class_name_is_valid("Post"));
413        assert!(class_name_is_valid("A1_b"));
414        assert!(!class_name_is_valid(""));
415        assert!(!class_name_is_valid("1Post"), "must not start with a digit");
416        assert!(!class_name_is_valid("_Custom"), "must not start with _");
417        assert!(!class_name_is_valid("has-dash"));
418        assert!(!class_name_is_valid("has space"));
419    }
420
421    #[test]
422    fn system_and_join_classes_are_valid_despite_the_underscore() {
423        for c in SYSTEM_CLASSES {
424            assert!(class_name_is_valid(c), "{c} should be valid");
425        }
426        assert!(class_name_is_valid("_Join:likes:Post"));
427        assert!(!class_name_is_valid("_Join:likes"), "needs both segments");
428    }
429
430    #[test]
431    fn class_name_is_refused_as_a_field_except_on_hooks() {
432        assert!(!field_name_is_valid("className", "Post"));
433        // The one carve-out upstream makes.
434        assert!(field_name_is_valid("className", "_Hooks"));
435    }
436
437    #[test]
438    fn default_columns_cannot_be_redefined() {
439        for (name, _) in DEFAULT_COLUMNS {
440            assert!(
441                !field_name_is_valid_for_class(name, "Post"),
442                "{name} is a default column"
443            );
444        }
445        assert!(field_name_is_valid_for_class("title", "Post"));
446    }
447
448    #[test]
449    fn length_is_refused_as_a_field_and_as_a_class() {
450        // `invalidColumns`, and `classNameIsValid` routes through `fieldNameIsValid`, so the ban
451        // applies to class names too.
452        assert!(!field_name_is_valid("length", "Post"));
453        assert!(!class_name_is_valid("length"));
454        assert!(field_name_is_valid("width", "Post"));
455    }
456
457    #[test]
458    fn the_audience_and_idempotency_classes_are_system_classes() {
459        // Missing from an earlier eight-entry transcription. Without them `_Idempotency` fails
460        // `classNameIsValid` and the idempotency middleware cannot create its own class.
461        assert_eq!(SYSTEM_CLASSES.len(), 10);
462        assert!(class_name_is_valid("_Audience"));
463        assert!(class_name_is_valid("_Idempotency"));
464    }
465
466    #[test]
467    fn the_required_columns_table_is_upstreams() {
468        assert_eq!(required_read_columns("_User"), ["username"]);
469        assert_eq!(required_read_columns("Post"), [] as [&str; 0]);
470        assert_eq!(
471            required_write_columns("_Product"),
472            ["productIdentifier", "icon", "order", "title", "subtitle"]
473        );
474        assert_eq!(required_write_columns("_Role"), ["name", "ACL"]);
475        assert_eq!(required_write_columns("_User"), [] as [&str; 0]);
476    }
477
478    #[test]
479    fn a_class_default_column_is_refused_for_that_class_only() {
480        assert!(!field_name_is_valid_for_class("name", "_Role"));
481        assert!(field_name_is_valid_for_class("name", "Post"));
482        assert!(!field_name_is_valid_for_class("sessionToken", "_Session"));
483        assert!(field_name_is_valid_for_class("sessionToken", "Post"));
484    }
485
486    #[test]
487    fn ops_infer_the_types_getobjecttype_gives_them() {
488        use parse_rust_core::Op;
489
490        assert_eq!(
491            infer_op_type(&Op::Increment(1.0)).expect("typed"),
492            Some(FieldType::Number)
493        );
494        assert_eq!(infer_op_type(&Op::Delete).expect("typed"), None);
495        for op in [Op::Add(vec![]), Op::AddUnique(vec![]), Op::Remove(vec![])] {
496            assert_eq!(infer_op_type(&op).expect("typed"), Some(FieldType::Array));
497        }
498    }
499
500    /// `getObjectType`'s switch has no `SetOnInsert` arm and its `default` throws a bare string,
501    /// so upstream answers `{"code":1,"error":"Internal server error."}` rather than accepting the
502    /// op (`SchemaController.js:1652-1653`, `middlewares.js:636-644`). The op is plumbed through
503    /// the write path anyway because upstream plumbs it, for callers that never pass here.
504    #[test]
505    fn set_on_insert_is_the_op_getobjecttype_refuses() {
506        use parse_rust_core::{ErrorCode, Op, ParseValue};
507
508        let e = infer_op_type(&Op::SetOnInsert(ParseValue::Number(1.0))).unwrap_err();
509        assert_eq!(e.code, ErrorCode::InternalServerError);
510        assert_eq!(e.message, "unexpected op: SetOnInsert");
511    }
512
513    #[test]
514    fn a_batch_recurses_into_its_first_op() {
515        use parse_rust_core::Op;
516
517        // The first op decides, even when the second would give a different answer.
518        let op = Op::Batch(vec![Op::Increment(1.0), Op::Add(vec![])]);
519        assert_eq!(infer_op_type(&op).expect("typed"), Some(FieldType::Number));
520    }
521
522    #[test]
523    fn the_mismatch_message_is_byte_exact() {
524        let e = schema_mismatch(
525            "Post",
526            "author",
527            &FieldType::Pointer {
528                target_class: "_User".into(),
529            },
530            &FieldType::String,
531        );
532        assert_eq!(
533            e.message,
534            "schema mismatch for Post.author; expected Pointer<_User> but got String"
535        );
536        assert_eq!(e.code, parse_rust_core::ErrorCode::IncorrectType);
537    }
538}