use parse_rust_core::{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 SYSTEM_CLASSES: [&str; 8] = [
"_User",
"_Installation",
"_Role",
"_Session",
"_Product",
"_PushStatus",
"_JobStatus",
"_JobSchedule",
];
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(),
}),
}
}
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();
}
matches_class_and_field_regex(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)
}
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;
}
!DEFAULT_COLUMNS.iter().any(|(name, _)| *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 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);
}
}