use indexmap::IndexMap;
use parse_rust_core::{
js_number, ClassLevelPermissions, ErrorCode, ParseError, ParseMap, ParseValue,
};
use parse_rust_storage::{ClassSchema, FieldType};
use crate::clp_validate::{validate_clp, ClpValidation};
use crate::infer::{
class_name_is_valid, default_columns_for, field_name_is_valid, field_name_is_valid_for_class,
infer_type, invalid_class_name_message,
};
pub const NEEDS_CLASS_NAME_CODE: ErrorCode = ErrorCode::MissingClassName;
pub const FIELD_CANNOT_BE_ADDED_CODE: ErrorCode = ErrorCode::UnchangeableField;
#[derive(Debug, Clone)]
pub enum FieldChange {
Set {
field_type: FieldType,
options: ParseMap,
},
Delete,
}
#[derive(Debug, Clone)]
#[must_use]
pub struct SchemaMutation {
pub deleted: Vec<String>,
pub clp: Option<ClassLevelPermissions>,
pub set_fields: Vec<SetField>,
}
#[derive(Debug, Clone)]
pub struct SetField {
pub name: String,
pub field_type: FieldType,
pub options: ParseMap,
pub is_new: bool,
}
impl SchemaMutation {
pub fn is_empty(&self) -> bool {
self.set_fields.is_empty() && self.deleted.is_empty() && self.clp.is_none()
}
}
pub fn validate_new_class(
class_name: &str,
fields: &ParseMap,
clp: Option<ParseMap>,
opts: ClpValidation,
) -> Result<ClassSchema, ParseError> {
if !class_name_is_valid(class_name) {
return Err(ParseError::new(
ErrorCode::InvalidClassName,
invalid_class_name_message(class_name),
));
}
let changes = parse_fields(fields)?;
let mut schema = ClassSchema::new(class_name);
let mut options = ParseMap::new();
for (name, change) in &changes {
match change {
FieldChange::Delete => {
return Err(ParseError::new(
ErrorCode::InvalidSchemaOperation,
format!("Field {name} does not exist, cannot delete."),
))
}
FieldChange::Set {
field_type,
options: field_options,
} => {
check_new_field(class_name, name, field_type, field_options)?;
schema.fields.insert(name.clone(), field_type.clone());
if !field_options.is_empty() {
options.insert(name.clone(), ParseValue::Object(field_options.clone()));
}
}
}
}
for (name, ty) in crate::infer::DEFAULT_COLUMNS {
schema.fields.insert(name.to_string(), ty);
}
for (name, ty) in default_columns_for(class_name) {
schema.fields.insert(name.to_string(), ty);
}
check_one_geopoint(&schema)?;
if let Some(raw) = clp {
schema.clp = Some(validate_clp(raw, &schema, opts)?);
}
if !options.is_empty() {
schema.field_options = Some(options);
}
Ok(schema)
}
pub fn plan_update(
existing: &ClassSchema,
fields: &ParseMap,
clp: Option<ParseMap>,
opts: ClpValidation,
) -> Result<SchemaMutation, ParseError> {
let changes = parse_fields(fields)?;
let class_name = existing.class_name.as_str();
for (name, change) in &changes {
match (existing.field(name), change) {
(Some(current), FieldChange::Set { field_type, .. })
if std::mem::discriminant(current) != std::mem::discriminant(field_type) =>
{
return Err(ParseError::new(
ErrorCode::InvalidSchemaOperation,
format!("Field {name} exists, cannot update."),
))
}
(Some(current), FieldChange::Set { field_type, .. }) if current != field_type => {
return Err(crate::infer::schema_mismatch(
class_name, name, current, field_type,
))
}
(None, FieldChange::Delete) => {
return Err(ParseError::new(
ErrorCode::InvalidSchemaOperation,
format!("Field {name} does not exist, cannot delete."),
))
}
_ => {}
}
}
let mut merged = existing.clone();
let mut set_fields: Vec<SetField> = Vec::new();
let mut deleted = Vec::new();
for (name, change) in &changes {
match change {
FieldChange::Delete => {
merged.fields.shift_remove(name.as_str());
deleted.push(name.clone());
}
FieldChange::Set {
field_type,
options: field_options,
} => {
let is_new = existing.field(name).is_none();
if is_new {
check_new_field(class_name, name, field_type, field_options)?;
}
merged.fields.insert(name.clone(), field_type.clone());
set_fields.push(SetField {
name: name.clone(),
field_type: field_type.clone(),
options: field_options.clone(),
is_new,
});
}
}
}
check_one_geopoint(&merged)?;
let clp = match clp {
Some(raw) => Some(validate_clp(raw, &merged, opts)?),
None => None,
};
for name in &deleted {
if !field_name_is_valid(name, class_name) {
return Err(ParseError::invalid_key_name(format!(
"invalid field name: {name}"
)));
}
if !field_name_is_valid_for_class(name, class_name) {
return Err(ParseError::new(
FIELD_CANNOT_BE_ADDED_CODE,
format!("field {name} cannot be changed"),
));
}
}
Ok(SchemaMutation {
set_fields,
deleted,
clp,
})
}
pub fn parse_fields(fields: &ParseMap) -> Result<IndexMap<String, FieldChange>, ParseError> {
let mut out = IndexMap::new();
for (name, spec) in fields {
let ParseValue::Object(spec) = spec else {
return Err(ParseError::invalid_json("invalid JSON".to_string()));
};
if matches!(spec.get("__op"), Some(ParseValue::String(op)) if op == "Delete") {
out.insert(name.clone(), FieldChange::Delete);
continue;
}
let field_type = parse_field_type(spec)?;
let options: ParseMap = spec
.iter()
.filter(|(k, _)| k.as_str() != "type" && k.as_str() != "targetClass")
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
out.insert(
name.clone(),
FieldChange::Set {
field_type,
options,
},
);
}
Ok(out)
}
pub fn parse_field_type(spec: &ParseMap) -> Result<FieldType, ParseError> {
let type_name = match spec.get("type") {
Some(ParseValue::String(s)) => Some(s.as_str()),
_ => None,
};
if matches!(type_name, Some("Pointer") | Some("Relation")) {
let name = type_name.unwrap_or_default();
let target = match spec.get("targetClass") {
Some(ParseValue::String(t)) if !t.is_empty() => t.clone(),
Some(ParseValue::String(_)) | None | Some(ParseValue::Null) => {
return Err(ParseError::new(
NEEDS_CLASS_NAME_CODE,
format!("type {name} needs a class name"),
))
}
Some(other) if is_falsy_non_string(other) => {
return Err(ParseError::new(
NEEDS_CLASS_NAME_CODE,
format!("type {name} needs a class name"),
))
}
Some(_) => return Err(ParseError::invalid_json("invalid JSON".to_string())),
};
if !class_name_is_valid(&target) {
return Err(ParseError::new(
ErrorCode::InvalidClassName,
invalid_class_name_message(&target),
));
}
return Ok(if name == "Pointer" {
FieldType::Pointer {
target_class: target,
}
} else {
FieldType::Relation {
target_class: target,
}
});
}
let Some(type_name) = type_name else {
return Err(ParseError::invalid_json("invalid JSON".to_string()));
};
Ok(match type_name {
"Number" => FieldType::Number,
"String" => FieldType::String,
"Boolean" => FieldType::Boolean,
"Date" => FieldType::Date,
"Object" => FieldType::Object,
"Array" => FieldType::Array,
"GeoPoint" => FieldType::GeoPoint,
"File" => FieldType::File,
"Bytes" => FieldType::Bytes,
"Polygon" => FieldType::Polygon,
other => {
return Err(ParseError::incorrect_type(format!(
"invalid field type: {other}"
)))
}
})
}
fn check_new_field(
class_name: &str,
field_name: &str,
field_type: &FieldType,
options: &ParseMap,
) -> Result<(), ParseError> {
if !field_name_is_valid(field_name, class_name) {
return Err(ParseError::invalid_key_name(format!(
"invalid field name: {field_name}"
)));
}
if !field_name_is_valid_for_class(field_name, class_name) {
return Err(ParseError::new(
FIELD_CANNOT_BE_ADDED_CODE,
format!("field {field_name} cannot be added"),
));
}
check_field_options(class_name, field_name, field_type, options)
}
fn check_field_options(
class_name: &str,
field_name: &str,
field_type: &FieldType,
options: &ParseMap,
) -> Result<(), ParseError> {
if let Some(default_value) = options.get("defaultValue") {
let inferred = infer_undecoded_type(class_name, field_name, default_value)?;
if field_type.is_relation() && inferred.as_ref().is_some_and(Inferred::is_parametric) {
return Err(ParseError::incorrect_type(format!(
"The 'default value' option is not applicable for {}",
field_type.to_wire_string()
)));
}
return check_default_value_type(class_name, field_name, field_type, options);
} else if matches!(options.get("required"), Some(ParseValue::Bool(true)))
&& field_type.is_relation()
{
return Err(ParseError::incorrect_type(format!(
"The 'required' option is not applicable for {}",
field_type.to_wire_string()
)));
}
Ok(())
}
#[derive(Debug)]
enum TargetClass {
Comparable(String),
NeverEqual(String),
}
#[derive(Debug, PartialEq, Eq)]
enum Inferred {
Type(FieldType),
NeverEqual {
rendered: String,
},
}
impl Inferred {
fn is_parametric(&self) -> bool {
match self {
Inferred::Type(t) => t.target_class().is_some(),
Inferred::NeverEqual { .. } => true,
}
}
}
fn infer_undecoded_type(
class_name: &str,
field_name: &str,
value: &ParseValue,
) -> Result<Option<Inferred>, ParseError> {
let ParseValue::Object(map) = value else {
return Ok(infer_type(value).map(Inferred::Type));
};
let Some(tag_value) = map.get("__type").filter(|v| js_number::is_truthy(v)) else {
return Ok(infer_type(value).map(Inferred::Type));
};
let tag = js_number::to_ecma_display(tag_value);
let truthy = |key: &str| map.get(key).is_some_and(js_number::is_truthy);
let not_null = |key: &str| matches!(map.get(key), Some(v) if !matches!(v, ParseValue::Null));
let target = |key: &str| match map.get(key) {
Some(v) if js_number::is_truthy(v) => Some(match v {
ParseValue::String(s) => TargetClass::Comparable(s.clone()),
other => TargetClass::NeverEqual(js_number::to_ecma_display(other)),
}),
_ => None,
};
let parametric = |tag: &str, target: Option<TargetClass>| {
target.map(|t| match t {
TargetClass::Comparable(target_class) => Inferred::Type(match tag {
"Pointer" => FieldType::Pointer { target_class },
_ => FieldType::Relation { target_class },
}),
TargetClass::NeverEqual(rendered) => Inferred::NeverEqual {
rendered: format!("{tag}<{rendered}>"),
},
})
};
let inferred = match tag_value {
ParseValue::String(t) => match t.as_str() {
"Pointer" => parametric("Pointer", target("className")),
"Relation" => parametric("Relation", target("className")),
"File" => truthy("name").then_some(Inferred::Type(FieldType::File)),
"Date" => truthy("iso").then_some(Inferred::Type(FieldType::Date)),
"GeoPoint" => (not_null("latitude") && not_null("longitude"))
.then_some(Inferred::Type(FieldType::GeoPoint)),
"Bytes" => truthy("base64").then_some(Inferred::Type(FieldType::Bytes)),
"Polygon" => truthy("coordinates").then_some(Inferred::Type(FieldType::Polygon)),
_ => None,
},
_ => None,
};
match inferred {
Some(inferred) => Ok(Some(inferred)),
None => {
let _ = (class_name, field_name);
Err(ParseError::incorrect_type(format!(
"This is not a valid {tag}"
)))
}
}
}
pub fn check_default_value_type(
class_name: &str,
field_name: &str,
field_type: &FieldType,
options: &ParseMap,
) -> Result<(), ParseError> {
let Some(default_value) = options.get("defaultValue") else {
return Ok(());
};
{
let inferred = infer_undecoded_type(class_name, field_name, default_value)?;
let (matches, got) = match &inferred {
Some(Inferred::Type(t)) => (t == field_type, t.to_wire_string()),
Some(Inferred::NeverEqual { rendered }) => (false, rendered.clone()),
None => (false, "undefined".to_string()),
};
if !matches {
return Err(ParseError::incorrect_type(format!(
"schema mismatch for {class_name}.{field_name} default value; expected {} but got \
{got}",
field_type.to_wire_string()
)));
}
}
Ok(())
}
fn check_one_geopoint(schema: &ClassSchema) -> Result<(), ParseError> {
let mut geo = schema
.fields
.iter()
.filter(|(_, ty)| **ty == FieldType::GeoPoint)
.map(|(name, _)| name.as_str());
let (Some(first), Some(second)) = (geo.next(), geo.next()) else {
return Ok(());
};
Err(ParseError::incorrect_type(format!(
"currently, only one GeoPoint field may exist in an object. Adding {second} when {first} \
already exists."
)))
}
fn is_falsy_non_string(value: &ParseValue) -> bool {
match value {
ParseValue::Null => true,
ParseValue::Bool(b) => !*b,
ParseValue::Number(n) => *n == 0.0 || n.is_nan(),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clp_validate::{ObjectIdForm, Unenforceable};
use parse_rust_core::classify;
fn opts() -> ClpValidation {
ClpValidation {
object_id: ObjectIdForm::Generated,
unenforceable: Unenforceable::Accept,
}
}
fn map(json: &str) -> ParseMap {
match classify(serde_json::from_str(json).expect("test literal must be valid JSON"))
.expect("classify")
{
ParseValue::Object(m) => m,
other => panic!("expected an object, got {other:?}"),
}
}
fn spec(json: &str) -> FieldType {
parse_field_type(&map(json)).expect("valid type")
}
fn spec_err(json: &str) -> ParseError {
parse_field_type(&map(json)).expect_err("invalid type")
}
#[test]
fn undecoded_type_inference_follows_javascript_truthiness() {
let raw = |json: &str| {
parse_rust_core::classify_raw(
serde_json::from_str(json).expect("test literal must be valid JSON"),
)
.expect("raw decode")
};
let infer = |json: &str| infer_undecoded_type("C", "f", &raw(json));
for (json, tag) in [
(r#"{"__type":"Date","iso":""}"#, "Date"),
(r#"{"__type":"Bytes","base64":""}"#, "Bytes"),
(r#"{"__type":"File","name":""}"#, "File"),
(r#"{"__type":"Pointer","className":""}"#, "Pointer"),
] {
let err = infer(json).expect_err("a falsy payload fails the guard");
assert_eq!(err.message, format!("This is not a valid {tag}"), "{json}");
}
assert_eq!(
infer(r#"{"__type":"GeoPoint","latitude":0,"longitude":0}"#).expect("null island"),
Some(Inferred::Type(FieldType::GeoPoint))
);
assert_eq!(
infer(r#"{"__type":"Polygon","coordinates":[]}"#).expect("truthy"),
Some(Inferred::Type(FieldType::Polygon))
);
assert_eq!(
infer(r#"{"__type":"","a":1}"#).expect("a falsy tag is not a tag"),
Some(Inferred::Type(FieldType::Object))
);
for (json, rendered) in [
(r#"{"__type":7}"#, "7"),
(r#"{"__type":true}"#, "true"),
(r#"{"__type":{"a":1}}"#, "[object Object]"),
(r#"{"__type":[1,2]}"#, "1,2"),
] {
let err = infer(json).expect_err("a truthy tag that matches no case throws");
assert_eq!(
err.message,
format!("This is not a valid {rendered}"),
"{json}"
);
}
for json in [
r#"{"__type":0}"#,
r#"{"__type":false}"#,
r#"{"__type":null}"#,
] {
assert_eq!(
infer(json).expect("falsy tags are not tags"),
Some(Inferred::Type(FieldType::Object)),
"{json}"
);
}
}
#[test]
fn every_non_parametric_type_parses() {
for (name, expected) in [
("Number", FieldType::Number),
("String", FieldType::String),
("Boolean", FieldType::Boolean),
("Date", FieldType::Date),
("Object", FieldType::Object),
("Array", FieldType::Array),
("GeoPoint", FieldType::GeoPoint),
("File", FieldType::File),
("Bytes", FieldType::Bytes),
("Polygon", FieldType::Polygon),
] {
assert_eq!(spec(&format!(r#"{{"type":"{name}"}}"#)), expected);
}
}
#[test]
fn acl_is_a_column_every_class_has_and_a_type_no_client_may_name() {
let err = parse_fields(&map(r#"{"acl":{"type":"ACL"}}"#)).expect_err("ACL is not a type");
assert_eq!(err.code, parse_rust_core::ErrorCode::IncorrectType);
assert_eq!(err.message, "invalid field type: ACL");
assert!(crate::storage_format::field_type_to_storage(&FieldType::Acl).is_empty());
}
#[test]
fn an_unrecognised_type_names_itself() {
let e = spec_err(r#"{"type":"Vector"}"#);
assert_eq!(e.message, "invalid field type: Vector");
assert_eq!(e.code, ErrorCode::IncorrectType);
}
#[test]
fn parametric_types_need_a_target_class() {
for name in ["Pointer", "Relation"] {
let e = spec_err(&format!(r#"{{"type":"{name}"}}"#));
assert_eq!(e.message, format!("type {name} needs a class name"));
let e = spec_err(&format!(r#"{{"type":"{name}","targetClass":""}}"#));
assert_eq!(e.message, format!("type {name} needs a class name"));
}
let e = spec_err(r#"{"type":"Pointer","targetClass":7}"#);
assert_eq!(e.message, "invalid JSON");
assert_eq!(e.code, ErrorCode::InvalidJson);
let e = spec_err(r#"{"type":"Pointer","targetClass":"1Bad"}"#);
assert_eq!(e.code, ErrorCode::InvalidClassName);
assert!(e.message.starts_with("Invalid classname: 1Bad,"));
}
#[test]
fn the_invalid_class_name_message_keeps_its_trailing_space() {
let m = invalid_class_name_message("1Bad");
assert_eq!(
m,
"Invalid classname: 1Bad, classnames can only have alphanumeric characters and _, and \
must start with an alpha character "
);
assert!(m.ends_with(' '));
}
#[test]
fn a_new_class_carries_its_default_columns() {
let s = validate_new_class("Post", &map(r#"{"title":{"type":"String"}}"#), None, opts())
.expect("valid");
assert_eq!(s.field("title"), Some(&FieldType::String));
for name in ["objectId", "createdAt", "updatedAt", "ACL"] {
assert!(s.field(name).is_some(), "{name} missing");
}
}
#[test]
fn role_and_session_get_their_own_default_columns() {
let role = validate_new_class("_Role", &ParseMap::new(), None, opts()).expect("valid");
assert_eq!(role.field("name"), Some(&FieldType::String));
assert_eq!(
role.field("users"),
Some(&FieldType::Relation {
target_class: "_User".into()
})
);
assert_eq!(
role.field("roles"),
Some(&FieldType::Relation {
target_class: "_Role".into()
})
);
let session =
validate_new_class("_Session", &ParseMap::new(), None, opts()).expect("valid");
assert_eq!(
session.field("user"),
Some(&FieldType::Pointer {
target_class: "_User".into()
})
);
assert_eq!(session.field("sessionToken"), Some(&FieldType::String));
assert_eq!(session.field("expiresAt"), Some(&FieldType::Date));
assert_eq!(session.field("createdWith"), Some(&FieldType::Object));
assert_eq!(session.field("installationId"), Some(&FieldType::String));
}
#[test]
fn a_default_column_cannot_be_redeclared() {
let e = validate_new_class(
"Post",
&map(r#"{"objectId":{"type":"String"}}"#),
None,
opts(),
)
.expect_err("refused");
assert_eq!(e.message, "field objectId cannot be added");
let e = validate_new_class("_Role", &map(r#"{"name":{"type":"String"}}"#), None, opts())
.expect_err("refused");
assert_eq!(e.message, "field name cannot be added");
}
#[test]
fn a_malformed_field_name_uses_the_lowercase_message() {
let e = validate_new_class("Post", &map(r#"{"1bad":{"type":"String"}}"#), None, opts())
.expect_err("refused");
assert_eq!(e.message, "invalid field name: 1bad");
assert_eq!(e.code, ErrorCode::InvalidKeyName);
let e = validate_new_class(
"Post",
&map(r#"{"length":{"type":"String"}}"#),
None,
opts(),
)
.expect_err("refused");
assert_eq!(e.message, "invalid field name: length");
}
#[test]
fn at_most_one_geopoint_per_class() {
let e = validate_new_class(
"Post",
&map(r#"{"here":{"type":"GeoPoint"},"there":{"type":"GeoPoint"}}"#),
None,
opts(),
)
.expect_err("refused");
assert_eq!(
e.message,
"currently, only one GeoPoint field may exist in an object. Adding there when here \
already exists."
);
assert_eq!(e.code, ErrorCode::IncorrectType);
assert!(validate_new_class(
"Post",
&map(r#"{"here":{"type":"GeoPoint"}}"#),
None,
opts()
)
.is_ok());
}
#[test]
fn default_value_must_match_the_declared_type() {
let e = validate_new_class(
"Post",
&map(r#"{"views":{"type":"Number","defaultValue":"none"}}"#),
None,
opts(),
)
.expect_err("refused");
assert_eq!(
e.message,
"schema mismatch for Post.views default value; expected Number but got String"
);
let s = validate_new_class(
"Post",
&map(r#"{"views":{"type":"Number","defaultValue":0}}"#),
None,
opts(),
)
.expect("valid");
let stored = s.field_options.expect("options stored");
assert!(stored.contains_key("views"));
}
#[test]
fn required_and_default_value_are_not_applicable_to_a_relation() {
let e = validate_new_class(
"Post",
&map(r#"{"tags":{"type":"Relation","targetClass":"Tag","required":true}}"#),
None,
opts(),
)
.expect_err("refused");
assert_eq!(
e.message,
"The 'required' option is not applicable for Relation<Tag>"
);
let e = validate_new_class(
"Post",
&map(r#"{"tags":{"type":"Relation","targetClass":"Tag",
"defaultValue":{"__type":"Pointer","className":"Tag","objectId":"a"}}}"#),
None,
opts(),
)
.expect_err("refused");
assert_eq!(
e.message,
"The 'default value' option is not applicable for Relation<Tag>"
);
}
#[test]
fn field_options_round_trip_every_key_but_type_and_target_class() {
let s = validate_new_class(
"Post",
&map(r#"{"author":{"type":"Pointer","targetClass":"_User",
"required":true,"somethingNew":42}}"#),
None,
opts(),
)
.expect("valid");
let stored = s.field_options.expect("options stored");
let ParseValue::Object(author) = stored.get("author").expect("author") else {
panic!("expected an object");
};
assert_eq!(author.len(), 2);
assert!(author.contains_key("required"));
assert!(author.contains_key("somethingNew"));
assert!(!author.contains_key("type"));
assert!(!author.contains_key("targetClass"));
}
fn post() -> ClassSchema {
crate::controller::default_schema("Post")
.with_field("title", FieldType::String)
.with_field(
"tags",
FieldType::Relation {
target_class: "Tag".into(),
},
)
}
#[test]
fn an_update_plans_adds_and_deletes() {
let plan = plan_update(
&post(),
&map(r#"{"body":{"type":"String"},"tags":{"__op":"Delete"}}"#),
None,
opts(),
)
.expect("valid");
assert!(matches!(plan.set_fields.as_slice(), [f] if f.name == "body"
&& f.field_type == FieldType::String
&& f.is_new));
assert_eq!(plan.deleted, vec!["tags".to_string()]);
assert!(
plan.clp.is_none(),
"an absent CLP must not become an empty one"
);
}
#[test]
fn retyping_an_existing_field_is_refused_and_nothing_else_in_the_body_applies() {
let e = plan_update(
&post(),
&map(r#"{"body":{"type":"String"},"title":{"type":"Number"}}"#),
None,
opts(),
)
.expect_err("refused");
assert_eq!(e.message, "Field title exists, cannot update.");
assert_eq!(e.code, ErrorCode::InvalidSchemaOperation);
assert_eq!(e.code.as_i32(), 255);
}
#[test]
fn deleting_a_field_that_does_not_exist_is_refused() {
let e = plan_update(
&post(),
&map(r#"{"ghost":{"__op":"Delete"}}"#),
None,
opts(),
)
.expect_err("refused");
assert_eq!(e.message, "Field ghost does not exist, cannot delete.");
assert_eq!(e.code.as_i32(), 255);
}
#[test]
fn deleting_a_default_column_has_its_own_message() {
let e = plan_update(
&post(),
&map(r#"{"objectId":{"__op":"Delete"}}"#),
None,
opts(),
)
.expect_err("refused");
assert_eq!(e.message, "field objectId cannot be changed");
let e = validate_new_class(
"Post",
&map(r#"{"objectId":{"type":"String"}}"#),
None,
opts(),
)
.expect_err("refused");
assert_eq!(e.message, "field objectId cannot be added");
}
#[test]
fn the_clp_is_validated_before_the_delete_name_check() {
let e = plan_update(
&post(),
&map(r#"{"objectId":{"__op":"Delete"}}"#),
Some(map(r#"{"nope":{}}"#)),
opts(),
)
.expect_err("refused");
assert_eq!(
e.message,
"nope is not a valid operation for class level permissions"
);
}
#[test]
fn resubmitting_a_field_with_different_options_is_not_a_conflict() {
let plan = plan_update(
&post(),
&map(r#"{"title":{"type":"String","required":true}}"#),
None,
opts(),
)
.expect("valid");
assert!(
matches!(plan.set_fields.as_slice(), [f] if f.name == "title"
&& !f.is_new
&& f.options.contains_key("required")),
"an existing field is still submitted, so its options can be written"
);
}
#[test]
fn resubmitting_a_field_with_no_options_carries_an_empty_option_set() {
let plan = plan_update(
&post(),
&map(r#"{"title":{"type":"String"}}"#),
None,
opts(),
)
.expect("valid");
assert!(
matches!(plan.set_fields.as_slice(), [f] if f.name == "title"
&& !f.is_new
&& f.options.is_empty())
);
}
#[test]
fn an_existing_fields_default_value_is_still_type_checked() {
let e = check_default_value_type(
"Post",
"title",
&FieldType::String,
&map(r#"{"defaultValue":10}"#),
)
.unwrap_err();
assert_eq!(e.code, ErrorCode::IncorrectType);
assert_eq!(
e.message,
"schema mismatch for Post.title default value; expected String but got Number"
);
assert!(check_default_value_type(
"Post",
"title",
&FieldType::String,
&map(r#"{"defaultValue":"ok","required":true}"#)
)
.is_ok());
}
#[test]
fn the_clp_is_validated_against_the_merged_schema() {
let plan = plan_update(
&post(),
&map(r#"{"secret":{"type":"String"}}"#),
Some(map(r#"{"protectedFields":{"*":["secret"]}}"#)),
opts(),
)
.expect("valid");
assert!(plan.clp.is_some());
let e = plan_update(
&post(),
&map(r#"{"title":{"__op":"Delete"}}"#),
Some(map(r#"{"protectedFields":{"*":["title"]}}"#)),
opts(),
)
.expect_err("refused");
assert_eq!(
e.message,
"Field 'title' in protectedFields:* does not exist"
);
}
#[test]
fn an_empty_clp_block_is_not_an_absent_one() {
let plan =
plan_update(&post(), &ParseMap::new(), Some(ParseMap::new()), opts()).expect("valid");
let clp = plan.clp.expect("present");
assert!(clp.raw().is_empty());
assert!(plan_update(&post(), &ParseMap::new(), None, opts())
.expect("valid")
.clp
.is_none());
}
#[test]
fn a_field_spec_that_is_not_an_object_is_invalid_json() {
let e = validate_new_class("Post", &map(r#"{"title":"String"}"#), None, opts())
.expect_err("refused");
assert_eq!(e.message, "invalid JSON");
}
#[test]
fn system_and_volatile_classes_are_two_different_lists() {
use crate::infer::{SYSTEM_CLASSES, VOLATILE_CLASSES};
for name in ["_Hooks", "_GlobalConfig", "_GraphQLConfig"] {
assert!(VOLATILE_CLASSES.contains(&name), "{name} is volatile");
assert!(
!SYSTEM_CLASSES.contains(&name),
"{name} is not a system class"
);
assert!(!class_name_is_valid(name), "{name} must not be addressable");
}
for name in ["_User", "_Installation", "_Role", "_Session", "_Product"] {
assert!(SYSTEM_CLASSES.contains(&name));
assert!(!VOLATILE_CLASSES.contains(&name));
}
for name in [
"_JobStatus",
"_PushStatus",
"_JobSchedule",
"_Audience",
"_Idempotency",
] {
assert!(SYSTEM_CLASSES.contains(&name), "{name} is a system class");
assert!(VOLATILE_CLASSES.contains(&name), "{name} is volatile");
}
}
}