Skip to main content

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}