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 indexmap::IndexMap;
9use parse_rust_core::{FieldWrite, ParseError, ParseMap, ParseValue};
10use parse_rust_storage::{ClassSchema, FieldType};
11
12use crate::infer::{
13    class_name_is_valid, default_columns_for, field_name_is_valid_for_class, infer_op_type,
14    infer_type, invalid_class_name_message, required_write_columns, schema_mismatch,
15    DEFAULT_COLUMNS,
16};
17
18/// What a write implies for the schema.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct SchemaDelta {
21    /// Fields that do not exist yet and would be created, in the order they appeared.
22    pub added: Vec<(String, FieldType)>,
23}
24
25impl SchemaDelta {
26    pub fn is_empty(&self) -> bool {
27        self.added.is_empty()
28    }
29}
30
31/// The schema a brand new class starts with: the four default columns, plus the class's own.
32///
33/// `injectDefaultSchema` spreads `_Default` then `defaultColumns[className]`
34/// (`SchemaController.js:618-632`), and the order matters only in that a class table may not
35/// shadow a `_Default` column; none does.
36///
37/// The class-specific columns are not decoration. Without `_Role.users` and `_Role.roles` typed
38/// as Relations, the first write to a role infers them from whatever it happens to carry, and
39/// the join collections a role's membership lives in are never created.
40pub fn default_schema(class_name: &str) -> ClassSchema {
41    let mut schema = ClassSchema::new(class_name);
42    for (name, ty) in DEFAULT_COLUMNS {
43        schema.fields.insert(name.to_string(), ty);
44    }
45    for (name, ty) in default_columns_for(class_name) {
46        schema.fields.insert(name.to_string(), ty);
47    }
48    schema
49}
50
51/// Validate a write against a class schema and report what it would add.
52///
53/// **Does not mutate.** The caller persists the delta only if the write commits, which is the
54/// ordering that stops a rejected write from leaving a phantom column behind.
55///
56/// Order of checks per field is upstream's and is observable, because the first failure is the
57/// error the client sees:
58/// 1. Skip `null`, which creates nothing.
59/// 2. If the field exists, the types must agree, else `INCORRECT_TYPE`.
60/// 3. Otherwise the name must be legal, and it is an addition.
61pub fn validate_write(schema: &ClassSchema, object: &ParseMap) -> Result<SchemaDelta, ParseError> {
62    if !class_name_is_valid(&schema.class_name) {
63        return Err(ParseError::new(
64            parse_rust_core::ErrorCode::InvalidClassName,
65            invalid_class_name_message(&schema.class_name),
66        ));
67    }
68
69    let mut added = Vec::new();
70
71    for (field_name, value) in object {
72        // Server-internal columns are not schema fields and are not validated.
73        //
74        // `_hashed_password`, `_rperm`, `_wperm` and friends are set by the server, never by a
75        // client, and they are stored under names the field-name regex deliberately rejects. The
76        // guard that keeps a *client* from supplying one is `reject_reserved_keys`, applied at the
77        // REST boundary before a body ever reaches here. Splitting it that way means the schema
78        // layer does not need to know which internal columns exist, and a client-supplied `_` key
79        // is refused with an error rather than silently accepted as a column.
80        if field_name.starts_with('_') {
81            continue;
82        }
83
84        // `ACL` is a default column of type `Acl`, but a client sends it as a plain JSON object,
85        // which infers as `Object`. Type-checking it against the column would reject every write
86        // that carries an ACL, which is exactly what happened: `schema mismatch for X.ACL;
87        // expected ACL but got Object`. The REST layer lowers it into `_rperm`/`_wperm` after
88        // validation, so there is nothing here to check and nothing to add.
89        if field_name == "ACL" {
90            match value {
91                ParseValue::Object(_) | ParseValue::Null => continue,
92                other => {
93                    return Err(schema_mismatch(
94                        &schema.class_name,
95                        field_name,
96                        &FieldType::Acl,
97                        &infer_type(other).unwrap_or(FieldType::Object),
98                    ))
99                }
100            }
101        }
102
103        // A literal null creates nothing. This is why `infer_type` returns Option.
104        let Some(incoming) = infer_type(value) else {
105            continue;
106        };
107
108        reconcile(schema, field_name, incoming, &mut added)?;
109    }
110
111    Ok(SchemaDelta { added })
112}
113
114/// The op-aware form of [`validate_write`].
115///
116/// Same rules, one extra source of type information: an `{"__op":...}` field infers through
117/// [`infer_op_type`] rather than [`infer_type`], so `AddRelation` reserves
118/// `Relation<targetClass>` and `Increment` reserves `Number`. Upstream does not distinguish the
119/// two paths at all, because `getType` handles literals and ops in one function
120/// (`SchemaController.js:1555-1657`); the split here exists because parse-rust decodes ops into
121/// [`FieldWrite`] before the schema layer sees them.
122///
123/// [`validate_write`] is kept for callers that hold a plain body. It is not a subset: a body that
124/// still carries `{"__op":"Increment"}` as a literal object infers `Object` through it, which is
125/// how 0.1.0 came to store the op envelope as a column value.
126pub fn validate_write_fields(
127    schema: &ClassSchema,
128    fields: &IndexMap<String, FieldWrite>,
129) -> Result<SchemaDelta, ParseError> {
130    if !class_name_is_valid(&schema.class_name) {
131        return Err(ParseError::new(
132            parse_rust_core::ErrorCode::InvalidClassName,
133            invalid_class_name_message(&schema.class_name),
134        ));
135    }
136
137    // **One GeoPoint per object, counted over the incoming body alone**
138    // (`SchemaController.js:1287-1302`). Upstream runs this first, before any per-field type
139    // check, and it counts only what this write carries: `geocount` is incremented inside a loop
140    // over `object`, never over the stored schema. So two GeoPoints in one body are refused here,
141    // and a *second* GeoPoint added by a later write is not, because that body carries one. The
142    // later case is caught during field reservation instead, with a different message, which is
143    // why this is not the whole rule.
144    //
145    // Measured against parse-server 9.10.1-alpha.6: two in one create answers 111 `there can only
146    // be one geopoint field in a class`, and adding a second later answers 111 `MongoDB only
147    // supports one GeoPoint field in a class.`
148    let mut geo_count = 0;
149    for (field_name, write) in fields {
150        if let FieldWrite::Value(value) = write {
151            if matches!(infer_type(value), Some(FieldType::GeoPoint)) {
152                geo_count += 1;
153            }
154        }
155        if geo_count > 1 {
156            let _ = field_name;
157            return Err(ParseError::incorrect_type(
158                "there can only be one geopoint field in a class".to_string(),
159            ));
160        }
161    }
162
163    let mut added = Vec::new();
164
165    for (field_name, write) in fields {
166        if field_name.starts_with('_') {
167            continue;
168        }
169        // Every object carries an ACL implicitly, so it is never type-checked and never added
170        // (`SchemaController.js:1312-1315`).
171        if field_name == "ACL" {
172            continue;
173        }
174
175        let incoming = match write {
176            FieldWrite::Value(value) => infer_type(value),
177            FieldWrite::Op(op) => infer_op_type(op)?,
178        };
179        let Some(incoming) = incoming else {
180            continue;
181        };
182
183        reconcile(schema, field_name, incoming, &mut added)?;
184    }
185
186    Ok(SchemaDelta { added })
187}
188
189/// The half of the per-field decision that does not depend on how the type was inferred.
190fn reconcile(
191    schema: &ClassSchema,
192    field_name: &str,
193    incoming: FieldType,
194    added: &mut Vec<(String, FieldType)>,
195) -> Result<(), ParseError> {
196    if let Some(existing) = schema.field(field_name) {
197        if existing != &incoming {
198            return Err(schema_mismatch(
199                &schema.class_name,
200                field_name,
201                existing,
202                &incoming,
203            ));
204        }
205        return Ok(());
206    }
207
208    if !field_name_is_valid_for_class(field_name, &schema.class_name) {
209        return Err(ParseError::invalid_key_name(format!(
210            "Invalid field name: {field_name}."
211        )));
212    }
213
214    added.push((field_name.to_string(), incoming));
215    Ok(())
216}
217
218/// Enforce `requiredColumns.write` (`validateRequiredColumns`, `SchemaController.js:1332-1354`).
219///
220/// Two things about this are easy to get wrong, and both are wire-visible.
221///
222/// **Only the first missing column is reported.** `missingColumns[0] + ' is required.'`, so a
223/// `_Role` with neither `name` nor `ACL` reports `name is required.` and nothing about the ACL.
224///
225/// **Create and update ask different questions.** On create the test is JavaScript falsiness, so
226/// `""`, `0` and `false` are all missing, not just absent. On update the column is only missing
227/// if the body is actively deleting it, which is why an ordinary role rename does not have to
228/// resend the ACL. `is_update` is upstream's `query && query.objectId`.
229///
230/// `_Role`'s `ACL` requirement is the load-bearing one: without it a role saves with no ACL and
231/// is therefore world-writable, so any client can add itself to it. That also fixes where the
232/// call belongs: pass the client-supplied body, before the REST layer lowers `ACL` into
233/// `_rperm`/`_wperm`, or the check looks at a key that is no longer there.
234pub fn validate_required_columns(
235    class_name: &str,
236    object: &ParseMap,
237    is_update: bool,
238) -> Result<(), ParseError> {
239    for column in required_write_columns(class_name) {
240        let missing = match object.get(*column) {
241            None => !is_update,
242            Some(value) => {
243                if is_update {
244                    is_delete_op(value)
245                } else {
246                    is_falsy(value)
247                }
248            }
249        };
250        if missing {
251            return Err(ParseError::incorrect_type(format!("{column} is required.")));
252        }
253    }
254    Ok(())
255}
256
257/// JavaScript falsiness over a decoded value.
258///
259/// Upstream's create-path test is `!object[column]`, so this has to agree with `!` and not with
260/// "is absent". `NaN` is falsy in JavaScript; every tagged value is an object and therefore
261/// truthy.
262fn is_falsy(value: &ParseValue) -> bool {
263    match value {
264        ParseValue::Null => true,
265        ParseValue::Bool(b) => !*b,
266        ParseValue::Number(n) => *n == 0.0 || n.is_nan(),
267        ParseValue::String(s) => s.is_empty(),
268        _ => false,
269    }
270}
271
272/// `object[column].__op == 'Delete'` (`SchemaController.js:1340-1342`), against a body whose ops
273/// have not been decoded.
274///
275/// Deliberately shape-matching rather than taking a `FieldWrite`: upstream runs this check on the
276/// raw REST body, before anything has interpreted the op, and a caller holding a decoded body can
277/// answer the question itself.
278fn is_delete_op(value: &ParseValue) -> bool {
279    match value {
280        ParseValue::Object(map) => {
281            matches!(map.get("__op"), Some(ParseValue::String(op)) if op == "Delete")
282        }
283        _ => false,
284    }
285}
286
287/// Apply a delta. Separate from [`validate_write`] so the caller controls when it happens.
288pub fn apply(schema: &mut ClassSchema, delta: &SchemaDelta) {
289    for (name, ty) in &delta.added {
290        schema.fields.insert(name.clone(), ty.clone());
291    }
292}
293
294/// Does a value belong in a field of this type?
295///
296/// `null` is assignable to any field, because upstream never type-checks it: it has no type.
297pub fn value_matches(ty: &FieldType, value: &ParseValue) -> bool {
298    match infer_type(value) {
299        None => true,
300        Some(t) => &t == ty,
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use parse_rust_core::ParseDate;
308
309    fn m(pairs: Vec<(&str, ParseValue)>) -> ParseMap {
310        let mut map = ParseMap::new();
311        for (k, v) in pairs {
312            map.insert(k.to_string(), v);
313        }
314        map
315    }
316
317    #[test]
318    fn a_new_class_starts_with_the_four_default_columns() {
319        let s = default_schema("Post");
320        assert_eq!(s.fields.len(), 4);
321        for (name, _) in DEFAULT_COLUMNS {
322            assert!(s.field(name).is_some(), "{name} missing");
323        }
324    }
325
326    #[test]
327    fn the_first_write_infers_and_adds() {
328        let s = default_schema("Post");
329        let delta = validate_write(
330            &s,
331            &m(vec![
332                ("title", ParseValue::String("x".into())),
333                ("views", ParseValue::Number(1.0)),
334            ]),
335        )
336        .expect("validate");
337        assert_eq!(
338            delta.added,
339            vec![
340                ("title".to_string(), FieldType::String),
341                ("views".to_string(), FieldType::Number),
342            ],
343            "order of addition follows the object's key order"
344        );
345    }
346
347    /// The behavior that makes stubbing this impossible: the *second* write is where it bites.
348    #[test]
349    fn the_second_write_is_enforced_against_the_first() {
350        let mut s = default_schema("Post");
351        let delta = validate_write(&s, &m(vec![("title", ParseValue::String("x".into()))]))
352            .expect("first write");
353        apply(&mut s, &delta);
354
355        let again = validate_write(&s, &m(vec![("title", ParseValue::String("y".into()))]))
356            .expect("second write");
357        assert!(again.is_empty());
358
359        let err = validate_write(&s, &m(vec![("title", ParseValue::Number(1.0))])).unwrap_err();
360        assert_eq!(
361            err.message,
362            "schema mismatch for Post.title; expected String but got Number"
363        );
364    }
365
366    #[test]
367    fn pointer_target_class_is_part_of_the_type() {
368        let mut s = default_schema("Post");
369        let delta = validate_write(
370            &s,
371            &m(vec![(
372                "author",
373                ParseValue::Pointer {
374                    class_name: "_User".into(),
375                    object_id: "a".into(),
376                },
377            )]),
378        )
379        .expect("first");
380        apply(&mut s, &delta);
381
382        let err = validate_write(
383            &s,
384            &m(vec![(
385                "author",
386                ParseValue::Pointer {
387                    class_name: "Admin".into(),
388                    object_id: "a".into(),
389                },
390            )]),
391        )
392        .unwrap_err();
393        assert_eq!(
394            err.message,
395            "schema mismatch for Post.author; expected Pointer<_User> but got Pointer<Admin>"
396        );
397    }
398
399    /// Regression: an ACL used to be rejected as `expected ACL but got Object`, which made every
400    /// write carrying one fail. ACL enforcement is a stated 0.1.0 feature and it never worked.
401    #[test]
402    fn user_columns_are_typed_rather_than_inferred() {
403        let s = default_schema("_User");
404        assert_eq!(s.field("email"), Some(&FieldType::String));
405        assert_eq!(s.field("emailVerified"), Some(&FieldType::Boolean));
406        // A numeric email used to be accepted, permanently fixing the column as a Number.
407        let err = validate_write(&s, &m(vec![("email", ParseValue::Number(42.0))])).unwrap_err();
408        assert!(
409            err.message.contains("expected String but got Number"),
410            "{}",
411            err.message
412        );
413    }
414
415    #[test]
416    fn an_acl_object_is_accepted_and_adds_no_column() {
417        let s = default_schema("Post");
418        let mut acl = ParseMap::new();
419        let mut entry = ParseMap::new();
420        entry.insert("read".into(), ParseValue::Bool(true));
421        acl.insert("*".into(), ParseValue::Object(entry));
422
423        let delta =
424            validate_write(&s, &m(vec![("ACL", ParseValue::Object(acl))])).expect("validate");
425        assert!(delta.is_empty(), "ACL is a default column, not a new field");
426
427        // Null clears it, and is also fine.
428        assert!(validate_write(&s, &m(vec![("ACL", ParseValue::Null)])).is_ok());
429
430        // Anything else is still a type error.
431        let err =
432            validate_write(&s, &m(vec![("ACL", ParseValue::String("nope".into()))])).unwrap_err();
433        assert!(
434            err.message.contains("expected ACL but got String"),
435            "{}",
436            err.message
437        );
438    }
439
440    #[test]
441    fn writing_null_creates_nothing() {
442        let s = default_schema("Post");
443        let delta = validate_write(&s, &m(vec![("ghost", ParseValue::Null)])).expect("validate");
444        assert!(
445            delta.is_empty(),
446            "a null must not create a column, or every optional field becomes a schema entry"
447        );
448    }
449
450    #[test]
451    fn null_is_assignable_to_an_existing_field_of_any_type() {
452        let mut s = default_schema("Post");
453        apply(
454            &mut s,
455            &SchemaDelta {
456                added: vec![("title".into(), FieldType::String)],
457            },
458        );
459        let delta = validate_write(&s, &m(vec![("title", ParseValue::Null)])).expect("validate");
460        assert!(delta.is_empty());
461        assert!(value_matches(&FieldType::String, &ParseValue::Null));
462    }
463
464    #[test]
465    fn default_columns_are_writable_but_not_redefinable() {
466        let s = default_schema("Post");
467        let ok = validate_write(
468            &s,
469            &m(vec![(
470                "createdAt",
471                ParseValue::Date(ParseDate::parse_iso("2026-01-01T00:00:00.000Z").expect("d")),
472            )]),
473        )
474        .expect("validate");
475        assert!(ok.is_empty());
476
477        let err = validate_write(&s, &m(vec![("createdAt", ParseValue::Number(1.0))])).unwrap_err();
478        assert!(err.message.contains("expected Date but got Number"));
479    }
480
481    #[test]
482    fn reserved_and_malformed_field_names_are_refused() {
483        let s = default_schema("Post");
484        // `_leading` is deliberately NOT here: an underscore-prefixed key is a server-internal
485        // column from this layer's point of view, and refusing a client one is
486        // `parse_rust_rest::reject_reserved_keys`'s job at the REST boundary. Splitting it that way is
487        // what lets signup write `_hashed_password` without routing around its own validation.
488        for bad in ["className", "1field", "has-dash"] {
489            let err =
490                validate_write(&s, &m(vec![(bad, ParseValue::String("x".into()))])).unwrap_err();
491            assert_eq!(
492                err.code,
493                parse_rust_core::ErrorCode::InvalidKeyName,
494                "{bad} should be refused"
495            );
496        }
497    }
498
499    #[test]
500    fn an_invalid_class_name_is_refused_before_any_field() {
501        let s = ClassSchema::new("1Bad");
502        let err = validate_write(&s, &m(vec![("a", ParseValue::String("x".into()))])).unwrap_err();
503        assert_eq!(err.code, parse_rust_core::ErrorCode::InvalidClassName);
504    }
505
506    #[test]
507    fn server_internal_columns_are_not_schema_fields() {
508        // `_hashed_password` is written by signup and must not become a `_SCHEMA` column, nor be
509        // rejected as a malformed field name. Keeping a client from supplying one is
510        // `reject_reserved_keys`'s job, at the REST boundary.
511        let s = default_schema("_User");
512        let delta = validate_write(
513            &s,
514            &m(vec![
515                // Already a `_User` default column, so it is accepted and adds nothing.
516                ("username", ParseValue::String("alice".into())),
517                // Server-internal, so skipped entirely.
518                ("_hashed_password", ParseValue::String("$2b$10$...".into())),
519                ("_rperm", ParseValue::Array(vec![])),
520                // A genuinely new client field is the only thing that becomes a column.
521                ("nickname", ParseValue::String("al".into())),
522            ]),
523        )
524        .expect("validate");
525        assert_eq!(
526            delta.added,
527            vec![("nickname".to_string(), FieldType::String)],
528            "internal columns must not become schema fields"
529        );
530    }
531
532    #[test]
533    fn role_and_session_carry_their_class_specific_columns() {
534        let role = default_schema("_Role");
535        assert_eq!(role.field("name"), Some(&FieldType::String));
536        assert_eq!(
537            role.field("users"),
538            Some(&FieldType::Relation {
539                target_class: "_User".into()
540            })
541        );
542        assert_eq!(
543            role.field("roles"),
544            Some(&FieldType::Relation {
545                target_class: "_Role".into()
546            })
547        );
548
549        let session = default_schema("_Session");
550        assert_eq!(
551            session.field("user"),
552            Some(&FieldType::Pointer {
553                target_class: "_User".into()
554            })
555        );
556        for (name, ty) in [
557            ("installationId", FieldType::String),
558            ("sessionToken", FieldType::String),
559            ("expiresAt", FieldType::Date),
560            ("createdWith", FieldType::Object),
561        ] {
562            assert_eq!(session.field(name), Some(&ty), "{name}");
563        }
564    }
565
566    /// Without the typed columns, the first role write decides these types from its payload, and
567    /// a role whose `users` came back as an Array has no join collection at all.
568    #[test]
569    fn a_role_write_is_checked_against_the_typed_relation_columns() {
570        let s = default_schema("_Role");
571        let err = validate_write(&s, &m(vec![("users", ParseValue::Array(vec![]))])).unwrap_err();
572        assert_eq!(
573            err.message,
574            "schema mismatch for _Role.users; expected Relation<_User> but got Array"
575        );
576    }
577
578    #[test]
579    fn a_role_needs_a_name_and_an_acl_on_create() {
580        // The first missing column, and only the first.
581        let err = validate_required_columns("_Role", &ParseMap::new(), false).unwrap_err();
582        assert_eq!(err.message, "name is required.");
583        assert_eq!(err.code, parse_rust_core::ErrorCode::IncorrectType);
584
585        // An ACL-less role would be world-writable, so this is the one that matters.
586        let err = validate_required_columns(
587            "_Role",
588            &m(vec![("name", ParseValue::String("Admins".into()))]),
589            false,
590        )
591        .unwrap_err();
592        assert_eq!(err.message, "ACL is required.");
593
594        let ok = m(vec![
595            ("name", ParseValue::String("Admins".into())),
596            ("ACL", ParseValue::Object(ParseMap::new())),
597        ]);
598        assert!(validate_required_columns("_Role", &ok, false).is_ok());
599    }
600
601    /// The create test is JavaScript falsiness, not absence, so an empty name is still missing.
602    #[test]
603    fn a_falsy_required_column_counts_as_missing_on_create() {
604        for value in [
605            ParseValue::String(String::new()),
606            ParseValue::Null,
607            ParseValue::Bool(false),
608            ParseValue::Number(0.0),
609        ] {
610            let body = m(vec![
611                ("name", value),
612                ("ACL", ParseValue::Object(ParseMap::new())),
613            ]);
614            assert_eq!(
615                validate_required_columns("_Role", &body, false)
616                    .unwrap_err()
617                    .message,
618                "name is required."
619            );
620        }
621    }
622
623    /// On update the column is only missing if the body is deleting it, which is why a rename
624    /// does not have to resend the ACL.
625    #[test]
626    fn an_update_only_objects_to_deleting_a_required_column() {
627        let rename = m(vec![("name", ParseValue::String("Ops".into()))]);
628        assert!(validate_required_columns("_Role", &rename, true).is_ok());
629
630        let mut delete = ParseMap::new();
631        delete.insert("__op".into(), ParseValue::String("Delete".into()));
632        let body = m(vec![("ACL", ParseValue::Object(delete))]);
633        assert_eq!(
634            validate_required_columns("_Role", &body, true)
635                .unwrap_err()
636                .message,
637            "ACL is required."
638        );
639    }
640
641    #[test]
642    fn a_class_with_no_required_columns_never_fails() {
643        assert!(validate_required_columns("Post", &ParseMap::new(), false).is_ok());
644        assert!(validate_required_columns("_User", &ParseMap::new(), false).is_ok());
645    }
646
647    #[test]
648    fn ops_infer_their_own_types() {
649        use parse_rust_core::Op;
650
651        let s = default_schema("Post");
652        let mut fields: IndexMap<String, FieldWrite> = IndexMap::new();
653        fields.insert("views".into(), FieldWrite::Op(Op::Increment(1.0)));
654        fields.insert(
655            "tags".into(),
656            FieldWrite::Op(Op::Add(vec![ParseValue::String("x".into())])),
657        );
658        fields.insert("gone".into(), FieldWrite::Op(Op::Delete));
659        let delta = validate_write_fields(&s, &fields).expect("validate");
660        assert_eq!(
661            delta.added,
662            vec![
663                ("views".to_string(), FieldType::Number),
664                ("tags".to_string(), FieldType::Array),
665            ],
666            "Delete has no type and must not create a column"
667        );
668    }
669
670    /// The relation ops take their target class from the first pointer in the payload, which is
671    /// the only place it appears. Without this a `_Role.users` write reserves nothing.
672    #[test]
673    fn relation_ops_infer_their_target_from_the_first_pointer() {
674        use parse_rust_core::Op;
675
676        let s = default_schema("Post");
677        let pointer = ParseValue::Pointer {
678            class_name: "_User".into(),
679            object_id: "abc".into(),
680        };
681        for op in [
682            Op::AddRelation(vec![pointer.clone()]),
683            Op::RemoveRelation(vec![pointer.clone()]),
684            Op::Batch(vec![Op::AddRelation(vec![pointer.clone()])]),
685        ] {
686            let mut fields: IndexMap<String, FieldWrite> = IndexMap::new();
687            fields.insert("members".into(), FieldWrite::Op(op));
688            let delta = validate_write_fields(&s, &fields).expect("validate");
689            assert_eq!(
690                delta.added,
691                vec![(
692                    "members".to_string(),
693                    FieldType::Relation {
694                        target_class: "_User".into()
695                    }
696                )]
697            );
698        }
699    }
700
701    /// Upstream has no defined type for this shape. Declining to create a column is the nearest
702    /// safe behavior; see `infer_op_type`.
703    #[test]
704    fn a_relation_op_with_no_pointers_creates_nothing() {
705        use parse_rust_core::Op;
706
707        let s = default_schema("Post");
708        let mut fields: IndexMap<String, FieldWrite> = IndexMap::new();
709        fields.insert("members".into(), FieldWrite::Op(Op::AddRelation(vec![])));
710        fields.insert("other".into(), FieldWrite::Op(Op::Batch(vec![])));
711        assert!(validate_write_fields(&s, &fields)
712            .expect("validate")
713            .is_empty());
714    }
715
716    #[test]
717    fn the_op_aware_path_enforces_the_same_types_as_the_value_path() {
718        use parse_rust_core::Op;
719
720        let mut s = default_schema("Post");
721        apply(
722            &mut s,
723            &SchemaDelta {
724                added: vec![("title".into(), FieldType::String)],
725            },
726        );
727        let mut fields: IndexMap<String, FieldWrite> = IndexMap::new();
728        fields.insert("title".into(), FieldWrite::Op(Op::Increment(1.0)));
729        let err = validate_write_fields(&s, &fields).unwrap_err();
730        assert_eq!(
731            err.message,
732            "schema mismatch for Post.title; expected String but got Number"
733        );
734    }
735
736    /// The trailing space is upstream's and reaches the client.
737    #[test]
738    fn the_invalid_class_name_message_is_byte_exact() {
739        let s = ClassSchema::new("1Bad");
740        let err = validate_write(&s, &m(vec![("a", ParseValue::String("x".into()))])).unwrap_err();
741        assert_eq!(
742            err.message,
743            "Invalid classname: 1Bad, classnames can only have alphanumeric characters and _, and \
744             must start with an alpha character "
745        );
746    }
747
748    #[test]
749    fn validate_does_not_mutate_so_a_rejected_write_leaves_no_column() {
750        let s = default_schema("Post");
751        let before = s.fields.len();
752        let _ = validate_write(&s, &m(vec![("title", ParseValue::String("x".into()))]));
753        assert_eq!(
754            s.fields.len(),
755            before,
756            "validation must be pure; the caller applies only on commit"
757        );
758    }
759}