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}