use indexmap::IndexMap;
use parse_rust_core::{FieldWrite, ParseError, ParseMap, ParseValue};
use parse_rust_storage::{ClassSchema, FieldType};
use crate::infer::{
class_name_is_valid, default_columns_for, field_name_is_valid_for_class, infer_op_type,
infer_type, invalid_class_name_message, required_write_columns, schema_mismatch,
DEFAULT_COLUMNS,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchemaDelta {
pub added: Vec<(String, FieldType)>,
}
impl SchemaDelta {
pub fn is_empty(&self) -> bool {
self.added.is_empty()
}
}
pub fn default_schema(class_name: &str) -> ClassSchema {
let mut schema = ClassSchema::new(class_name);
for (name, ty) in 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);
}
schema
}
pub fn validate_write(schema: &ClassSchema, object: &ParseMap) -> Result<SchemaDelta, ParseError> {
if !class_name_is_valid(&schema.class_name) {
return Err(ParseError::new(
parse_rust_core::ErrorCode::InvalidClassName,
invalid_class_name_message(&schema.class_name),
));
}
let mut added = Vec::new();
for (field_name, value) in object {
if field_name.starts_with('_') {
continue;
}
if field_name == "ACL" {
match value {
ParseValue::Object(_) | ParseValue::Null => continue,
other => {
return Err(schema_mismatch(
&schema.class_name,
field_name,
&FieldType::Acl,
&infer_type(other).unwrap_or(FieldType::Object),
))
}
}
}
let Some(incoming) = infer_type(value) else {
continue;
};
reconcile(schema, field_name, incoming, &mut added)?;
}
Ok(SchemaDelta { added })
}
pub fn validate_write_fields(
schema: &ClassSchema,
fields: &IndexMap<String, FieldWrite>,
) -> Result<SchemaDelta, ParseError> {
if !class_name_is_valid(&schema.class_name) {
return Err(ParseError::new(
parse_rust_core::ErrorCode::InvalidClassName,
invalid_class_name_message(&schema.class_name),
));
}
let mut geo_count = 0;
for (field_name, write) in fields {
if let FieldWrite::Value(value) = write {
if matches!(infer_type(value), Some(FieldType::GeoPoint)) {
geo_count += 1;
}
}
if geo_count > 1 {
let _ = field_name;
return Err(ParseError::incorrect_type(
"there can only be one geopoint field in a class".to_string(),
));
}
}
let mut added = Vec::new();
for (field_name, write) in fields {
if field_name.starts_with('_') {
continue;
}
if field_name == "ACL" {
continue;
}
let incoming = match write {
FieldWrite::Value(value) => infer_type(value),
FieldWrite::Op(op) => infer_op_type(op)?,
};
let Some(incoming) = incoming else {
continue;
};
reconcile(schema, field_name, incoming, &mut added)?;
}
Ok(SchemaDelta { added })
}
fn reconcile(
schema: &ClassSchema,
field_name: &str,
incoming: FieldType,
added: &mut Vec<(String, FieldType)>,
) -> Result<(), ParseError> {
if let Some(existing) = schema.field(field_name) {
if existing != &incoming {
return Err(schema_mismatch(
&schema.class_name,
field_name,
existing,
&incoming,
));
}
return Ok(());
}
if !field_name_is_valid_for_class(field_name, &schema.class_name) {
return Err(ParseError::invalid_key_name(format!(
"Invalid field name: {field_name}."
)));
}
added.push((field_name.to_string(), incoming));
Ok(())
}
pub fn validate_required_columns(
class_name: &str,
object: &ParseMap,
is_update: bool,
) -> Result<(), ParseError> {
for column in required_write_columns(class_name) {
let missing = match object.get(*column) {
None => !is_update,
Some(value) => {
if is_update {
is_delete_op(value)
} else {
is_falsy(value)
}
}
};
if missing {
return Err(ParseError::incorrect_type(format!("{column} is required.")));
}
}
Ok(())
}
fn is_falsy(value: &ParseValue) -> bool {
match value {
ParseValue::Null => true,
ParseValue::Bool(b) => !*b,
ParseValue::Number(n) => *n == 0.0 || n.is_nan(),
ParseValue::String(s) => s.is_empty(),
_ => false,
}
}
fn is_delete_op(value: &ParseValue) -> bool {
match value {
ParseValue::Object(map) => {
matches!(map.get("__op"), Some(ParseValue::String(op)) if op == "Delete")
}
_ => false,
}
}
pub fn apply(schema: &mut ClassSchema, delta: &SchemaDelta) {
for (name, ty) in &delta.added {
schema.fields.insert(name.clone(), ty.clone());
}
}
pub fn value_matches(ty: &FieldType, value: &ParseValue) -> bool {
match infer_type(value) {
None => true,
Some(t) => &t == ty,
}
}
#[cfg(test)]
mod tests {
use super::*;
use parse_rust_core::ParseDate;
fn m(pairs: Vec<(&str, ParseValue)>) -> ParseMap {
let mut map = ParseMap::new();
for (k, v) in pairs {
map.insert(k.to_string(), v);
}
map
}
#[test]
fn a_new_class_starts_with_the_four_default_columns() {
let s = default_schema("Post");
assert_eq!(s.fields.len(), 4);
for (name, _) in DEFAULT_COLUMNS {
assert!(s.field(name).is_some(), "{name} missing");
}
}
#[test]
fn the_first_write_infers_and_adds() {
let s = default_schema("Post");
let delta = validate_write(
&s,
&m(vec![
("title", ParseValue::String("x".into())),
("views", ParseValue::Number(1.0)),
]),
)
.expect("validate");
assert_eq!(
delta.added,
vec![
("title".to_string(), FieldType::String),
("views".to_string(), FieldType::Number),
],
"order of addition follows the object's key order"
);
}
#[test]
fn the_second_write_is_enforced_against_the_first() {
let mut s = default_schema("Post");
let delta = validate_write(&s, &m(vec![("title", ParseValue::String("x".into()))]))
.expect("first write");
apply(&mut s, &delta);
let again = validate_write(&s, &m(vec![("title", ParseValue::String("y".into()))]))
.expect("second write");
assert!(again.is_empty());
let err = validate_write(&s, &m(vec![("title", ParseValue::Number(1.0))])).unwrap_err();
assert_eq!(
err.message,
"schema mismatch for Post.title; expected String but got Number"
);
}
#[test]
fn pointer_target_class_is_part_of_the_type() {
let mut s = default_schema("Post");
let delta = validate_write(
&s,
&m(vec![(
"author",
ParseValue::Pointer {
class_name: "_User".into(),
object_id: "a".into(),
},
)]),
)
.expect("first");
apply(&mut s, &delta);
let err = validate_write(
&s,
&m(vec![(
"author",
ParseValue::Pointer {
class_name: "Admin".into(),
object_id: "a".into(),
},
)]),
)
.unwrap_err();
assert_eq!(
err.message,
"schema mismatch for Post.author; expected Pointer<_User> but got Pointer<Admin>"
);
}
#[test]
fn user_columns_are_typed_rather_than_inferred() {
let s = default_schema("_User");
assert_eq!(s.field("email"), Some(&FieldType::String));
assert_eq!(s.field("emailVerified"), Some(&FieldType::Boolean));
let err = validate_write(&s, &m(vec![("email", ParseValue::Number(42.0))])).unwrap_err();
assert!(
err.message.contains("expected String but got Number"),
"{}",
err.message
);
}
#[test]
fn an_acl_object_is_accepted_and_adds_no_column() {
let s = default_schema("Post");
let mut acl = ParseMap::new();
let mut entry = ParseMap::new();
entry.insert("read".into(), ParseValue::Bool(true));
acl.insert("*".into(), ParseValue::Object(entry));
let delta =
validate_write(&s, &m(vec![("ACL", ParseValue::Object(acl))])).expect("validate");
assert!(delta.is_empty(), "ACL is a default column, not a new field");
assert!(validate_write(&s, &m(vec![("ACL", ParseValue::Null)])).is_ok());
let err =
validate_write(&s, &m(vec![("ACL", ParseValue::String("nope".into()))])).unwrap_err();
assert!(
err.message.contains("expected ACL but got String"),
"{}",
err.message
);
}
#[test]
fn writing_null_creates_nothing() {
let s = default_schema("Post");
let delta = validate_write(&s, &m(vec![("ghost", ParseValue::Null)])).expect("validate");
assert!(
delta.is_empty(),
"a null must not create a column, or every optional field becomes a schema entry"
);
}
#[test]
fn null_is_assignable_to_an_existing_field_of_any_type() {
let mut s = default_schema("Post");
apply(
&mut s,
&SchemaDelta {
added: vec![("title".into(), FieldType::String)],
},
);
let delta = validate_write(&s, &m(vec![("title", ParseValue::Null)])).expect("validate");
assert!(delta.is_empty());
assert!(value_matches(&FieldType::String, &ParseValue::Null));
}
#[test]
fn default_columns_are_writable_but_not_redefinable() {
let s = default_schema("Post");
let ok = validate_write(
&s,
&m(vec![(
"createdAt",
ParseValue::Date(ParseDate::parse_iso("2026-01-01T00:00:00.000Z").expect("d")),
)]),
)
.expect("validate");
assert!(ok.is_empty());
let err = validate_write(&s, &m(vec![("createdAt", ParseValue::Number(1.0))])).unwrap_err();
assert!(err.message.contains("expected Date but got Number"));
}
#[test]
fn reserved_and_malformed_field_names_are_refused() {
let s = default_schema("Post");
for bad in ["className", "1field", "has-dash"] {
let err =
validate_write(&s, &m(vec![(bad, ParseValue::String("x".into()))])).unwrap_err();
assert_eq!(
err.code,
parse_rust_core::ErrorCode::InvalidKeyName,
"{bad} should be refused"
);
}
}
#[test]
fn an_invalid_class_name_is_refused_before_any_field() {
let s = ClassSchema::new("1Bad");
let err = validate_write(&s, &m(vec![("a", ParseValue::String("x".into()))])).unwrap_err();
assert_eq!(err.code, parse_rust_core::ErrorCode::InvalidClassName);
}
#[test]
fn server_internal_columns_are_not_schema_fields() {
let s = default_schema("_User");
let delta = validate_write(
&s,
&m(vec![
("username", ParseValue::String("alice".into())),
("_hashed_password", ParseValue::String("$2b$10$...".into())),
("_rperm", ParseValue::Array(vec![])),
("nickname", ParseValue::String("al".into())),
]),
)
.expect("validate");
assert_eq!(
delta.added,
vec![("nickname".to_string(), FieldType::String)],
"internal columns must not become schema fields"
);
}
#[test]
fn role_and_session_carry_their_class_specific_columns() {
let role = default_schema("_Role");
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 = default_schema("_Session");
assert_eq!(
session.field("user"),
Some(&FieldType::Pointer {
target_class: "_User".into()
})
);
for (name, ty) in [
("installationId", FieldType::String),
("sessionToken", FieldType::String),
("expiresAt", FieldType::Date),
("createdWith", FieldType::Object),
] {
assert_eq!(session.field(name), Some(&ty), "{name}");
}
}
#[test]
fn a_role_write_is_checked_against_the_typed_relation_columns() {
let s = default_schema("_Role");
let err = validate_write(&s, &m(vec![("users", ParseValue::Array(vec![]))])).unwrap_err();
assert_eq!(
err.message,
"schema mismatch for _Role.users; expected Relation<_User> but got Array"
);
}
#[test]
fn a_role_needs_a_name_and_an_acl_on_create() {
let err = validate_required_columns("_Role", &ParseMap::new(), false).unwrap_err();
assert_eq!(err.message, "name is required.");
assert_eq!(err.code, parse_rust_core::ErrorCode::IncorrectType);
let err = validate_required_columns(
"_Role",
&m(vec![("name", ParseValue::String("Admins".into()))]),
false,
)
.unwrap_err();
assert_eq!(err.message, "ACL is required.");
let ok = m(vec![
("name", ParseValue::String("Admins".into())),
("ACL", ParseValue::Object(ParseMap::new())),
]);
assert!(validate_required_columns("_Role", &ok, false).is_ok());
}
#[test]
fn a_falsy_required_column_counts_as_missing_on_create() {
for value in [
ParseValue::String(String::new()),
ParseValue::Null,
ParseValue::Bool(false),
ParseValue::Number(0.0),
] {
let body = m(vec![
("name", value),
("ACL", ParseValue::Object(ParseMap::new())),
]);
assert_eq!(
validate_required_columns("_Role", &body, false)
.unwrap_err()
.message,
"name is required."
);
}
}
#[test]
fn an_update_only_objects_to_deleting_a_required_column() {
let rename = m(vec![("name", ParseValue::String("Ops".into()))]);
assert!(validate_required_columns("_Role", &rename, true).is_ok());
let mut delete = ParseMap::new();
delete.insert("__op".into(), ParseValue::String("Delete".into()));
let body = m(vec![("ACL", ParseValue::Object(delete))]);
assert_eq!(
validate_required_columns("_Role", &body, true)
.unwrap_err()
.message,
"ACL is required."
);
}
#[test]
fn a_class_with_no_required_columns_never_fails() {
assert!(validate_required_columns("Post", &ParseMap::new(), false).is_ok());
assert!(validate_required_columns("_User", &ParseMap::new(), false).is_ok());
}
#[test]
fn ops_infer_their_own_types() {
use parse_rust_core::Op;
let s = default_schema("Post");
let mut fields: IndexMap<String, FieldWrite> = IndexMap::new();
fields.insert("views".into(), FieldWrite::Op(Op::Increment(1.0)));
fields.insert(
"tags".into(),
FieldWrite::Op(Op::Add(vec![ParseValue::String("x".into())])),
);
fields.insert("gone".into(), FieldWrite::Op(Op::Delete));
let delta = validate_write_fields(&s, &fields).expect("validate");
assert_eq!(
delta.added,
vec![
("views".to_string(), FieldType::Number),
("tags".to_string(), FieldType::Array),
],
"Delete has no type and must not create a column"
);
}
#[test]
fn relation_ops_infer_their_target_from_the_first_pointer() {
use parse_rust_core::Op;
let s = default_schema("Post");
let pointer = ParseValue::Pointer {
class_name: "_User".into(),
object_id: "abc".into(),
};
for op in [
Op::AddRelation(vec![pointer.clone()]),
Op::RemoveRelation(vec![pointer.clone()]),
Op::Batch(vec![Op::AddRelation(vec![pointer.clone()])]),
] {
let mut fields: IndexMap<String, FieldWrite> = IndexMap::new();
fields.insert("members".into(), FieldWrite::Op(op));
let delta = validate_write_fields(&s, &fields).expect("validate");
assert_eq!(
delta.added,
vec![(
"members".to_string(),
FieldType::Relation {
target_class: "_User".into()
}
)]
);
}
}
#[test]
fn a_relation_op_with_no_pointers_creates_nothing() {
use parse_rust_core::Op;
let s = default_schema("Post");
let mut fields: IndexMap<String, FieldWrite> = IndexMap::new();
fields.insert("members".into(), FieldWrite::Op(Op::AddRelation(vec![])));
fields.insert("other".into(), FieldWrite::Op(Op::Batch(vec![])));
assert!(validate_write_fields(&s, &fields)
.expect("validate")
.is_empty());
}
#[test]
fn the_op_aware_path_enforces_the_same_types_as_the_value_path() {
use parse_rust_core::Op;
let mut s = default_schema("Post");
apply(
&mut s,
&SchemaDelta {
added: vec![("title".into(), FieldType::String)],
},
);
let mut fields: IndexMap<String, FieldWrite> = IndexMap::new();
fields.insert("title".into(), FieldWrite::Op(Op::Increment(1.0)));
let err = validate_write_fields(&s, &fields).unwrap_err();
assert_eq!(
err.message,
"schema mismatch for Post.title; expected String but got Number"
);
}
#[test]
fn the_invalid_class_name_message_is_byte_exact() {
let s = ClassSchema::new("1Bad");
let err = validate_write(&s, &m(vec![("a", ParseValue::String("x".into()))])).unwrap_err();
assert_eq!(
err.message,
"Invalid classname: 1Bad, classnames can only have alphanumeric characters and _, and \
must start with an alpha character "
);
}
#[test]
fn validate_does_not_mutate_so_a_rejected_write_leaves_no_column() {
let s = default_schema("Post");
let before = s.fields.len();
let _ = validate_write(&s, &m(vec![("title", ParseValue::String("x".into()))]));
assert_eq!(
s.fields.len(),
before,
"validation must be pure; the caller applies only on commit"
);
}
}