Skip to main content

parse_rust_schema/
controller.rs

1//! Enforcing a schema against a write, and growing it implicitly.
2//!
3//! Upstream splits this across `enforceFieldExists`, `validateObject` and
4//! `validateRequiredColumns` (`SchemaController.js`). The part that matters for 0.1.0 is the pair
5//! of decisions made per field on every write: does this field already have a type, and does the
6//! incoming value agree with it.
7
8use parse_rust_core::{ParseError, ParseMap, ParseValue};
9use parse_rust_storage::{ClassSchema, FieldType};
10
11use crate::infer::{
12    class_name_is_valid, field_name_is_valid_for_class, infer_type, schema_mismatch,
13    DEFAULT_COLUMNS,
14};
15
16/// What a write implies for the schema.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct SchemaDelta {
19    /// Fields that do not exist yet and would be created, in the order they appeared.
20    pub added: Vec<(String, FieldType)>,
21}
22
23impl SchemaDelta {
24    pub fn is_empty(&self) -> bool {
25        self.added.is_empty()
26    }
27}
28
29/// The schema a brand new class starts with: the four default columns and nothing else.
30pub fn default_schema(class_name: &str) -> ClassSchema {
31    let mut schema = ClassSchema::new(class_name);
32    for (name, ty) in DEFAULT_COLUMNS {
33        schema.fields.insert(name.to_string(), ty);
34    }
35    // System classes carry additional columns whose types are fixed by Parse rather than inferred.
36    if class_name == "_User" {
37        for (name, ty) in crate::infer::USER_COLUMNS {
38            schema.fields.insert(name.to_string(), ty);
39        }
40    }
41    schema
42}
43
44/// Validate a write against a class schema and report what it would add.
45///
46/// **Does not mutate.** The caller persists the delta only if the write commits, which is the
47/// ordering that stops a rejected write from leaving a phantom column behind.
48///
49/// Order of checks per field is upstream's and is observable, because the first failure is the
50/// error the client sees:
51/// 1. Skip `null`, which creates nothing.
52/// 2. If the field exists, the types must agree, else `INCORRECT_TYPE`.
53/// 3. Otherwise the name must be legal, and it is an addition.
54pub fn validate_write(schema: &ClassSchema, object: &ParseMap) -> Result<SchemaDelta, ParseError> {
55    if !class_name_is_valid(&schema.class_name) {
56        return Err(ParseError::new(
57            parse_rust_core::ErrorCode::InvalidClassName,
58            format!(
59                "Invalid classname: {}, classnames can only have alphanumeric characters and _, \
60                 and must start with an alpha character",
61                schema.class_name
62            ),
63        ));
64    }
65
66    let mut added = Vec::new();
67
68    for (field_name, value) in object {
69        // Server-internal columns are not schema fields and are not validated.
70        //
71        // `_hashed_password`, `_rperm`, `_wperm` and friends are set by the server, never by a
72        // client, and they are stored under names the field-name regex deliberately rejects. The
73        // guard that keeps a *client* from supplying one is `reject_reserved_keys`, applied at the
74        // REST boundary before a body ever reaches here. Splitting it that way means the schema
75        // layer does not need to know which internal columns exist, and a client-supplied `_` key
76        // is refused with an error rather than silently accepted as a column.
77        if field_name.starts_with('_') {
78            continue;
79        }
80
81        // `ACL` is a default column of type `Acl`, but a client sends it as a plain JSON object,
82        // which infers as `Object`. Type-checking it against the column would reject every write
83        // that carries an ACL, which is exactly what happened: `schema mismatch for X.ACL;
84        // expected ACL but got Object`. The REST layer lowers it into `_rperm`/`_wperm` after
85        // validation, so there is nothing here to check and nothing to add.
86        if field_name == "ACL" {
87            match value {
88                ParseValue::Object(_) | ParseValue::Null => continue,
89                other => {
90                    return Err(schema_mismatch(
91                        &schema.class_name,
92                        field_name,
93                        &FieldType::Acl,
94                        &infer_type(other).unwrap_or(FieldType::Object),
95                    ))
96                }
97            }
98        }
99
100        // A literal null creates nothing. This is why `infer_type` returns Option.
101        let Some(incoming) = infer_type(value) else {
102            continue;
103        };
104
105        if let Some(existing) = schema.field(field_name) {
106            if existing != &incoming {
107                return Err(schema_mismatch(
108                    &schema.class_name,
109                    field_name,
110                    existing,
111                    &incoming,
112                ));
113            }
114            continue;
115        }
116
117        if !field_name_is_valid_for_class(field_name, &schema.class_name) {
118            return Err(ParseError::invalid_key_name(format!(
119                "Invalid field name: {field_name}."
120            )));
121        }
122
123        added.push((field_name.clone(), incoming));
124    }
125
126    Ok(SchemaDelta { added })
127}
128
129/// Apply a delta. Separate from [`validate_write`] so the caller controls when it happens.
130pub fn apply(schema: &mut ClassSchema, delta: &SchemaDelta) {
131    for (name, ty) in &delta.added {
132        schema.fields.insert(name.clone(), ty.clone());
133    }
134}
135
136/// Does a value belong in a field of this type?
137///
138/// `null` is assignable to any field, because upstream never type-checks it: it has no type.
139pub fn value_matches(ty: &FieldType, value: &ParseValue) -> bool {
140    match infer_type(value) {
141        None => true,
142        Some(t) => &t == ty,
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use parse_rust_core::ParseDate;
150
151    fn m(pairs: Vec<(&str, ParseValue)>) -> ParseMap {
152        let mut map = ParseMap::new();
153        for (k, v) in pairs {
154            map.insert(k.to_string(), v);
155        }
156        map
157    }
158
159    #[test]
160    fn a_new_class_starts_with_the_four_default_columns() {
161        let s = default_schema("Post");
162        assert_eq!(s.fields.len(), 4);
163        for (name, _) in DEFAULT_COLUMNS {
164            assert!(s.field(name).is_some(), "{name} missing");
165        }
166    }
167
168    #[test]
169    fn the_first_write_infers_and_adds() {
170        let s = default_schema("Post");
171        let delta = validate_write(
172            &s,
173            &m(vec![
174                ("title", ParseValue::String("x".into())),
175                ("views", ParseValue::Number(1.0)),
176            ]),
177        )
178        .expect("validate");
179        assert_eq!(
180            delta.added,
181            vec![
182                ("title".to_string(), FieldType::String),
183                ("views".to_string(), FieldType::Number),
184            ],
185            "order of addition follows the object's key order"
186        );
187    }
188
189    /// The behavior that makes stubbing this impossible: the *second* write is where it bites.
190    #[test]
191    fn the_second_write_is_enforced_against_the_first() {
192        let mut s = default_schema("Post");
193        let delta = validate_write(&s, &m(vec![("title", ParseValue::String("x".into()))]))
194            .expect("first write");
195        apply(&mut s, &delta);
196
197        let again = validate_write(&s, &m(vec![("title", ParseValue::String("y".into()))]))
198            .expect("second write");
199        assert!(again.is_empty());
200
201        let err = validate_write(&s, &m(vec![("title", ParseValue::Number(1.0))])).unwrap_err();
202        assert_eq!(
203            err.message,
204            "schema mismatch for Post.title; expected String but got Number"
205        );
206    }
207
208    #[test]
209    fn pointer_target_class_is_part_of_the_type() {
210        let mut s = default_schema("Post");
211        let delta = validate_write(
212            &s,
213            &m(vec![(
214                "author",
215                ParseValue::Pointer {
216                    class_name: "_User".into(),
217                    object_id: "a".into(),
218                },
219            )]),
220        )
221        .expect("first");
222        apply(&mut s, &delta);
223
224        let err = validate_write(
225            &s,
226            &m(vec![(
227                "author",
228                ParseValue::Pointer {
229                    class_name: "Admin".into(),
230                    object_id: "a".into(),
231                },
232            )]),
233        )
234        .unwrap_err();
235        assert_eq!(
236            err.message,
237            "schema mismatch for Post.author; expected Pointer<_User> but got Pointer<Admin>"
238        );
239    }
240
241    /// Regression: an ACL used to be rejected as `expected ACL but got Object`, which made every
242    /// write carrying one fail. ACL enforcement is a stated 0.1.0 feature and it never worked.
243    #[test]
244    fn user_columns_are_typed_rather_than_inferred() {
245        let s = default_schema("_User");
246        assert_eq!(s.field("email"), Some(&FieldType::String));
247        assert_eq!(s.field("emailVerified"), Some(&FieldType::Boolean));
248        // A numeric email used to be accepted, permanently fixing the column as a Number.
249        let err = validate_write(&s, &m(vec![("email", ParseValue::Number(42.0))])).unwrap_err();
250        assert!(
251            err.message.contains("expected String but got Number"),
252            "{}",
253            err.message
254        );
255    }
256
257    #[test]
258    fn an_acl_object_is_accepted_and_adds_no_column() {
259        let s = default_schema("Post");
260        let mut acl = ParseMap::new();
261        let mut entry = ParseMap::new();
262        entry.insert("read".into(), ParseValue::Bool(true));
263        acl.insert("*".into(), ParseValue::Object(entry));
264
265        let delta =
266            validate_write(&s, &m(vec![("ACL", ParseValue::Object(acl))])).expect("validate");
267        assert!(delta.is_empty(), "ACL is a default column, not a new field");
268
269        // Null clears it, and is also fine.
270        assert!(validate_write(&s, &m(vec![("ACL", ParseValue::Null)])).is_ok());
271
272        // Anything else is still a type error.
273        let err =
274            validate_write(&s, &m(vec![("ACL", ParseValue::String("nope".into()))])).unwrap_err();
275        assert!(
276            err.message.contains("expected ACL but got String"),
277            "{}",
278            err.message
279        );
280    }
281
282    #[test]
283    fn writing_null_creates_nothing() {
284        let s = default_schema("Post");
285        let delta = validate_write(&s, &m(vec![("ghost", ParseValue::Null)])).expect("validate");
286        assert!(
287            delta.is_empty(),
288            "a null must not create a column, or every optional field becomes a schema entry"
289        );
290    }
291
292    #[test]
293    fn null_is_assignable_to_an_existing_field_of_any_type() {
294        let mut s = default_schema("Post");
295        apply(
296            &mut s,
297            &SchemaDelta {
298                added: vec![("title".into(), FieldType::String)],
299            },
300        );
301        let delta = validate_write(&s, &m(vec![("title", ParseValue::Null)])).expect("validate");
302        assert!(delta.is_empty());
303        assert!(value_matches(&FieldType::String, &ParseValue::Null));
304    }
305
306    #[test]
307    fn default_columns_are_writable_but_not_redefinable() {
308        let s = default_schema("Post");
309        let ok = validate_write(
310            &s,
311            &m(vec![(
312                "createdAt",
313                ParseValue::Date(ParseDate::parse_iso("2026-01-01T00:00:00.000Z").expect("d")),
314            )]),
315        )
316        .expect("validate");
317        assert!(ok.is_empty());
318
319        let err = validate_write(&s, &m(vec![("createdAt", ParseValue::Number(1.0))])).unwrap_err();
320        assert!(err.message.contains("expected Date but got Number"));
321    }
322
323    #[test]
324    fn reserved_and_malformed_field_names_are_refused() {
325        let s = default_schema("Post");
326        // `_leading` is deliberately NOT here: an underscore-prefixed key is a server-internal
327        // column from this layer's point of view, and refusing a client one is
328        // `parse_rust_rest::reject_reserved_keys`'s job at the REST boundary. Splitting it that way is
329        // what lets signup write `_hashed_password` without routing around its own validation.
330        for bad in ["className", "1field", "has-dash"] {
331            let err =
332                validate_write(&s, &m(vec![(bad, ParseValue::String("x".into()))])).unwrap_err();
333            assert_eq!(
334                err.code,
335                parse_rust_core::ErrorCode::InvalidKeyName,
336                "{bad} should be refused"
337            );
338        }
339    }
340
341    #[test]
342    fn an_invalid_class_name_is_refused_before_any_field() {
343        let s = ClassSchema::new("1Bad");
344        let err = validate_write(&s, &m(vec![("a", ParseValue::String("x".into()))])).unwrap_err();
345        assert_eq!(err.code, parse_rust_core::ErrorCode::InvalidClassName);
346    }
347
348    #[test]
349    fn server_internal_columns_are_not_schema_fields() {
350        // `_hashed_password` is written by signup and must not become a `_SCHEMA` column, nor be
351        // rejected as a malformed field name. Keeping a client from supplying one is
352        // `reject_reserved_keys`'s job, at the REST boundary.
353        let s = default_schema("_User");
354        let delta = validate_write(
355            &s,
356            &m(vec![
357                // Already a `_User` default column, so it is accepted and adds nothing.
358                ("username", ParseValue::String("alice".into())),
359                // Server-internal, so skipped entirely.
360                ("_hashed_password", ParseValue::String("$2b$10$...".into())),
361                ("_rperm", ParseValue::Array(vec![])),
362                // A genuinely new client field is the only thing that becomes a column.
363                ("nickname", ParseValue::String("al".into())),
364            ]),
365        )
366        .expect("validate");
367        assert_eq!(
368            delta.added,
369            vec![("nickname".to_string(), FieldType::String)],
370            "internal columns must not become schema fields"
371        );
372    }
373
374    #[test]
375    fn validate_does_not_mutate_so_a_rejected_write_leaves_no_column() {
376        let s = default_schema("Post");
377        let before = s.fields.len();
378        let _ = validate_write(&s, &m(vec![("title", ParseValue::String("x".into()))]));
379        assert_eq!(
380            s.fields.len(),
381            before,
382            "validation must be pure; the caller applies only on commit"
383        );
384    }
385}