use parse_rust_core::{ParseError, ParseMap, ParseValue};
use parse_rust_storage::{ClassSchema, FieldType};
use crate::infer::{
class_name_is_valid, field_name_is_valid_for_class, infer_type, 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);
}
if class_name == "_User" {
for (name, ty) in crate::infer::USER_COLUMNS {
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,
format!(
"Invalid classname: {}, classnames can only have alphanumeric characters and _, \
and must start with an alpha character",
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;
};
if let Some(existing) = schema.field(field_name) {
if existing != &incoming {
return Err(schema_mismatch(
&schema.class_name,
field_name,
existing,
&incoming,
));
}
continue;
}
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.clone(), incoming));
}
Ok(SchemaDelta { added })
}
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 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"
);
}
}