use parse_rust_core::{Op, ParseError, ParseValue};
use parse_rust_storage::FieldType;
pub const DEFAULT_COLUMNS: [(&str, FieldType); 4] = [
("objectId", FieldType::String),
("createdAt", FieldType::Date),
("updatedAt", FieldType::Date),
("ACL", FieldType::Acl),
];
pub const USER_COLUMNS: [(&str, FieldType); 5] = [
("username", FieldType::String),
("password", FieldType::String),
("email", FieldType::String),
("emailVerified", FieldType::Boolean),
("authData", FieldType::Object),
];
pub const INVALID_COLUMNS: [&str; 1] = ["length"];
pub const SYSTEM_CLASSES: [&str; 10] = [
"_User",
"_Installation",
"_Role",
"_Session",
"_Product",
"_PushStatus",
"_JobStatus",
"_JobSchedule",
"_Audience",
"_Idempotency",
];
pub const VOLATILE_CLASSES: [&str; 8] = [
"_JobStatus",
"_PushStatus",
"_Hooks",
"_GlobalConfig",
"_GraphQLConfig",
"_JobSchedule",
"_Audience",
"_Idempotency",
];
pub fn default_columns_for(class_name: &str) -> Vec<(&'static str, FieldType)> {
let pointer = |target: &str| FieldType::Pointer {
target_class: target.to_string(),
};
let relation = |target: &str| FieldType::Relation {
target_class: target.to_string(),
};
match class_name {
"_User" => USER_COLUMNS.iter().map(|(n, t)| (*n, t.clone())).collect(),
"_Role" => vec![
("name", FieldType::String),
("users", relation("_User")),
("roles", relation("_Role")),
],
"_Session" => vec![
("user", pointer("_User")),
("installationId", FieldType::String),
("sessionToken", FieldType::String),
("expiresAt", FieldType::Date),
("createdWith", FieldType::Object),
],
_ => Vec::new(),
}
}
pub fn is_default_column(class_name: &str, field_name: &str) -> bool {
DEFAULT_COLUMNS.iter().any(|(n, _)| *n == field_name)
|| default_columns_for(class_name)
.iter()
.any(|(n, _)| *n == field_name)
}
pub fn required_write_columns(class_name: &str) -> &'static [&'static str] {
match class_name {
"_Product" => &["productIdentifier", "icon", "order", "title", "subtitle"],
"_Role" => &["name", "ACL"],
_ => &[],
}
}
pub fn required_read_columns(class_name: &str) -> &'static [&'static str] {
match class_name {
"_User" => &["username"],
_ => &[],
}
}
pub fn invalid_class_name_message(class_name: &str) -> String {
format!(
"Invalid classname: {class_name}, classnames can only have alphanumeric characters and _, \
and must start with an alpha character "
)
}
pub fn infer_type(value: &ParseValue) -> Option<FieldType> {
match value {
ParseValue::Null => None,
ParseValue::Bool(_) => Some(FieldType::Boolean),
ParseValue::String(_) => Some(FieldType::String),
ParseValue::Number(_) => Some(FieldType::Number),
ParseValue::Array(_) => Some(FieldType::Array),
ParseValue::Object(_) => Some(FieldType::Object),
ParseValue::Date(_) => Some(FieldType::Date),
ParseValue::Bytes(_) => Some(FieldType::Bytes),
ParseValue::GeoPoint { .. } => Some(FieldType::GeoPoint),
ParseValue::Polygon(_) => Some(FieldType::Polygon),
ParseValue::File { .. } => Some(FieldType::File),
ParseValue::Pointer { class_name, .. } => Some(FieldType::Pointer {
target_class: class_name.clone(),
}),
ParseValue::Relation { class_name } => Some(FieldType::Relation {
target_class: class_name.clone(),
}),
}
}
pub fn infer_op_type(op: &Op) -> Result<Option<FieldType>, ParseError> {
Ok(match op {
Op::Increment(_) => Some(FieldType::Number),
Op::Delete => None,
Op::Add(_) | Op::AddUnique(_) | Op::Remove(_) => Some(FieldType::Array),
Op::AddRelation(objects) | Op::RemoveRelation(objects) => match objects.first() {
Some(ParseValue::Pointer { class_name, .. }) => Some(FieldType::Relation {
target_class: class_name.clone(),
}),
_ => None,
},
Op::Batch(ops) => match ops.first() {
Some(first) => infer_op_type(first)?,
None => None,
},
Op::SetOnInsert(_) => {
return Err(ParseError::internal(format!(
"unexpected op: {}",
op.name()
)))
}
})
}
fn matches_class_and_field_regex(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
pub fn class_name_is_valid(class_name: &str) -> bool {
if SYSTEM_CLASSES.contains(&class_name) {
return true;
}
if let Some(rest) = class_name.strip_prefix("_Join:") {
let mut parts = rest.splitn(2, ':');
let (a, b) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""));
let ok =
|s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
let b_prefix: String = b
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
return ok(a) && !b_prefix.is_empty();
}
field_name_is_valid(class_name, class_name)
}
pub fn field_name_is_valid(field_name: &str, class_name: &str) -> bool {
if !class_name.is_empty() && class_name != "_Hooks" && field_name == "className" {
return false;
}
matches_class_and_field_regex(field_name) && !INVALID_COLUMNS.contains(&field_name)
}
pub fn field_name_is_valid_for_class(field_name: &str, class_name: &str) -> bool {
if !field_name_is_valid(field_name, class_name) {
return false;
}
!is_default_column(class_name, field_name)
}
pub fn schema_mismatch(
class_name: &str,
field_name: &str,
expected: &FieldType,
got: &FieldType,
) -> ParseError {
ParseError::incorrect_type(format!(
"schema mismatch for {class_name}.{field_name}; expected {} but got {}",
expected.to_wire_string(),
got.to_wire_string()
))
}
#[cfg(test)]
mod tests {
use super::*;
use parse_rust_core::{ParseDate, ParseMap};
#[test]
fn null_infers_no_type_at_all() {
assert_eq!(infer_type(&ParseValue::Null), None);
}
#[test]
fn scalars_infer_as_upstream_does() {
assert_eq!(
infer_type(&ParseValue::Bool(true)),
Some(FieldType::Boolean)
);
assert_eq!(
infer_type(&ParseValue::String("x".into())),
Some(FieldType::String)
);
assert_eq!(
infer_type(&ParseValue::Number(1.0)),
Some(FieldType::Number)
);
assert_eq!(
infer_type(&ParseValue::Number(1.5)),
Some(FieldType::Number)
);
}
#[test]
fn containers_and_tagged_values() {
assert_eq!(
infer_type(&ParseValue::Array(vec![])),
Some(FieldType::Array)
);
assert_eq!(
infer_type(&ParseValue::Object(ParseMap::new())),
Some(FieldType::Object)
);
assert_eq!(
infer_type(&ParseValue::Date(
ParseDate::parse_iso("2026-01-01T00:00:00.000Z").expect("date")
)),
Some(FieldType::Date)
);
assert_eq!(
infer_type(&ParseValue::GeoPoint {
latitude: 1.0,
longitude: 2.0
}),
Some(FieldType::GeoPoint)
);
}
#[test]
fn pointers_and_relations_carry_their_target() {
assert_eq!(
infer_type(&ParseValue::Pointer {
class_name: "_User".into(),
object_id: "x".into()
}),
Some(FieldType::Pointer {
target_class: "_User".into()
})
);
assert_eq!(
infer_type(&ParseValue::Relation {
class_name: "Post".into()
}),
Some(FieldType::Relation {
target_class: "Post".into()
})
);
}
#[test]
fn class_names_follow_the_regex() {
assert!(class_name_is_valid("Post"));
assert!(class_name_is_valid("A1_b"));
assert!(!class_name_is_valid(""));
assert!(!class_name_is_valid("1Post"), "must not start with a digit");
assert!(!class_name_is_valid("_Custom"), "must not start with _");
assert!(!class_name_is_valid("has-dash"));
assert!(!class_name_is_valid("has space"));
}
#[test]
fn system_and_join_classes_are_valid_despite_the_underscore() {
for c in SYSTEM_CLASSES {
assert!(class_name_is_valid(c), "{c} should be valid");
}
assert!(class_name_is_valid("_Join:likes:Post"));
assert!(!class_name_is_valid("_Join:likes"), "needs both segments");
}
#[test]
fn class_name_is_refused_as_a_field_except_on_hooks() {
assert!(!field_name_is_valid("className", "Post"));
assert!(field_name_is_valid("className", "_Hooks"));
}
#[test]
fn default_columns_cannot_be_redefined() {
for (name, _) in DEFAULT_COLUMNS {
assert!(
!field_name_is_valid_for_class(name, "Post"),
"{name} is a default column"
);
}
assert!(field_name_is_valid_for_class("title", "Post"));
}
#[test]
fn length_is_refused_as_a_field_and_as_a_class() {
assert!(!field_name_is_valid("length", "Post"));
assert!(!class_name_is_valid("length"));
assert!(field_name_is_valid("width", "Post"));
}
#[test]
fn the_audience_and_idempotency_classes_are_system_classes() {
assert_eq!(SYSTEM_CLASSES.len(), 10);
assert!(class_name_is_valid("_Audience"));
assert!(class_name_is_valid("_Idempotency"));
}
#[test]
fn the_required_columns_table_is_upstreams() {
assert_eq!(required_read_columns("_User"), ["username"]);
assert_eq!(required_read_columns("Post"), [] as [&str; 0]);
assert_eq!(
required_write_columns("_Product"),
["productIdentifier", "icon", "order", "title", "subtitle"]
);
assert_eq!(required_write_columns("_Role"), ["name", "ACL"]);
assert_eq!(required_write_columns("_User"), [] as [&str; 0]);
}
#[test]
fn a_class_default_column_is_refused_for_that_class_only() {
assert!(!field_name_is_valid_for_class("name", "_Role"));
assert!(field_name_is_valid_for_class("name", "Post"));
assert!(!field_name_is_valid_for_class("sessionToken", "_Session"));
assert!(field_name_is_valid_for_class("sessionToken", "Post"));
}
#[test]
fn ops_infer_the_types_getobjecttype_gives_them() {
use parse_rust_core::Op;
assert_eq!(
infer_op_type(&Op::Increment(1.0)).expect("typed"),
Some(FieldType::Number)
);
assert_eq!(infer_op_type(&Op::Delete).expect("typed"), None);
for op in [Op::Add(vec![]), Op::AddUnique(vec![]), Op::Remove(vec![])] {
assert_eq!(infer_op_type(&op).expect("typed"), Some(FieldType::Array));
}
}
#[test]
fn set_on_insert_is_the_op_getobjecttype_refuses() {
use parse_rust_core::{ErrorCode, Op, ParseValue};
let e = infer_op_type(&Op::SetOnInsert(ParseValue::Number(1.0))).unwrap_err();
assert_eq!(e.code, ErrorCode::InternalServerError);
assert_eq!(e.message, "unexpected op: SetOnInsert");
}
#[test]
fn a_batch_recurses_into_its_first_op() {
use parse_rust_core::Op;
let op = Op::Batch(vec![Op::Increment(1.0), Op::Add(vec![])]);
assert_eq!(infer_op_type(&op).expect("typed"), Some(FieldType::Number));
}
#[test]
fn the_mismatch_message_is_byte_exact() {
let e = schema_mismatch(
"Post",
"author",
&FieldType::Pointer {
target_class: "_User".into(),
},
&FieldType::String,
);
assert_eq!(
e.message,
"schema mismatch for Post.author; expected Pointer<_User> but got String"
);
assert_eq!(e.code, parse_rust_core::ErrorCode::IncorrectType);
}
}