Skip to main content

parse_rust_schema/
schema_api.rs

1//! Validation for `POST /schemas/:className` and `PUT /schemas/:className`.
2//!
3//! Pure. No I/O, no routing, no schema cache. A create returns the [`ClassSchema`] the caller
4//! should persist; an update returns a [`SchemaMutation`] the caller executes. Upstream fuses
5//! validation with execution across `addClassIfNotExists` (`SchemaController.js:832-868`) and
6//! `updateClass` (`:870-975`), which is why an upstream field delete can half-happen. Splitting
7//! them here does not make the sequence atomic, and it does make the decision reviewable in one
8//! place.
9//!
10//! **0.2.0 scope: field options are stored, not enforced.** `required` and `defaultValue` are
11//! validated against the field's type and round-tripped **exactly as sent**: a schema body is
12//! decoded without interpreting any `__type` envelope, so an offset instant, unpadded base64 and
13//! any key the envelope does not declare all survive. Stored into
14//! `ClassSchema::field_options`, which the Mongo adapter writes to `_metadata.fields_options`.
15//! Nothing consults them on a write. That is a deliberate exclusion, and storing them anyway is
16//! what keeps a mixed fleet honest: a parse-server node reading the same database enforces them,
17//! and dropping the keys on a parse-rust rewrite would silently relax a constraint it never
18//! agreed to relax.
19//!
20//! Two error codes upstream uses here are bare numbers with no name in the `parse` SDK's table.
21//! They have [`ErrorCode`] variants all the same, named from their messages, because the number is
22//! what a client sees. See [`NEEDS_CLASS_NAME_CODE`].
23
24use indexmap::IndexMap;
25use parse_rust_core::{
26    js_number, ClassLevelPermissions, ErrorCode, ParseError, ParseMap, ParseValue,
27};
28use parse_rust_storage::{ClassSchema, FieldType};
29
30use crate::clp_validate::{validate_clp, ClpValidation};
31use crate::infer::{
32    class_name_is_valid, default_columns_for, field_name_is_valid, field_name_is_valid_for_class,
33    infer_type, invalid_class_name_message,
34};
35
36/// `135`, `type <T> needs a class name` (`SchemaController.js:508`, `SchemasRouter.js:90`).
37///
38/// **Named constants rather than the variants inline, because these two numbers have no name in
39/// `src/Error.js` or in the `parse` SDK.** Upstream throws them as `new Parse.Error(135, ...)`, so
40/// there is no upstream symbol to match against and a reader has nothing to check a bare
41/// `ErrorCode::MissingClassName` against. The name here is ours, taken from the message.
42///
43/// `spec/Schema.spec.js` asserts on the numbers directly (`:575`, `:590`, `:620`, `:650`), which is
44/// what makes a substitution wire-visible: an earlier version of this file used `IncorrectType`
45/// (111) and `InvalidKeyName` (105) on the stated belief that no variant existed for either. Both
46/// have existed since the codes were first enumerated (`parse_rust_core::ErrorCode`). Do not
47/// substitute a named neighbour again.
48pub const NEEDS_CLASS_NAME_CODE: ErrorCode = ErrorCode::MissingClassName;
49
50/// `136`, `field <name> cannot be added` (`SchemaController.js:1038-1039`, `:1242`). See
51/// [`NEEDS_CLASS_NAME_CODE`] for why this is a named constant.
52pub const FIELD_CANNOT_BE_ADDED_CODE: ErrorCode = ErrorCode::UnchangeableField;
53
54/// What a `fields` entry in a schema-API body asks for.
55///
56/// `Delete` is spelled `{"__op":"Delete"}` on the wire and is only meaningful on `PUT`. Modelled
57/// as a variant rather than as a flag on a struct so that a caller enumerating the plan cannot
58/// treat a delete as a weird kind of add.
59#[derive(Debug, Clone)]
60pub enum FieldChange {
61    Set {
62        field_type: FieldType,
63        /// Everything the wire spec carried besides `type` and `targetClass`, verbatim.
64        ///
65        /// That is exactly what upstream stores under `_metadata.fields_options.<field>`
66        /// (`MongoSchemaCollection.js:262-264`, `:293-295`): it destructures `type` and
67        /// `targetClass` off and keeps the rest, including keys it does not understand.
68        options: ParseMap,
69    },
70    Delete,
71}
72
73/// A `PUT /schemas/:className` reduced to the work the caller has to do.
74///
75/// `#[must_use]`, and `deleted` is not an `Option`. Both are deliberate: a caller that applies
76/// `added` and forgets `deleted` leaves a class carrying a field the client believes it removed,
77/// and the field then reappears in every response.
78#[derive(Debug, Clone)]
79#[must_use]
80pub struct SchemaMutation {
81    /// Fields to drop, with their rows' columns and, for a `Relation`, its join collection.
82    pub deleted: Vec<String>,
83    /// `None` means the body carried no `classLevelPermissions` at all, which upstream treats as
84    /// "leave the stored block alone" (`setPermissions` returns early on `undefined`,
85    /// `SchemaController.js:1097-1099`). It is **not** the same as an empty block, which
86    /// replaces the stored one.
87    pub clp: Option<ClassLevelPermissions>,
88    /// Every submitted field that is not a delete, in body order, with the options it carried.
89    ///
90    /// **Includes fields that already exist, and includes empty option sets**, because both are
91    /// meaningful. Upstream reaches `enforceFieldExists` for every submitted field, not only the
92    /// new ones (`SchemaController.js:930-934`), and resubmitting a field without the options it
93    /// was stored with is how a client clears them: `updateFieldOptions` writes whatever is left
94    /// after `type` and `targetClass` are removed, which for `{"type":"String"}` is `{}`
95    /// (`MongoSchemaCollection.js:284-297`).
96    pub set_fields: Vec<SetField>,
97}
98
99/// One submitted field spec, split into the parts that are stored in two different places.
100#[derive(Debug, Clone)]
101pub struct SetField {
102    pub name: String,
103    pub field_type: FieldType,
104    /// Every key of the spec except `type` and `targetClass`. Empty is a value, not an absence.
105    pub options: ParseMap,
106    /// Absent from the stored schema, so this submission reserves it rather than updating it.
107    pub is_new: bool,
108}
109
110impl SchemaMutation {
111    pub fn is_empty(&self) -> bool {
112        self.set_fields.is_empty() && self.deleted.is_empty() && self.clp.is_none()
113    }
114}
115
116/// Validate a `POST /schemas/:className` body and produce the schema to create.
117///
118/// Order of checks is upstream's, and it is observable because the first failure is what the
119/// client sees: class name, then per field name, name-for-class, type, and options, then the
120/// GeoPoint count, then the CLP (`validateNewClass` at `:1009-1020` into `validateSchemaData` at
121/// `:1022-1093`).
122///
123/// The "class already exists" check is not here. It needs the loaded schema set, which is the
124/// caller's, and its message is `Class <name> already exists.` with `INVALID_CLASS_NAME`
125/// (`:1011`).
126pub fn validate_new_class(
127    class_name: &str,
128    fields: &ParseMap,
129    clp: Option<ParseMap>,
130    opts: ClpValidation,
131) -> Result<ClassSchema, ParseError> {
132    if !class_name_is_valid(class_name) {
133        return Err(ParseError::new(
134            ErrorCode::InvalidClassName,
135            invalid_class_name_message(class_name),
136        ));
137    }
138
139    let changes = parse_fields(fields)?;
140    let mut schema = ClassSchema::new(class_name);
141    let mut options = ParseMap::new();
142
143    for (name, change) in &changes {
144        match change {
145            // A delete on a class that does not exist yet. Upstream never reaches its own
146            // "does not exist, cannot delete" check here, because `validateNewClass` runs on the
147            // submitted fields and `buildMergedSchemaObject` is only used by `updateClass`. It
148            // refuses the spec anyway, one step later and for a different reason:
149            // `fieldTypeIsInvalid` destructures `{type, targetClass}` off `{"__op":"Delete"}`,
150            // finds `type` undefined, and returns `INVALID_JSON` `invalid JSON`
151            // (`SchemaController.js:505-518`, called at `:1042-1043`).
152            //
153            // So the divergence is the code and the message, not the acceptance: both servers
154            // refuse the request, upstream with 107 `invalid JSON` and parse-rust with the update
155            // path's 255 `Field <name> does not exist, cannot delete.`, which names the actual
156            // problem. Recorded rather than reproduced.
157            FieldChange::Delete => {
158                return Err(ParseError::new(
159                    ErrorCode::InvalidSchemaOperation,
160                    format!("Field {name} does not exist, cannot delete."),
161                ))
162            }
163            FieldChange::Set {
164                field_type,
165                options: field_options,
166            } => {
167                check_new_field(class_name, name, field_type, field_options)?;
168                schema.fields.insert(name.clone(), field_type.clone());
169                if !field_options.is_empty() {
170                    options.insert(name.clone(), ParseValue::Object(field_options.clone()));
171                }
172            }
173        }
174    }
175
176    // `for (const fieldName in defaultColumns[className]) fields[fieldName] = ...`
177    // (`SchemaController.js:1074-1076`), then `_Default` on top through `injectDefaultSchema`.
178    // Merged after the per-field loop so a submitted field that collides with a default column is
179    // still reported by `fieldNameIsValidForClass` rather than being silently overwritten.
180    for (name, ty) in crate::infer::DEFAULT_COLUMNS {
181        schema.fields.insert(name.to_string(), ty);
182    }
183    for (name, ty) in default_columns_for(class_name) {
184        schema.fields.insert(name.to_string(), ty);
185    }
186
187    check_one_geopoint(&schema)?;
188
189    if let Some(raw) = clp {
190        schema.clp = Some(validate_clp(raw, &schema, opts)?);
191    }
192    if !options.is_empty() {
193        schema.field_options = Some(options);
194    }
195
196    Ok(schema)
197}
198
199/// Validate a `PUT /schemas/:className` body against the stored schema and produce the plan.
200///
201/// The two mutation errors are checked before anything else happens, exactly as upstream does at
202/// `:880-892`, so a body that both adds a legal field and illegally retypes another adds nothing.
203pub fn plan_update(
204    existing: &ClassSchema,
205    fields: &ParseMap,
206    clp: Option<ParseMap>,
207    opts: ClpValidation,
208) -> Result<SchemaMutation, ParseError> {
209    let changes = parse_fields(fields)?;
210    let class_name = existing.class_name.as_str();
211
212    for (name, change) in &changes {
213        match (existing.field(name), change) {
214            // **The pre-check compares the type *name* only, not the target class.** Upstream is
215            // `existingFields[name].type !== field.type` (`SchemaController.js:882-887`), and
216            // `type` there is the bare string, so `Pointer<Other>` over `Pointer<_User>` passes
217            // this gate and is refused one stage later by the field reservation, as
218            // `INCORRECT_TYPE` `schema mismatch for <Class>.<field>; expected Pointer<_User> but
219            // got Pointer<Other>`. Comparing the whole `FieldType` here reported 255 `Field <name>
220            // exists, cannot update.` instead, which is a different code and a different message
221            // for the same request. The cited `dbTypeMatchesObjectType` is a different function
222            // and is not the gate.
223            //
224            // Resubmitting a field with different options is still an options update rather than a
225            // conflict, which is what makes the comparison a type comparison at all.
226            (Some(current), FieldChange::Set { field_type, .. })
227                if std::mem::discriminant(current) != std::mem::discriminant(field_type) =>
228            {
229                return Err(ParseError::new(
230                    ErrorCode::InvalidSchemaOperation,
231                    format!("Field {name} exists, cannot update."),
232                ))
233            }
234            // **Same kind, different target.** `Pointer<_User>` and `Pointer<Other>` share a
235            // discriminant, so the gate above lets them past, and the comment above used to say
236            // the field reservation would catch it. It does not: reservation runs only for fields
237            // that are *new* (`routes/schemas.rs`), so a retarget answered 200 and silently kept
238            // the original target. Measured against parse-server 9.10.1-alpha.6, which answers
239            // `111 schema mismatch for <Class>.<field>; expected Pointer<_User> but got
240            // Pointer<Other>`.
241            //
242            // The check belongs here rather than at the reservation, because this is the only
243            // stage that sees the stored type for a field the request is not creating.
244            //
245            // **Planning, so nothing is applied.** Upstream discovers the mismatch after its
246            // deletions have already committed, so its failed request drops a column and says
247            // nothing about it. Refusing before any write is the same rule `enforceClassExists`
248            // follows and is registered as a deliberate difference with its mixed-fleet cost.
249            (Some(current), FieldChange::Set { field_type, .. }) if current != field_type => {
250                return Err(crate::infer::schema_mismatch(
251                    class_name, name, current, field_type,
252                ))
253            }
254            (None, FieldChange::Delete) => {
255                return Err(ParseError::new(
256                    ErrorCode::InvalidSchemaOperation,
257                    format!("Field {name} does not exist, cannot delete."),
258                ))
259            }
260            _ => {}
261        }
262    }
263
264    // The merged view the rest of validation runs against: existing fields minus the deletions,
265    // plus the additions (`buildMergedSchemaObject`, `:1507-1540`).
266    let mut merged = existing.clone();
267    let mut set_fields: Vec<SetField> = Vec::new();
268    let mut deleted = Vec::new();
269
270    for (name, change) in &changes {
271        match change {
272            FieldChange::Delete => {
273                merged.fields.shift_remove(name.as_str());
274                deleted.push(name.clone());
275            }
276            FieldChange::Set {
277                field_type,
278                options: field_options,
279            } => {
280                // The option checks in `validateSchemaData` are all inside its
281                // `existingFieldNames.indexOf(fieldName) < 0` guard (`SchemaController.js:1029`),
282                // so a field that already exists is not re-validated here. It is validated at the
283                // point of the write instead, by `enforceFieldExists`' own `defaultValue` check,
284                // which is a strictly narrower rule: no `required` check and no Relation
285                // applicability check. See [`check_default_value_type`].
286                let is_new = existing.field(name).is_none();
287                if is_new {
288                    check_new_field(class_name, name, field_type, field_options)?;
289                }
290                merged.fields.insert(name.clone(), field_type.clone());
291                set_fields.push(SetField {
292                    name: name.clone(),
293                    field_type: field_type.clone(),
294                    options: field_options.clone(),
295                    is_new,
296                });
297            }
298        }
299    }
300
301    check_one_geopoint(&merged)?;
302
303    let clp = match clp {
304        Some(raw) => Some(validate_clp(raw, &merged, opts)?),
305        None => None,
306    };
307
308    // `deleteFields`' own name checks (`:1236-1244`), and they go last on purpose rather than in
309    // the loop above. Upstream reaches them only after `validateSchemaData` has run
310    // (`:899-923`), so a body that both deletes a default column and carries a broken CLP reports
311    // the CLP. Checking earlier would report the field instead, which is a different string for
312    // the same request.
313    for name in &deleted {
314        if !field_name_is_valid(name, class_name) {
315            return Err(ParseError::invalid_key_name(format!(
316                "invalid field name: {name}"
317            )));
318        }
319        if !field_name_is_valid_for_class(name, class_name) {
320            return Err(ParseError::new(
321                FIELD_CANNOT_BE_ADDED_CODE,
322                format!("field {name} cannot be changed"),
323            ));
324        }
325    }
326
327    Ok(SchemaMutation {
328        set_fields,
329        deleted,
330        clp,
331    })
332}
333
334/// Decode a `fields` object into per-field changes.
335///
336/// Every value must be an object. `{"__op":"Delete"}` is a delete; anything else is a type
337/// specification.
338pub fn parse_fields(fields: &ParseMap) -> Result<IndexMap<String, FieldChange>, ParseError> {
339    let mut out = IndexMap::new();
340    for (name, spec) in fields {
341        let ParseValue::Object(spec) = spec else {
342            return Err(ParseError::invalid_json("invalid JSON".to_string()));
343        };
344        if matches!(spec.get("__op"), Some(ParseValue::String(op)) if op == "Delete") {
345            out.insert(name.clone(), FieldChange::Delete);
346            continue;
347        }
348        let field_type = parse_field_type(spec)?;
349        let options: ParseMap = spec
350            .iter()
351            .filter(|(k, _)| k.as_str() != "type" && k.as_str() != "targetClass")
352            .map(|(k, v)| (k.clone(), v.clone()))
353            .collect();
354        out.insert(
355            name.clone(),
356            FieldChange::Set {
357                field_type,
358                options,
359            },
360        );
361    }
362    Ok(out)
363}
364
365/// `fieldTypeIsInvalid` (`SchemaController.js:505-524`), inverted into a parse.
366///
367/// Order is upstream's and it is observable. `Pointer` and `Relation` are checked first, so a
368/// `Pointer` with no `targetClass` reports the missing class name rather than anything about the
369/// type. Only then is a non-string `type` `invalid JSON`, and only then is an unrecognised one
370/// `invalid field type: <type>`.
371///
372/// `ACL` is a pseudo-type. `convertSchemaToAdapterSchema` deletes the `ACL` field before the
373/// schema is stored and `convertAdapterSchemaToParseSchema` puts it back on read
374/// (`:526-538`, `:540-557`), so it is accepted on the wire and never written.
375pub fn parse_field_type(spec: &ParseMap) -> Result<FieldType, ParseError> {
376    let type_name = match spec.get("type") {
377        Some(ParseValue::String(s)) => Some(s.as_str()),
378        _ => None,
379    };
380
381    if matches!(type_name, Some("Pointer") | Some("Relation")) {
382        let name = type_name.unwrap_or_default();
383        let target = match spec.get("targetClass") {
384            // `!targetClass` is falsy, so an empty string is a missing class name too.
385            Some(ParseValue::String(t)) if !t.is_empty() => t.clone(),
386            Some(ParseValue::String(_)) | None | Some(ParseValue::Null) => {
387                return Err(ParseError::new(
388                    NEEDS_CLASS_NAME_CODE,
389                    format!("type {name} needs a class name"),
390                ))
391            }
392            // `typeof targetClass !== 'string'`. A number or an object is `invalid JSON`, not a
393            // missing class name, and the two carry different codes.
394            Some(other) if is_falsy_non_string(other) => {
395                return Err(ParseError::new(
396                    NEEDS_CLASS_NAME_CODE,
397                    format!("type {name} needs a class name"),
398                ))
399            }
400            Some(_) => return Err(ParseError::invalid_json("invalid JSON".to_string())),
401        };
402        if !class_name_is_valid(&target) {
403            return Err(ParseError::new(
404                ErrorCode::InvalidClassName,
405                invalid_class_name_message(&target),
406            ));
407        }
408        return Ok(if name == "Pointer" {
409            FieldType::Pointer {
410                target_class: target,
411            }
412        } else {
413            FieldType::Relation {
414                target_class: target,
415            }
416        });
417    }
418
419    let Some(type_name) = type_name else {
420        return Err(ParseError::invalid_json("invalid JSON".to_string()));
421    };
422
423    Ok(match type_name {
424        "Number" => FieldType::Number,
425        "String" => FieldType::String,
426        "Boolean" => FieldType::Boolean,
427        "Date" => FieldType::Date,
428        "Object" => FieldType::Object,
429        "Array" => FieldType::Array,
430        "GeoPoint" => FieldType::GeoPoint,
431        "File" => FieldType::File,
432        "Bytes" => FieldType::Bytes,
433        "Polygon" => FieldType::Polygon,
434        // **`ACL` is deliberately absent, because it is absent from `validNonRelationOrPointerTypes`
435        // upstream** (`SchemaController.js:492-502`), so `{"type": "ACL"}` falls through to
436        // `INCORRECT_TYPE` `invalid field type: ACL` there (`:520-522`). Accepting it here answered
437        // 200 for a field that never came into existence: `field_type_to_storage` renders
438        // `FieldType::Acl` as the empty string, so nothing reached `_SCHEMA` and the next
439        // `GET /schemas` did not list it. The variant still exists for the *column* named `ACL`,
440        // which every class has; what does not exist is a client's ability to ask for the type by
441        // name.
442        other => {
443            return Err(ParseError::incorrect_type(format!(
444                "invalid field type: {other}"
445            )))
446        }
447    })
448}
449
450/// Everything `validateSchemaData` checks about one *new* field (`:1029-1071`).
451fn check_new_field(
452    class_name: &str,
453    field_name: &str,
454    field_type: &FieldType,
455    options: &ParseMap,
456) -> Result<(), ParseError> {
457    // Note the lowercase and the absent trailing period. `enforceFieldExists` raises
458    // `Invalid field name: <name>.` for the same condition on the write path (`:1137`), and the
459    // two strings are different on purpose because both are asserted upstream.
460    if !field_name_is_valid(field_name, class_name) {
461        return Err(ParseError::invalid_key_name(format!(
462            "invalid field name: {field_name}"
463        )));
464    }
465    if !field_name_is_valid_for_class(field_name, class_name) {
466        return Err(ParseError::new(
467            FIELD_CANNOT_BE_ADDED_CODE,
468            format!("field {field_name} cannot be added"),
469        ));
470    }
471    check_field_options(class_name, field_name, field_type, options)
472}
473
474/// `defaultValue` and `required` (`SchemaController.js:1045-1070`).
475///
476/// `else if`, not two independent checks: a field carrying both only has its `defaultValue`
477/// validated, so `{"type":"Relation","targetClass":"X","required":true,"defaultValue":1}` reports
478/// the default-value mismatch and never mentions `required`.
479fn check_field_options(
480    class_name: &str,
481    field_name: &str,
482    field_type: &FieldType,
483    options: &ParseMap,
484) -> Result<(), ParseError> {
485    if let Some(default_value) = options.get("defaultValue") {
486        let inferred = infer_undecoded_type(class_name, field_name, default_value)?;
487        // Upstream's guard is `typeof defaultValueType === 'object'`, which is true for exactly
488        // the parametric types, the ones `getType` returns an object for.
489        if field_type.is_relation() && inferred.as_ref().is_some_and(Inferred::is_parametric) {
490            return Err(ParseError::incorrect_type(format!(
491                "The 'default value' option is not applicable for {}",
492                field_type.to_wire_string()
493            )));
494        }
495        return check_default_value_type(class_name, field_name, field_type, options);
496    } else if matches!(options.get("required"), Some(ParseValue::Bool(true)))
497        && field_type.is_relation()
498    {
499        return Err(ParseError::incorrect_type(format!(
500            "The 'required' option is not applicable for {}",
501            field_type.to_wire_string()
502        )));
503    }
504    Ok(())
505}
506
507/// A `targetClass` read off an undecoded value.
508///
509/// Two cases because upstream's comparison is strict and its rendering is not. Collapsing them into
510/// a `String` makes a boolean `true` compare equal to the declared string `"true"`.
511#[derive(Debug)]
512enum TargetClass {
513    Comparable(String),
514    NeverEqual(String),
515}
516
517/// A type read off an undecoded value, and whether it can compare equal to a declared one.
518#[derive(Debug, PartialEq, Eq)]
519enum Inferred {
520    Type(FieldType),
521    /// A parametric type whose `className` was truthy but not a string. `dbTypeMatchesObjectType`
522    /// compares `targetClass` with `!==`, so this matches nothing; the string is only for the
523    /// mismatch message.
524    NeverEqual {
525        rendered: String,
526    },
527}
528
529impl Inferred {
530    /// `typeof defaultValueType === 'object'`, which `getObjectType` returns for exactly the two
531    /// parametric types. True whatever the `className` turned out to be, because upstream builds
532    /// the object before anything compares it.
533    fn is_parametric(&self) -> bool {
534        match self {
535            Inferred::Type(t) => t.target_class().is_some(),
536            Inferred::NeverEqual { .. } => true,
537        }
538    }
539}
540
541/// The type of a value that has **not** been through the `__type` decoder.
542///
543/// `getObjectType` (`SchemaController.js`), which is where upstream reads a `defaultValue`'s type,
544/// and it is a **validator as well as a classifier**: every recognized tag is guarded on the key
545/// that carries its payload, and anything that falls through, a guard that fails or a tag nobody
546/// recognizes, throws `INCORRECT_TYPE` `This is not a valid <tag>`.
547///
548/// **Both halves are load-bearing here and one of them was a regression.** A schema body is decoded
549/// raw so it can be stored as sent, which means the ordinary decoder no longer rejects a malformed
550/// envelope on the way in. Reading the tag without re-checking the payload therefore accepted
551/// `{"__type": "Date"}` with no `iso`, and accepted an unknown tag as an ordinary object: schema
552/// metadata a parse-server node refuses to create, written into a database it shares.
553fn infer_undecoded_type(
554    class_name: &str,
555    field_name: &str,
556    value: &ParseValue,
557) -> Result<Option<Inferred>, ParseError> {
558    let ParseValue::Object(map) = value else {
559        return Ok(infer_type(value).map(Inferred::Type));
560    };
561    // **`if (obj.__type)` is a truthiness test.** Not a presence test and not a type test, and it
562    // was read as both. `__type: ""` is falsy, so upstream ignores it and carries on to infer an
563    // ordinary `Object`; reading it as presence made that a thrown `This is not a valid `. A truthy
564    // non-string like `__type: 7` is the mirror: the `switch` compares against string literals, so
565    // it matches no case and falls to the throw, where reading it as a type test let it through as
566    // an `Object`.
567    let Some(tag_value) = map.get("__type").filter(|v| js_number::is_truthy(v)) else {
568        return Ok(infer_type(value).map(Inferred::Type));
569    };
570    // `'This is not a valid ' + obj.__type` is string concatenation, so the tag is rendered the way
571    // JavaScript renders it rather than quoted or debug-printed.
572    let tag = js_number::to_ecma_display(tag_value);
573
574    // **Truthiness again, and it is not uniform.** Six of the seven guards are `if (obj.key)`, so
575    // an empty string, a zero and a `false` all fail them. `GeoPoint` alone is
576    // `obj.latitude != null && obj.longitude != null`, a loose null check, so a latitude of `0`
577    // passes there and a name of `""` does not pass anywhere else. Collapsing the two into one
578    // predicate is wrong in one direction or the other whichever one you pick.
579    let truthy = |key: &str| map.get(key).is_some_and(js_number::is_truthy);
580    let not_null = |key: &str| matches!(map.get(key), Some(v) if !matches!(v, ParseValue::Null));
581    // **`targetClass: obj.className` keeps the value, and the comparison against it is strict.**
582    // `dbTypeMatchesObjectType` is `dbType.targetClass !== objectType.targetClass`
583    // (`SchemaController.js:689-695`), so a `className` of boolean `true` never equals a declared
584    // `targetClass` of the string `"true"`. Coercing it to a string here made those two match and
585    // let the default through: 200 where upstream answers 111, and the accepted metadata is then
586    // applied to creates by any parse-server node sharing the database.
587    //
588    // So the coercion belongs to the message alone, which is `typeToString` and *is* string
589    // interpolation (`:701-703`, `Pointer<${targetClass}>`).
590    let target = |key: &str| match map.get(key) {
591        Some(v) if js_number::is_truthy(v) => Some(match v {
592            ParseValue::String(s) => TargetClass::Comparable(s.clone()),
593            other => TargetClass::NeverEqual(js_number::to_ecma_display(other)),
594        }),
595        _ => None,
596    };
597
598    // Each arm is upstream's guard, and the key it names is upstream's key. A tag that is truthy
599    // but not one of these seven strings matches no case, which is the same fall-through a failed
600    // guard takes.
601    let parametric = |tag: &str, target: Option<TargetClass>| {
602        target.map(|t| match t {
603            TargetClass::Comparable(target_class) => Inferred::Type(match tag {
604                "Pointer" => FieldType::Pointer { target_class },
605                _ => FieldType::Relation { target_class },
606            }),
607            TargetClass::NeverEqual(rendered) => Inferred::NeverEqual {
608                rendered: format!("{tag}<{rendered}>"),
609            },
610        })
611    };
612    let inferred = match tag_value {
613        ParseValue::String(t) => match t.as_str() {
614            "Pointer" => parametric("Pointer", target("className")),
615            "Relation" => parametric("Relation", target("className")),
616            "File" => truthy("name").then_some(Inferred::Type(FieldType::File)),
617            "Date" => truthy("iso").then_some(Inferred::Type(FieldType::Date)),
618            "GeoPoint" => (not_null("latitude") && not_null("longitude"))
619                .then_some(Inferred::Type(FieldType::GeoPoint)),
620            "Bytes" => truthy("base64").then_some(Inferred::Type(FieldType::Bytes)),
621            // An empty array is truthy in JavaScript, so `coordinates: []` is a Polygon here.
622            "Polygon" => truthy("coordinates").then_some(Inferred::Type(FieldType::Polygon)),
623            _ => None,
624        },
625        _ => None,
626    };
627
628    match inferred {
629        Some(inferred) => Ok(Some(inferred)),
630        // The single throw every failed guard and every unknown tag falls to. The message names the
631        // tag the client sent, including one nobody recognizes.
632        None => {
633            let _ = (class_name, field_name);
634            Err(ParseError::incorrect_type(format!(
635                "This is not a valid {tag}"
636            )))
637        }
638    }
639}
640
641/// The `defaultValue` type check **as `enforceFieldExists` runs it**
642/// (`SchemaController.js:1145-1162`), which is the only option validation an already-existing
643/// field gets.
644///
645/// Deliberately narrower than `check_field_options`. `validateSchemaData`'s Relation
646/// applicability branches and its `required` check are behind the `existingFieldNames` guard
647/// (`:1029`) and never see a field that is already stored, so an existing `Relation` field can be
648/// resubmitted with `required: true` and upstream accepts it. This function reproduces only what
649/// upstream actually applies there.
650pub fn check_default_value_type(
651    class_name: &str,
652    field_name: &str,
653    field_type: &FieldType,
654    options: &ParseMap,
655) -> Result<(), ParseError> {
656    let Some(default_value) = options.get("defaultValue") else {
657        return Ok(());
658    };
659    {
660        let inferred = infer_undecoded_type(class_name, field_name, default_value)?;
661        // A `null` default has no type and therefore never matches. That is upstream's behavior:
662        // `getType(null)` is `undefined`, `typeToString(undefined)` throws, and the request 500s.
663        // Reported as a mismatch here instead, with `undefined` as the rendering, because a crash
664        // is not a wire behavior worth reproducing.
665        let (matches, got) = match &inferred {
666            Some(Inferred::Type(t)) => (t == field_type, t.to_wire_string()),
667            // A truthy non-string `className`. Never equal under upstream's strict `!==`, and
668            // rendered by interpolation for the message.
669            Some(Inferred::NeverEqual { rendered }) => (false, rendered.clone()),
670            None => (false, "undefined".to_string()),
671        };
672        if !matches {
673            return Err(ParseError::incorrect_type(format!(
674                "schema mismatch for {class_name}.{field_name} default value; expected {} but got \
675                 {got}",
676                field_type.to_wire_string()
677            )));
678        }
679    }
680    Ok(())
681}
682
683/// At most one GeoPoint per class (`SchemaController.js:1078-1091`).
684///
685/// The message names the second field and then the first, in the order the merged field table
686/// enumerates them, which is why the field table has to be order-preserving.
687///
688/// There is a **second** one-GeoPoint check on the object-write path, `validateObject` at
689/// `:1286-1303`, and it raises a completely different string: `there can only be one geopoint
690/// field in a class`, with no field names. A client that matches on one will not match the other.
691fn check_one_geopoint(schema: &ClassSchema) -> Result<(), ParseError> {
692    let mut geo = schema
693        .fields
694        .iter()
695        .filter(|(_, ty)| **ty == FieldType::GeoPoint)
696        .map(|(name, _)| name.as_str());
697    let (Some(first), Some(second)) = (geo.next(), geo.next()) else {
698        return Ok(());
699    };
700    Err(ParseError::incorrect_type(format!(
701        "currently, only one GeoPoint field may exist in an object. Adding {second} when {first} \
702         already exists."
703    )))
704}
705
706/// Would JavaScript's `!x` be true for this non-string value?
707///
708/// `fieldTypeIsInvalid` tests `!targetClass` before it tests `typeof targetClass !== 'string'`,
709/// so `0` and `false` reach the missing-class-name branch while `1` and `{}` reach the
710/// `invalid JSON` one.
711fn is_falsy_non_string(value: &ParseValue) -> bool {
712    match value {
713        ParseValue::Null => true,
714        ParseValue::Bool(b) => !*b,
715        ParseValue::Number(n) => *n == 0.0 || n.is_nan(),
716        _ => false,
717    }
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723    use crate::clp_validate::{ObjectIdForm, Unenforceable};
724    use parse_rust_core::classify;
725
726    fn opts() -> ClpValidation {
727        ClpValidation {
728            object_id: ObjectIdForm::Generated,
729            unenforceable: Unenforceable::Accept,
730        }
731    }
732
733    fn map(json: &str) -> ParseMap {
734        match classify(serde_json::from_str(json).expect("test literal must be valid JSON"))
735            .expect("classify")
736        {
737            ParseValue::Object(m) => m,
738            other => panic!("expected an object, got {other:?}"),
739        }
740    }
741
742    fn spec(json: &str) -> FieldType {
743        parse_field_type(&map(json)).expect("valid type")
744    }
745
746    fn spec_err(json: &str) -> ParseError {
747        parse_field_type(&map(json)).expect_err("invalid type")
748    }
749
750    /// `getObjectType`'s guards are **JavaScript truthiness**, and reading them as Rust presence
751    /// gets three separate things wrong.
752    ///
753    /// A schema body is decoded raw so it can be stored as sent, so this function is the only thing
754    /// standing between a client and `_SCHEMA` metadata that a parse-server node sharing the
755    /// database would refuse to create. Every row below is a quotation from `SchemaController.js`:
756    /// `if (obj.__type)`, `if (obj.iso)`, `if (obj.className)`, and for GeoPoint alone
757    /// `if (obj.latitude != null && obj.longitude != null)`.
758    #[test]
759    fn undecoded_type_inference_follows_javascript_truthiness() {
760        let raw = |json: &str| {
761            parse_rust_core::classify_raw(
762                serde_json::from_str(json).expect("test literal must be valid JSON"),
763            )
764            .expect("raw decode")
765        };
766        let infer = |json: &str| infer_undecoded_type("C", "f", &raw(json));
767
768        // An empty payload is falsy, so the guard fails and the tag falls to the throw. Presence
769        // accepted every one of these.
770        for (json, tag) in [
771            (r#"{"__type":"Date","iso":""}"#, "Date"),
772            (r#"{"__type":"Bytes","base64":""}"#, "Bytes"),
773            (r#"{"__type":"File","name":""}"#, "File"),
774            (r#"{"__type":"Pointer","className":""}"#, "Pointer"),
775        ] {
776            let err = infer(json).expect_err("a falsy payload fails the guard");
777            assert_eq!(err.message, format!("This is not a valid {tag}"), "{json}");
778        }
779
780        // GeoPoint is the exception: `!= null` is a loose null check, so a zero coordinate passes
781        // where a zero anywhere else would not. `0, 0` is a real place.
782        assert_eq!(
783            infer(r#"{"__type":"GeoPoint","latitude":0,"longitude":0}"#).expect("null island"),
784            Some(Inferred::Type(FieldType::GeoPoint))
785        );
786
787        // An empty array is truthy in JavaScript, so this is a Polygon as far as the guard is
788        // concerned. Whether the coordinates make a polygon is not asked here.
789        assert_eq!(
790            infer(r#"{"__type":"Polygon","coordinates":[]}"#).expect("truthy"),
791            Some(Inferred::Type(FieldType::Polygon))
792        );
793
794        // `__type: ""` is falsy, so upstream never enters the switch and infers an ordinary object.
795        // Treating the key as present threw instead, refusing a body upstream stores.
796        assert_eq!(
797            infer(r#"{"__type":"","a":1}"#).expect("a falsy tag is not a tag"),
798            Some(Inferred::Type(FieldType::Object))
799        );
800
801        // A truthy non-string tag is the mirror case: the switch compares against string literals,
802        // matches nothing, and falls to the throw. Requiring a string let these through as objects.
803        // The message renders the tag by concatenation, not by quoting it.
804        for (json, rendered) in [
805            (r#"{"__type":7}"#, "7"),
806            (r#"{"__type":true}"#, "true"),
807            (r#"{"__type":{"a":1}}"#, "[object Object]"),
808            (r#"{"__type":[1,2]}"#, "1,2"),
809        ] {
810            let err = infer(json).expect_err("a truthy tag that matches no case throws");
811            assert_eq!(
812                err.message,
813                format!("This is not a valid {rendered}"),
814                "{json}"
815            );
816        }
817
818        // And the falsy non-string tags fall through rather than throwing.
819        for json in [
820            r#"{"__type":0}"#,
821            r#"{"__type":false}"#,
822            r#"{"__type":null}"#,
823        ] {
824            assert_eq!(
825                infer(json).expect("falsy tags are not tags"),
826                Some(Inferred::Type(FieldType::Object)),
827                "{json}"
828            );
829        }
830    }
831
832    #[test]
833    fn every_non_parametric_type_parses() {
834        for (name, expected) in [
835            ("Number", FieldType::Number),
836            ("String", FieldType::String),
837            ("Boolean", FieldType::Boolean),
838            ("Date", FieldType::Date),
839            ("Object", FieldType::Object),
840            ("Array", FieldType::Array),
841            ("GeoPoint", FieldType::GeoPoint),
842            ("File", FieldType::File),
843            ("Bytes", FieldType::Bytes),
844            ("Polygon", FieldType::Polygon),
845        ] {
846            assert_eq!(spec(&format!(r#"{{"type":"{name}"}}"#)), expected);
847        }
848    }
849
850    #[test]
851    fn acl_is_a_column_every_class_has_and_a_type_no_client_may_name() {
852        // Upstream's split, and the reason accepting it was wrong: the request answered 200 for a
853        // field that never came into existence, because the renderer emits nothing for it.
854        let err = parse_fields(&map(r#"{"acl":{"type":"ACL"}}"#)).expect_err("ACL is not a type");
855        assert_eq!(err.code, parse_rust_core::ErrorCode::IncorrectType);
856        assert_eq!(err.message, "invalid field type: ACL");
857        // The renderer still has to answer for the variant, because the column exists.
858        assert!(crate::storage_format::field_type_to_storage(&FieldType::Acl).is_empty());
859    }
860
861    #[test]
862    fn an_unrecognised_type_names_itself() {
863        let e = spec_err(r#"{"type":"Vector"}"#);
864        assert_eq!(e.message, "invalid field type: Vector");
865        assert_eq!(e.code, ErrorCode::IncorrectType);
866    }
867
868    #[test]
869    fn parametric_types_need_a_target_class() {
870        for name in ["Pointer", "Relation"] {
871            let e = spec_err(&format!(r#"{{"type":"{name}"}}"#));
872            assert_eq!(e.message, format!("type {name} needs a class name"));
873            // An empty string is falsy, so it is a missing class name and not an invalid one.
874            let e = spec_err(&format!(r#"{{"type":"{name}","targetClass":""}}"#));
875            assert_eq!(e.message, format!("type {name} needs a class name"));
876        }
877        // A non-string, truthy targetClass is `invalid JSON` instead.
878        let e = spec_err(r#"{"type":"Pointer","targetClass":7}"#);
879        assert_eq!(e.message, "invalid JSON");
880        assert_eq!(e.code, ErrorCode::InvalidJson);
881        // And a syntactically invalid class name is a third error.
882        let e = spec_err(r#"{"type":"Pointer","targetClass":"1Bad"}"#);
883        assert_eq!(e.code, ErrorCode::InvalidClassName);
884        assert!(e.message.starts_with("Invalid classname: 1Bad,"));
885    }
886
887    /// The trailing space is in the upstream literal and reaches the client.
888    #[test]
889    fn the_invalid_class_name_message_keeps_its_trailing_space() {
890        let m = invalid_class_name_message("1Bad");
891        assert_eq!(
892            m,
893            "Invalid classname: 1Bad, classnames can only have alphanumeric characters and _, and \
894             must start with an alpha character "
895        );
896        assert!(m.ends_with(' '));
897    }
898
899    #[test]
900    fn a_new_class_carries_its_default_columns() {
901        let s = validate_new_class("Post", &map(r#"{"title":{"type":"String"}}"#), None, opts())
902            .expect("valid");
903        assert_eq!(s.field("title"), Some(&FieldType::String));
904        for name in ["objectId", "createdAt", "updatedAt", "ACL"] {
905            assert!(s.field(name).is_some(), "{name} missing");
906        }
907    }
908
909    #[test]
910    fn role_and_session_get_their_own_default_columns() {
911        let role = validate_new_class("_Role", &ParseMap::new(), None, opts()).expect("valid");
912        assert_eq!(role.field("name"), Some(&FieldType::String));
913        assert_eq!(
914            role.field("users"),
915            Some(&FieldType::Relation {
916                target_class: "_User".into()
917            })
918        );
919        assert_eq!(
920            role.field("roles"),
921            Some(&FieldType::Relation {
922                target_class: "_Role".into()
923            })
924        );
925
926        let session =
927            validate_new_class("_Session", &ParseMap::new(), None, opts()).expect("valid");
928        assert_eq!(
929            session.field("user"),
930            Some(&FieldType::Pointer {
931                target_class: "_User".into()
932            })
933        );
934        assert_eq!(session.field("sessionToken"), Some(&FieldType::String));
935        assert_eq!(session.field("expiresAt"), Some(&FieldType::Date));
936        assert_eq!(session.field("createdWith"), Some(&FieldType::Object));
937        assert_eq!(session.field("installationId"), Some(&FieldType::String));
938    }
939
940    #[test]
941    fn a_default_column_cannot_be_redeclared() {
942        let e = validate_new_class(
943            "Post",
944            &map(r#"{"objectId":{"type":"String"}}"#),
945            None,
946            opts(),
947        )
948        .expect_err("refused");
949        assert_eq!(e.message, "field objectId cannot be added");
950
951        // And a class's own default column, which is what makes the second table load-bearing.
952        let e = validate_new_class("_Role", &map(r#"{"name":{"type":"String"}}"#), None, opts())
953            .expect_err("refused");
954        assert_eq!(e.message, "field name cannot be added");
955    }
956
957    #[test]
958    fn a_malformed_field_name_uses_the_lowercase_message() {
959        let e = validate_new_class("Post", &map(r#"{"1bad":{"type":"String"}}"#), None, opts())
960            .expect_err("refused");
961        assert_eq!(e.message, "invalid field name: 1bad");
962        assert_eq!(e.code, ErrorCode::InvalidKeyName);
963        // `length` is banned by `invalidColumns` rather than by the regex.
964        let e = validate_new_class(
965            "Post",
966            &map(r#"{"length":{"type":"String"}}"#),
967            None,
968            opts(),
969        )
970        .expect_err("refused");
971        assert_eq!(e.message, "invalid field name: length");
972    }
973
974    #[test]
975    fn at_most_one_geopoint_per_class() {
976        let e = validate_new_class(
977            "Post",
978            &map(r#"{"here":{"type":"GeoPoint"},"there":{"type":"GeoPoint"}}"#),
979            None,
980            opts(),
981        )
982        .expect_err("refused");
983        assert_eq!(
984            e.message,
985            "currently, only one GeoPoint field may exist in an object. Adding there when here \
986             already exists."
987        );
988        assert_eq!(e.code, ErrorCode::IncorrectType);
989        // One is fine.
990        assert!(validate_new_class(
991            "Post",
992            &map(r#"{"here":{"type":"GeoPoint"}}"#),
993            None,
994            opts()
995        )
996        .is_ok());
997    }
998
999    #[test]
1000    fn default_value_must_match_the_declared_type() {
1001        let e = validate_new_class(
1002            "Post",
1003            &map(r#"{"views":{"type":"Number","defaultValue":"none"}}"#),
1004            None,
1005            opts(),
1006        )
1007        .expect_err("refused");
1008        assert_eq!(
1009            e.message,
1010            "schema mismatch for Post.views default value; expected Number but got String"
1011        );
1012
1013        let s = validate_new_class(
1014            "Post",
1015            &map(r#"{"views":{"type":"Number","defaultValue":0}}"#),
1016            None,
1017            opts(),
1018        )
1019        .expect("valid");
1020        // Stored, not enforced. The 0.2.0 contract in one assertion.
1021        let stored = s.field_options.expect("options stored");
1022        assert!(stored.contains_key("views"));
1023    }
1024
1025    #[test]
1026    fn required_and_default_value_are_not_applicable_to_a_relation() {
1027        let e = validate_new_class(
1028            "Post",
1029            &map(r#"{"tags":{"type":"Relation","targetClass":"Tag","required":true}}"#),
1030            None,
1031            opts(),
1032        )
1033        .expect_err("refused");
1034        assert_eq!(
1035            e.message,
1036            "The 'required' option is not applicable for Relation<Tag>"
1037        );
1038
1039        let e = validate_new_class(
1040            "Post",
1041            &map(r#"{"tags":{"type":"Relation","targetClass":"Tag",
1042                    "defaultValue":{"__type":"Pointer","className":"Tag","objectId":"a"}}}"#),
1043            None,
1044            opts(),
1045        )
1046        .expect_err("refused");
1047        assert_eq!(
1048            e.message,
1049            "The 'default value' option is not applicable for Relation<Tag>"
1050        );
1051    }
1052
1053    /// `type` and `targetClass` come off; everything else is stored verbatim, including keys
1054    /// parse-rust does not model. Dropping one would relax a constraint a parse-server node
1055    /// reading the same database still enforces.
1056    #[test]
1057    fn field_options_round_trip_every_key_but_type_and_target_class() {
1058        let s = validate_new_class(
1059            "Post",
1060            &map(r#"{"author":{"type":"Pointer","targetClass":"_User",
1061                    "required":true,"somethingNew":42}}"#),
1062            None,
1063            opts(),
1064        )
1065        .expect("valid");
1066        let stored = s.field_options.expect("options stored");
1067        let ParseValue::Object(author) = stored.get("author").expect("author") else {
1068            panic!("expected an object");
1069        };
1070        assert_eq!(author.len(), 2);
1071        assert!(author.contains_key("required"));
1072        assert!(author.contains_key("somethingNew"));
1073        assert!(!author.contains_key("type"));
1074        assert!(!author.contains_key("targetClass"));
1075    }
1076
1077    fn post() -> ClassSchema {
1078        crate::controller::default_schema("Post")
1079            .with_field("title", FieldType::String)
1080            .with_field(
1081                "tags",
1082                FieldType::Relation {
1083                    target_class: "Tag".into(),
1084                },
1085            )
1086    }
1087
1088    #[test]
1089    fn an_update_plans_adds_and_deletes() {
1090        let plan = plan_update(
1091            &post(),
1092            &map(r#"{"body":{"type":"String"},"tags":{"__op":"Delete"}}"#),
1093            None,
1094            opts(),
1095        )
1096        .expect("valid");
1097        assert!(matches!(plan.set_fields.as_slice(), [f] if f.name == "body"
1098                && f.field_type == FieldType::String
1099                && f.is_new));
1100        assert_eq!(plan.deleted, vec!["tags".to_string()]);
1101        assert!(
1102            plan.clp.is_none(),
1103            "an absent CLP must not become an empty one"
1104        );
1105    }
1106
1107    #[test]
1108    fn retyping_an_existing_field_is_refused_and_nothing_else_in_the_body_applies() {
1109        let e = plan_update(
1110            &post(),
1111            &map(r#"{"body":{"type":"String"},"title":{"type":"Number"}}"#),
1112            None,
1113            opts(),
1114        )
1115        .expect_err("refused");
1116        assert_eq!(e.message, "Field title exists, cannot update.");
1117        assert_eq!(e.code, ErrorCode::InvalidSchemaOperation);
1118        assert_eq!(e.code.as_i32(), 255);
1119    }
1120
1121    #[test]
1122    fn deleting_a_field_that_does_not_exist_is_refused() {
1123        let e = plan_update(
1124            &post(),
1125            &map(r#"{"ghost":{"__op":"Delete"}}"#),
1126            None,
1127            opts(),
1128        )
1129        .expect_err("refused");
1130        assert_eq!(e.message, "Field ghost does not exist, cannot delete.");
1131        assert_eq!(e.code.as_i32(), 255);
1132    }
1133
1134    #[test]
1135    fn deleting_a_default_column_has_its_own_message() {
1136        let e = plan_update(
1137            &post(),
1138            &map(r#"{"objectId":{"__op":"Delete"}}"#),
1139            None,
1140            opts(),
1141        )
1142        .expect_err("refused");
1143        assert_eq!(e.message, "field objectId cannot be changed");
1144        // Note "changed", not "added". The schema API uses both strings for the same predicate,
1145        // one on delete and one on add, and a client matching the wrong one matches nothing.
1146        let e = validate_new_class(
1147            "Post",
1148            &map(r#"{"objectId":{"type":"String"}}"#),
1149            None,
1150            opts(),
1151        )
1152        .expect_err("refused");
1153        assert_eq!(e.message, "field objectId cannot be added");
1154    }
1155
1156    /// Upstream reaches `deleteFields` only after `validateSchemaData`, so a body that is wrong
1157    /// in both ways reports the CLP. Ordering is wire-visible whenever two checks can both fire.
1158    #[test]
1159    fn the_clp_is_validated_before_the_delete_name_check() {
1160        let e = plan_update(
1161            &post(),
1162            &map(r#"{"objectId":{"__op":"Delete"}}"#),
1163            Some(map(r#"{"nope":{}}"#)),
1164            opts(),
1165        )
1166        .expect_err("refused");
1167        assert_eq!(
1168            e.message,
1169            "nope is not a valid operation for class level permissions"
1170        );
1171    }
1172
1173    /// Type identity is `(type, targetClass)`, so resubmitting a field with new options is an
1174    /// options update and not a conflict.
1175    #[test]
1176    fn resubmitting_a_field_with_different_options_is_not_a_conflict() {
1177        let plan = plan_update(
1178            &post(),
1179            &map(r#"{"title":{"type":"String","required":true}}"#),
1180            None,
1181            opts(),
1182        )
1183        .expect("valid");
1184        assert!(
1185            matches!(plan.set_fields.as_slice(), [f] if f.name == "title"
1186                && !f.is_new
1187                && f.options.contains_key("required")),
1188            "an existing field is still submitted, so its options can be written"
1189        );
1190    }
1191
1192    /// Resubmitting a field **without** the options it was stored with clears them, and the plan
1193    /// has to carry the empty set for the route to be able to write it. Recording only non-empty
1194    /// options made this a silent no-op, so a `required` field could never be made optional again.
1195    #[test]
1196    fn resubmitting_a_field_with_no_options_carries_an_empty_option_set() {
1197        let plan = plan_update(
1198            &post(),
1199            &map(r#"{"title":{"type":"String"}}"#),
1200            None,
1201            opts(),
1202        )
1203        .expect("valid");
1204        assert!(
1205            matches!(plan.set_fields.as_slice(), [f] if f.name == "title"
1206                && !f.is_new
1207                && f.options.is_empty())
1208        );
1209    }
1210
1211    /// The one option rule that reaches an already-stored field. `validateSchemaData`'s checks are
1212    /// behind its `existingFieldNames` guard, but `enforceFieldExists` runs the `defaultValue`
1213    /// type check on every submitted field.
1214    #[test]
1215    fn an_existing_fields_default_value_is_still_type_checked() {
1216        let e = check_default_value_type(
1217            "Post",
1218            "title",
1219            &FieldType::String,
1220            &map(r#"{"defaultValue":10}"#),
1221        )
1222        .unwrap_err();
1223        assert_eq!(e.code, ErrorCode::IncorrectType);
1224        assert_eq!(
1225            e.message,
1226            "schema mismatch for Post.title default value; expected String but got Number"
1227        );
1228        assert!(check_default_value_type(
1229            "Post",
1230            "title",
1231            &FieldType::String,
1232            &map(r#"{"defaultValue":"ok","required":true}"#)
1233        )
1234        .is_ok());
1235    }
1236
1237    /// The CLP is validated against the *merged* fields, so a field being added in the same body
1238    /// can be protected by it and a field being deleted cannot.
1239    #[test]
1240    fn the_clp_is_validated_against_the_merged_schema() {
1241        let plan = plan_update(
1242            &post(),
1243            &map(r#"{"secret":{"type":"String"}}"#),
1244            Some(map(r#"{"protectedFields":{"*":["secret"]}}"#)),
1245            opts(),
1246        )
1247        .expect("valid");
1248        assert!(plan.clp.is_some());
1249
1250        let e = plan_update(
1251            &post(),
1252            &map(r#"{"title":{"__op":"Delete"}}"#),
1253            Some(map(r#"{"protectedFields":{"*":["title"]}}"#)),
1254            opts(),
1255        )
1256        .expect_err("refused");
1257        assert_eq!(
1258            e.message,
1259            "Field 'title' in protectedFields:* does not exist"
1260        );
1261    }
1262
1263    #[test]
1264    fn an_empty_clp_block_is_not_an_absent_one() {
1265        let plan =
1266            plan_update(&post(), &ParseMap::new(), Some(ParseMap::new()), opts()).expect("valid");
1267        let clp = plan.clp.expect("present");
1268        assert!(clp.raw().is_empty());
1269        assert!(plan_update(&post(), &ParseMap::new(), None, opts())
1270            .expect("valid")
1271            .clp
1272            .is_none());
1273    }
1274
1275    #[test]
1276    fn a_field_spec_that_is_not_an_object_is_invalid_json() {
1277        let e = validate_new_class("Post", &map(r#"{"title":"String"}"#), None, opts())
1278            .expect_err("refused");
1279        assert_eq!(e.message, "invalid JSON");
1280    }
1281
1282    /// The two lists are different and neither contains the other. Conflating them is what makes
1283    /// `_Hooks` addressable through `/classes`.
1284    #[test]
1285    fn system_and_volatile_classes_are_two_different_lists() {
1286        use crate::infer::{SYSTEM_CLASSES, VOLATILE_CLASSES};
1287        for name in ["_Hooks", "_GlobalConfig", "_GraphQLConfig"] {
1288            assert!(VOLATILE_CLASSES.contains(&name), "{name} is volatile");
1289            assert!(
1290                !SYSTEM_CLASSES.contains(&name),
1291                "{name} is not a system class"
1292            );
1293            assert!(!class_name_is_valid(name), "{name} must not be addressable");
1294        }
1295        for name in ["_User", "_Installation", "_Role", "_Session", "_Product"] {
1296            assert!(SYSTEM_CLASSES.contains(&name));
1297            assert!(!VOLATILE_CLASSES.contains(&name));
1298        }
1299        for name in [
1300            "_JobStatus",
1301            "_PushStatus",
1302            "_JobSchedule",
1303            "_Audience",
1304            "_Idempotency",
1305        ] {
1306            assert!(SYSTEM_CLASSES.contains(&name), "{name} is a system class");
1307            assert!(VOLATILE_CLASSES.contains(&name), "{name} is volatile");
1308        }
1309    }
1310}