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}