parse_rust_rest/snapshot.rs
1//! One schema snapshot per request.
2//!
3//! Upstream threads a `validSchemaController` down through every controller entry point
4//! (`DatabaseController.js:553`, `:843`, `:906`, `:1407`) so that one request cannot evaluate half
5//! its work under one schema and half under another. A batch that saw two schemas mid-flight would
6//! decide what a caller may write and what a caller may see under two different rule sets, and the
7//! second half would carry no error.
8//!
9//! It is also what makes `include` and `$relatedTo` expressible at all: both need the schema of a
10//! class other than the one being queried, and a per-class fetch at the point of use would be a
11//! second snapshot.
12//!
13//! No caching here. 0.2.0 reloads every schema on every request, which is correct and slow. A
14//! cache is the obvious next step and deliberately not taken yet: a schema carries the CLP, so the
15//! staleness window of a schema cache is the window in which a revoked permission is still
16//! honored. That makes it an authorization decision rather than a tuning knob, and it wants to be
17//! designed as one rather than added for the throughput.
18
19use std::borrow::Cow;
20
21use indexmap::IndexMap;
22use parse_rust_core::{ClassLevelPermissions, ParseError};
23use parse_rust_schema::default_schema;
24use parse_rust_storage::{ClassSchema, StorageAdapter};
25
26/// Every class schema, as of one point in time.
27#[derive(Debug, Clone, Default)]
28pub struct SchemaSnapshot {
29 classes: IndexMap<String, ClassSchema>,
30}
31
32impl SchemaSnapshot {
33 /// Load every schema from storage. Called once per request.
34 pub async fn load<S: StorageAdapter>(storage: &S) -> Result<Self, ParseError> {
35 Ok(Self::from_classes(storage.all_schemas().await?))
36 }
37
38 /// Build a snapshot from an already-loaded list. Cheap, and the constructor tests use.
39 pub fn from_classes(classes: Vec<ClassSchema>) -> Self {
40 Self {
41 classes: classes
42 .into_iter()
43 .map(|s| (s.class_name.clone(), s))
44 .collect(),
45 }
46 }
47
48 pub fn get(&self, class_name: &str) -> Option<&ClassSchema> {
49 self.classes.get(class_name)
50 }
51
52 /// Does this class exist? Upstream's `classExists`, which decides whether a count short
53 /// circuits to zero rather than reaching the adapter (`DatabaseController.js:1524-1527`).
54 pub fn contains(&self, class_name: &str) -> bool {
55 self.classes.contains_key(class_name)
56 }
57
58 /// The schema to read a class under.
59 ///
60 /// A missing class behaves as `{fields: {}}` rather than as an error
61 /// (`DatabaseController.js:1422-1432`), so a query against a class nobody has written yet
62 /// returns nothing instead of failing. Note that this is **not** [`Self::resolve_for_write`]:
63 /// the read fallback has no default columns at all, which is what makes every sort key on a
64 /// non-existent class get dropped.
65 pub fn get_or_default(&self, class_name: &str) -> Cow<'_, ClassSchema> {
66 match self.classes.get(class_name) {
67 Some(schema) => Cow::Borrowed(schema),
68 None => Cow::Owned(ClassSchema::new(class_name)),
69 }
70 }
71
72 /// The schema to write a class under.
73 ///
74 /// A missing class resolves to the injected default schema, matching `enforceClassExists`
75 /// followed by `getOneSchema` on the create path (`DatabaseController.js:939-940`). The
76 /// difference from [`Self::get_or_default`] is load bearing: without `_Role.users` typed as a
77 /// Relation, the first write to a role infers it from whatever it happens to carry.
78 pub fn resolve_for_write(&self, class_name: &str) -> ClassSchema {
79 match self.classes.get(class_name) {
80 Some(schema) => schema.clone(),
81 None => default_schema(class_name),
82 }
83 }
84
85 /// The class-level permissions for a class.
86 ///
87 /// **`None` means the class has no CLP block, which is unrestricted, not denied.** Every
88 /// caller has to spell that out; see `parse_rust_core::clp`.
89 pub fn clp(&self, class_name: &str) -> Option<&ClassLevelPermissions> {
90 self.classes.get(class_name).and_then(|s| s.clp.as_ref())
91 }
92
93 /// Insert or replace a class. Used after a write reserves a new field, and by tests.
94 pub fn insert(&mut self, schema: ClassSchema) {
95 self.classes.insert(schema.class_name.clone(), schema);
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use parse_rust_storage::FieldType;
103
104 #[test]
105 fn a_missing_class_reads_as_empty_and_writes_as_the_default_schema() {
106 let snap = SchemaSnapshot::default();
107 assert!(snap.get("Post").is_none());
108 assert!(!snap.contains("Post"));
109 assert!(
110 snap.get_or_default("Post").fields.is_empty(),
111 "the read fallback is {{fields: {{}}}}, with no default columns"
112 );
113 assert!(
114 snap.resolve_for_write("Post").field("objectId").is_some(),
115 "the write fallback carries the default columns"
116 );
117 }
118
119 #[test]
120 fn a_present_class_is_returned_borrowed() {
121 let snap = SchemaSnapshot::from_classes(vec![
122 ClassSchema::new("Post").with_field("title", FieldType::String)
123 ]);
124 assert!(matches!(snap.get_or_default("Post"), Cow::Borrowed(_)));
125 assert_eq!(
126 snap.get_or_default("Post").field("title"),
127 Some(&FieldType::String)
128 );
129 }
130}