use bson::{Bson, Document};
use parse_rust_core::{recognize_atom, AtomPosition, ParseDate, ParseError, ParseMap, ParseValue};
use parse_rust_storage::{
ClassSchema, Clause, Comparison, Constraint, FieldType, Query, Update, UpdateValue,
};
pub fn storage_key(schema: &ClassSchema, field: &str) -> String {
match field {
"objectId" => return "_id".into(),
"createdAt" => return "_created_at".into(),
"updatedAt" => return "_updated_at".into(),
"sessionToken" => return "_session_token".into(),
"lastUsed" => return "_last_used".into(),
"timesUsed" => return "times_used".into(),
_ => {}
}
if schema.is_pointer_field(field) {
format!("_p_{field}")
} else {
field.to_string()
}
}
const INTERNAL_COLUMNS: [&str; 12] = [
"_rperm",
"_wperm",
"_hashed_password",
"_perishable_token",
"_perishable_token_expires_at",
"_email_verify_token",
"_email_verify_token_expires_at",
"_account_lockout_expires_at",
"_failed_login_count",
"_password_changed_at",
"_password_history",
"_tombstone",
];
fn untransform_key(field: &str) -> Option<String> {
match field {
"_id" => Some("objectId".into()),
"_created_at" => Some("createdAt".into()),
"_updated_at" => Some("updatedAt".into()),
"_session_token" => Some("sessionToken".into()),
"_expiresAt" => Some("expiresAt".into()),
"_last_used" => Some("lastUsed".into()),
"times_used" => Some("timesUsed".into()),
_ => {
if let Some(stripped) = field.strip_prefix("_p_") {
return Some(stripped.to_string());
}
if INTERNAL_COLUMNS.contains(&field) || field.starts_with("_auth_data_") {
return Some(field.to_string());
}
None
}
}
}
fn to_bson_number(n: f64) -> Bson {
if n.fract() == 0.0 && n >= i32::MIN as f64 && n <= i32::MAX as f64 {
Bson::Int32(n as i32)
} else {
Bson::Double(n)
}
}
fn interior_atom_core(value: &ParseValue) -> Result<Bson, ParseError> {
Ok(match value {
ParseValue::Date(d) => date_to_bson(d),
ParseValue::Bytes(b) => Bson::Binary(bson::Binary {
subtype: bson::spec::BinarySubtype::Generic,
bytes: b.clone(),
}),
ParseValue::Pointer {
class_name,
object_id,
} => {
let mut d = Document::new();
d.insert("__type", "Pointer");
d.insert("className", class_name.clone());
d.insert("objectId", object_id.clone());
Bson::Document(d)
}
ParseValue::GeoPoint { .. }
| ParseValue::Polygon(_)
| ParseValue::File { .. }
| ParseValue::Relation { .. } => raw_typed_value(value)?,
other => plain_value_to_bson(other)?,
})
}
fn interior_value_to_bson(value: &ParseValue) -> Result<Bson, ParseError> {
if let ParseValue::Object(map) = value {
if map.keys().any(|k| k.contains('$') || k.contains('.')) {
return Err(ParseError::new(
parse_rust_core::ErrorCode::InvalidNestedKey,
"Nested keys should not contain the '$' or '.' characters",
));
}
}
interior_atom_core(value)
}
fn interior_query_atom_to_bson(value: &ParseValue) -> Result<Bson, ParseError> {
if let ParseValue::Object(map) = value {
if let Some(pattern) = interior_regex(map) {
return Ok(Bson::RegularExpression(bson::Regex {
pattern,
options: String::new(),
}));
}
}
match value {
ParseValue::Object(_) | ParseValue::Array(_) => unchanged_atom_to_bson(value),
other => interior_atom_core(other),
}
}
fn unchanged_atom_to_bson(value: &ParseValue) -> Result<Bson, ParseError> {
Ok(match value {
ParseValue::Object(map) => {
let mut d = Document::new();
for (k, v) in map {
d.insert(k.clone(), unchanged_atom_to_bson(v)?);
}
Bson::Document(d)
}
ParseValue::Array(items) => Bson::Array(
items
.iter()
.map(unchanged_atom_to_bson)
.collect::<Result<Vec<_>, _>>()?,
),
ParseValue::Date(d) => {
let mut e = Document::new();
e.insert("__type", "Date");
e.insert("iso", d.to_iso());
Bson::Document(e)
}
ParseValue::Bytes(b) => {
let mut e = Document::new();
e.insert("__type", "Bytes");
e.insert("base64", parse_rust_core::base64_encode(b));
Bson::Document(e)
}
ParseValue::Pointer {
class_name,
object_id,
} => {
let mut e = Document::new();
e.insert("__type", "Pointer");
e.insert("className", class_name.clone());
e.insert("objectId", object_id.clone());
Bson::Document(e)
}
ParseValue::GeoPoint { .. }
| ParseValue::Polygon(_)
| ParseValue::File { .. }
| ParseValue::Relation { .. } => raw_typed_value(value)?,
other => plain_value_to_bson(other)?,
})
}
fn interior_regex(map: &parse_rust_core::ParseMap) -> Option<String> {
match map.get("$regex")? {
ParseValue::String(pattern) => Some(pattern.clone()),
ParseValue::Number(n) => Some(parse_rust_core::js_number::to_ecma_string(*n)),
ParseValue::Bool(b) => Some(b.to_string()),
ParseValue::Null => Some("null".to_string()),
other => Some(parse_rust_core::js_number::to_ecma_display(other)),
}
}
fn raw_typed_value(value: &ParseValue) -> Result<Bson, ParseError> {
let mut d = Document::new();
match value {
ParseValue::GeoPoint {
latitude,
longitude,
} => {
d.insert("__type", "GeoPoint");
d.insert("latitude", Bson::Double(*latitude));
d.insert("longitude", Bson::Double(*longitude));
}
ParseValue::Polygon(coords) => {
d.insert("__type", "Polygon");
d.insert(
"coordinates",
Bson::Array(
coords
.iter()
.map(|(lat, lng)| Bson::Array(vec![Bson::Double(*lat), Bson::Double(*lng)]))
.collect(),
),
);
}
ParseValue::File { name, url } => {
d.insert("__type", "File");
d.insert("name", name.clone());
if let Some(url) = url {
d.insert("url", url.clone());
}
}
ParseValue::Relation { class_name } => {
d.insert("__type", "Relation");
d.insert("className", class_name.clone());
}
other => return plain_value_to_bson(other),
}
Ok(Bson::Document(d))
}
fn js_join_element(value: &ParseValue) -> String {
match value {
ParseValue::String(s) => s.clone(),
ParseValue::Number(n) => parse_rust_core::js_number::to_ecma_string(*n),
ParseValue::Bool(b) => b.to_string(),
ParseValue::Null => String::new(),
_ => "[object Object]".to_string(),
}
}
fn date_to_bson(d: &ParseDate) -> Bson {
Bson::DateTime(bson::DateTime::from_millis(d.timestamp_millis()))
}
fn plain_value_to_bson(value: &ParseValue) -> Result<Bson, ParseError> {
Ok(match value {
ParseValue::Null => Bson::Null,
ParseValue::Bool(b) => Bson::Boolean(*b),
ParseValue::Number(n) => to_bson_number(*n),
ParseValue::String(s) => Bson::String(s.clone()),
ParseValue::Array(items) => Bson::Array(
items
.iter()
.map(interior_value_to_bson)
.collect::<Result<Vec<_>, _>>()?,
),
ParseValue::Object(map) => {
let mut d = Document::new();
for (k, v) in map {
d.insert(k.clone(), interior_value_to_bson(v)?);
}
Bson::Document(d)
}
ParseValue::Date(d) => date_to_bson(d),
ParseValue::Bytes(b) => Bson::Binary(bson::Binary {
subtype: bson::spec::BinarySubtype::Generic,
bytes: b.clone(),
}),
ParseValue::GeoPoint {
latitude,
longitude,
} => {
Bson::Array(vec![Bson::Double(*longitude), Bson::Double(*latitude)])
}
ParseValue::Pointer { .. } => {
return Err(ParseError::incorrect_type(
"a top-level Pointer is lowered by key, not by value".to_string(),
))
}
ParseValue::Polygon(coords) => {
let mut ring = coords.clone();
match (ring.first(), ring.last()) {
(Some(first), Some(last)) if first != last => ring.push(*first),
_ => {}
}
let mut distinct: Vec<(f64, f64)> = Vec::new();
for point in &ring {
if !distinct.contains(point) {
distinct.push(*point);
}
}
if distinct.len() < 3 {
return Err(ParseError::new(
parse_rust_core::ErrorCode::InternalServerError,
"GeoJSON: Loop must have at least 3 different vertices",
));
}
Bson::Document({
let mut d = Document::new();
d.insert("type", "Polygon");
d.insert(
"coordinates",
Bson::Array(vec![Bson::Array(
ring.iter()
.map(|(lat, lng)| {
Bson::Array(vec![Bson::Double(*lng), Bson::Double(*lat)])
})
.collect(),
)]),
);
d
})
}
ParseValue::File { name, .. } => Bson::String(name.clone()),
ParseValue::Relation { .. } => {
return Err(ParseError::incorrect_type(
"Relation fields are not stored on the object".to_string(),
))
}
})
}
pub fn parse_object_to_mongo_create(
schema: &ClassSchema,
object: &ParseMap,
) -> Result<Document, ParseError> {
let mut out = Document::new();
for (key, value) in object {
if let Some((mongo_key, bson)) = field_to_column(schema, key, value)? {
out.insert(mongo_key, bson);
}
}
Ok(out)
}
fn field_to_column(
schema: &ClassSchema,
key: &str,
value: &ParseValue,
) -> Result<Option<(String, Bson)>, ParseError> {
if matches!(value, ParseValue::Relation { .. }) {
return Ok(None);
}
if key == "ACL" {
return Err(ParseError::invalid_json(
"ACL must be lowered through parse_acl_to_columns, not as a field".to_string(),
));
}
let mongo_key = storage_key(schema, key);
if schema.is_pointer_field(key) {
return match value {
ParseValue::Pointer {
class_name,
object_id,
} => Ok(Some((
mongo_key,
Bson::String(format!("{class_name}${object_id}")),
))),
ParseValue::Null => Ok(Some((mongo_key, Bson::Null))),
_ => Err(ParseError::incorrect_type(format!(
"schema mismatch for {}.{key}; expected Pointer but got a non-pointer",
schema.class_name
))),
};
}
if key == "expiresAt" {
if let ParseValue::String(s) = value {
return Ok(Some((mongo_key, date_to_bson(&ParseDate::parse_iso(s)?))));
}
}
Ok(Some((mongo_key, plain_value_to_bson(value)?)))
}
pub fn mongo_object_to_parse(schema: &ClassSchema, doc: &Document) -> Result<ParseMap, ParseError> {
let mut out = ParseMap::new();
for (key, value) in doc {
if key == "_acl" {
continue;
}
if key == "_id" {
out.insert(
"objectId".to_string(),
ParseValue::String(bson_id_string(value)),
);
continue;
}
let parse_key = match untransform_key(key) {
Some(k) => k,
None if key.starts_with('_') && key != "__type" => {
return Err(ParseError::invalid_query(format!(
"bad key in untransform: {key}"
)))
}
None => key.clone(),
};
if let Some(stripped) = key.strip_prefix("_p_") {
match value {
Bson::String(s) => {
let (class_name, object_id) = s.split_once('$').ok_or_else(|| {
ParseError::incorrect_type(format!(
"pointer field {stripped} is malformed: {s}"
))
})?;
out.insert(
stripped.to_string(),
ParseValue::Pointer {
class_name: class_name.to_string(),
object_id: object_id.to_string(),
},
);
}
Bson::Null => {
out.insert(stripped.to_string(), ParseValue::Null);
}
_ => {
return Err(ParseError::incorrect_type(format!(
"pointer field {stripped} is not a string"
)))
}
}
continue;
}
match schema_raised_value(schema, &parse_key, value) {
Some(raised) => out.insert(parse_key, raised),
None => out.insert(parse_key, bson_to_parse_value(value)?),
};
}
for (name, target_class) in schema.relation_fields() {
out.insert(
name.to_string(),
ParseValue::Relation {
class_name: target_class.to_string(),
},
);
}
Ok(out)
}
fn schema_raised_value(schema: &ClassSchema, field: &str, value: &Bson) -> Option<ParseValue> {
let field_type = schema.field(field)?;
match (field_type, value) {
(FieldType::Bytes, Bson::String(s)) if parse_rust_core::is_base64_value(s) => {
let mut envelope = ParseMap::new();
envelope.insert(
"__type".to_string(),
ParseValue::String("Bytes".to_string()),
);
envelope.insert("base64".to_string(), ParseValue::String(s.clone()));
Some(ParseValue::Object(envelope))
}
(FieldType::File, Bson::String(name)) => Some(ParseValue::File {
name: name.clone(),
url: None,
}),
(FieldType::GeoPoint, Bson::Array(items)) if items.len() == 2 => {
match (bson_f64(&items[0]), bson_f64(&items[1])) {
(Some(longitude), Some(latitude)) => Some(ParseValue::GeoPoint {
latitude,
longitude,
}),
_ => None,
}
}
(FieldType::Polygon, Bson::Document(d)) => {
if d.get_str("type").ok()? != "Polygon" {
return None;
}
let ring = d.get_array("coordinates").ok()?.first()?.as_array()?;
let mut points = Vec::with_capacity(ring.len());
for point in ring {
let pair = point.as_array()?;
if pair.len() != 2 {
return None;
}
points.push((bson_f64(&pair[1])?, bson_f64(&pair[0])?));
}
Some(ParseValue::Polygon(points))
}
_ => None,
}
}
fn bson_f64(value: &Bson) -> Option<f64> {
match value {
Bson::Double(n) => Some(*n),
Bson::Int32(n) => Some(*n as f64),
Bson::Int64(n) => Some(*n as f64),
_ => None,
}
}
pub fn bson_document_to_parse_map(doc: &Document) -> Result<ParseMap, ParseError> {
let mut out = ParseMap::new();
for (key, value) in doc {
out.insert(key.clone(), bson_to_parse_value(value)?);
}
Ok(out)
}
pub fn parse_map_to_bson_document(map: &ParseMap) -> Result<Document, ParseError> {
let mut out = Document::new();
for (key, value) in map {
out.insert(key.clone(), raw_metadata_value(value)?);
}
Ok(out)
}
fn raw_metadata_value(value: &ParseValue) -> Result<Bson, ParseError> {
unchanged_atom_to_bson(value)
}
pub fn bson_to_parse_value(value: &Bson) -> Result<ParseValue, ParseError> {
Ok(match value {
Bson::Null => ParseValue::Null,
Bson::Boolean(b) => ParseValue::Bool(*b),
Bson::Int32(n) => ParseValue::Number(*n as f64),
Bson::Int64(n) => ParseValue::Number(*n as f64),
Bson::Double(n) => ParseValue::Number(*n),
Bson::String(s) => ParseValue::String(s.clone()),
Bson::DateTime(dt) => ParseValue::Date(ParseDate::parse_iso(
&dt.try_to_rfc3339_string()
.map_err(|_| ParseError::invalid_json("undecodable stored date"))?,
)?),
Bson::Binary(b) => ParseValue::Bytes(b.bytes.clone()),
Bson::Array(items) => ParseValue::Array(
items
.iter()
.map(bson_to_parse_value)
.collect::<Result<Vec<_>, _>>()?,
),
Bson::Document(d) => {
let mut map = ParseMap::new();
for (k, v) in d {
map.insert(k.clone(), bson_to_parse_value(v)?);
}
ParseValue::Object(map)
}
other => {
return Err(ParseError::incorrect_type(format!(
"unsupported BSON type in stored document: {other:?}"
)))
}
})
}
fn bson_id_string(value: &Bson) -> String {
match value {
Bson::String(s) => s.clone(),
Bson::ObjectId(oid) => oid.to_hex(),
Bson::Int32(n) => n.to_string(),
Bson::Int64(n) => n.to_string(),
Bson::Double(n) => parse_rust_core::js_number::to_ecma_string(*n),
other => other.to_string(),
}
}
pub fn value_to_bson_for_query(
schema: &ClassSchema,
field: &str,
value: &ParseValue,
) -> Result<Bson, ParseError> {
match value {
ParseValue::Pointer {
class_name,
object_id,
} => Ok(Bson::String(format!("{class_name}${object_id}"))),
ParseValue::String(object_id) if schema.is_pointer_field(field) => {
match schema.field(field).and_then(FieldType::target_class) {
Some(target) => Ok(Bson::String(format!("{target}${object_id}"))),
None => plain_value_to_bson(value),
}
}
_ => plain_value_to_bson(value),
}
}
pub fn index_key_to_bson(index: &str, field: &str, value: &ParseValue) -> Result<Bson, ParseError> {
match value {
ParseValue::Number(n) => Ok(to_bson_number(*n)),
ParseValue::String(s) => Ok(Bson::String(s.clone())),
_ => Err(ParseError::invalid_query(format!(
"Index {index} has an invalid value for {field}"
))),
}
}
pub fn transform_where(schema: &ClassSchema, query: &Query) -> Result<Document, ParseError> {
let mut out = Document::new();
for clause in &query.clauses {
match clause {
Clause::Field(constraint) => {
let key = storage_key(schema, &constraint.field);
let entry = comparison_to_bson(schema, constraint)?;
merge_constraint(&mut out, key, entry)?;
}
Clause::Or(branches) => {
insert_logical(&mut out, "$or", lower_branches(schema, branches)?)
}
Clause::And(branches) => {
insert_logical(&mut out, "$and", lower_branches(schema, branches)?)
}
Clause::Nor(branches) => {
insert_logical(&mut out, "$nor", lower_branches(schema, branches)?)
}
}
}
Ok(out)
}
fn lower_branches(schema: &ClassSchema, branches: &[Query]) -> Result<Vec<Bson>, ParseError> {
branches
.iter()
.map(|q| transform_where(schema, q).map(Bson::Document))
.collect()
}
fn insert_logical(filter: &mut Document, key: &str, branches: Vec<Bson>) {
if key == "$and" {
if let Some(Bson::Array(mut existing)) = filter.remove("$and") {
existing.extend(branches);
filter.insert("$and", Bson::Array(existing));
return;
}
filter.insert("$and", Bson::Array(branches));
return;
}
let Some(existing) = filter.remove(key) else {
filter.insert(key, Bson::Array(branches));
return;
};
let mut conjuncts: Vec<Bson> = match filter.remove("$and") {
Some(Bson::Array(items)) => items,
Some(other) => vec![other],
None => Vec::new(),
};
let mut first = Document::new();
first.insert(key, existing);
let mut second = Document::new();
second.insert(key, Bson::Array(branches));
conjuncts.push(Bson::Document(first));
conjuncts.push(Bson::Document(second));
filter.insert("$and", Bson::Array(conjuncts));
}
fn merge_constraint(filter: &mut Document, key: String, entry: Bson) -> Result<(), ParseError> {
match filter.remove(&key) {
None => {
filter.insert(key, entry);
}
Some(existing) => match (existing, entry) {
(Bson::Document(mut a), Bson::Document(b)) => {
for (k, v) in b {
a.insert(k, v);
}
filter.insert(key, Bson::Document(a));
}
_ => {
return Err(ParseError::invalid_query(format!(
"conflicting constraints on field {key}"
)))
}
},
}
Ok(())
}
fn comparison_to_bson(schema: &ClassSchema, constraint: &Constraint) -> Result<Bson, ParseError> {
let dotted = constraint.field.contains('.');
let in_array = schema
.field(&constraint.field)
.is_some_and(|f| matches!(f, FieldType::Array));
let constraint_position = if in_array || dotted {
AtomPosition::Interior
} else {
AtomPosition::TopLevel
};
let shorthand_position = if dotted {
AtomPosition::Interior
} else {
AtomPosition::TopLevel
};
let lower = |v: &ParseValue, position: AtomPosition| -> Result<Bson, ParseError> {
let atom = recognize_atom(v.clone(), position);
match position {
AtomPosition::Interior => interior_query_atom_to_bson(&atom),
AtomPosition::TopLevel => {
if matches!(atom, ParseValue::Object(_) | ParseValue::Array(_)) {
return Err(ParseError::invalid_json(format!(
"bad atom: {}",
atom.to_json()
)));
}
value_to_bson_for_query(schema, &constraint.field, &atom)
}
}
};
let value = |v: &ParseValue| -> Result<Bson, ParseError> { lower(v, constraint_position) };
let flatten_each = |items: &Vec<ParseValue>| -> Result<Vec<Bson>, ParseError> {
let mut out = Vec::with_capacity(items.len());
for item in items {
match item {
ParseValue::Array(inner) => {
for nested in inner {
out.push(value(nested)?);
}
}
other => out.push(value(other)?),
}
}
Ok(out)
};
Ok(match &constraint.comparison {
Comparison::Equal(ParseValue::Array(items)) if items.is_empty() => {
Bson::Document(Document::new())
}
Comparison::Equal(ParseValue::Object(map)) if map.is_empty() => {
Bson::Document(Document::new())
}
Comparison::Equal(v)
if matches!(schema.field(&constraint.field), Some(FieldType::Array))
&& !matches!(v, ParseValue::Array(_)) =>
{
operator("$all", Bson::Array(vec![lower(v, AtomPosition::Interior)?]))
}
Comparison::Equal(v) => {
let atom = recognize_atom(v.clone(), shorthand_position);
if shorthand_position == AtomPosition::TopLevel
&& matches!(atom, ParseValue::Object(_) | ParseValue::Array(_))
{
return Err(ParseError::invalid_json(format!(
"You cannot use {} as a query parameter.",
parse_rust_core::js_number::to_ecma_display(&atom)
)));
}
match shorthand_position {
AtomPosition::Interior => interior_query_atom_to_bson(&atom)?,
AtomPosition::TopLevel => {
value_to_bson_for_query(schema, &constraint.field, &atom)?
}
}
}
Comparison::EqualOperator(v) => operator("$eq", value(v)?),
Comparison::NotEqual(v) => operator("$ne", value(v)?),
Comparison::GreaterThan(v) => operator("$gt", value(v)?),
Comparison::GreaterThanOrEqual(v) => operator("$gte", value(v)?),
Comparison::LessThan(v) => operator("$lt", value(v)?),
Comparison::LessThanOrEqual(v) => operator("$lte", value(v)?),
Comparison::In(items) => operator("$in", Bson::Array(flatten_each(items)?)),
Comparison::NotIn(items) => operator("$nin", Bson::Array(flatten_each(items)?)),
Comparison::Exists(b) => operator("$exists", Bson::Boolean(*b)),
Comparison::All(items) => {
let lowered = items
.iter()
.map(|v| lower(v, AtomPosition::Interior))
.collect::<Result<Vec<_>, _>>()?;
let regexes = lowered
.iter()
.filter(|b| matches!(b, Bson::RegularExpression(_)))
.count();
if regexes > 0 && regexes != lowered.len() {
return Err(ParseError::invalid_json(format!(
"All $all values must be of regex type or none: {}",
items
.iter()
.map(js_join_element)
.collect::<Vec<_>>()
.join(",")
)));
}
operator("$all", Bson::Array(lowered))
}
Comparison::Regex { pattern, options } => {
let mut d = Document::new();
d.insert("$regex", Bson::String(pattern.clone()));
if let Some(options) = options {
d.insert("$options", Bson::String(options.clone()));
}
Bson::Document(d)
}
})
}
fn operator(op: &str, value: Bson) -> Bson {
let mut d = Document::new();
d.insert(op, value);
Bson::Document(d)
}
pub fn transform_update(schema: &ClassSchema, update: &Update) -> Result<Document, ParseError> {
let mut out = Document::new();
for (key, op) in update {
match op {
UpdateValue::Set(value) => {
if let Some((mongo_key, bson)) = field_to_column(schema, key, value)? {
push_op(&mut out, "$set", mongo_key, bson);
}
}
UpdateValue::Increment(amount) => {
push_op(
&mut out,
"$inc",
storage_key(schema, key),
to_bson_number(*amount),
);
}
UpdateValue::SetOnInsert(value) => {
if let Some((mongo_key, bson)) = field_to_column(schema, key, value)? {
push_op(&mut out, "$setOnInsert", mongo_key, bson);
}
}
UpdateValue::Add(values) => {
push_op(&mut out, "$push", storage_key(schema, key), each(values)?);
}
UpdateValue::AddUnique(values) => {
push_op(
&mut out,
"$addToSet",
storage_key(schema, key),
each(values)?,
);
}
UpdateValue::Remove(values) => {
push_op(
&mut out,
"$pullAll",
storage_key(schema, key),
Bson::Array(interior_atoms(values)?),
);
}
UpdateValue::Unset => {
push_op(
&mut out,
"$unset",
storage_key(schema, key),
Bson::String(String::new()),
);
}
}
}
Ok(out)
}
fn interior_atoms(values: &[ParseValue]) -> Result<Vec<Bson>, ParseError> {
values.iter().map(interior_value_to_bson).collect()
}
fn each(values: &[ParseValue]) -> Result<Bson, ParseError> {
let mut d = Document::new();
d.insert("$each", Bson::Array(interior_atoms(values)?));
Ok(Bson::Document(d))
}
fn push_op(out: &mut Document, op: &str, key: String, value: Bson) {
if let Ok(existing) = out.get_document_mut(op) {
existing.insert(key, value);
return;
}
let mut sub = Document::new();
sub.insert(key, value);
out.insert(op, sub);
}
#[cfg(test)]
mod tests {
use super::*;
use parse_rust_storage::FieldType;
#[test]
fn ambiguous_columns_are_raised_by_their_declared_type() {
let schema = ClassSchema::new("M")
.with_field("pic", FieldType::File)
.with_field("spot", FieldType::GeoPoint)
.with_field("bin", FieldType::Bytes)
.with_field("label", FieldType::String);
let mut doc = Document::new();
doc.insert("_id", "abc");
doc.insert("pic", "avatar.png");
doc.insert(
"spot",
Bson::Array(vec![Bson::Double(2.0), Bson::Double(1.0)]),
);
doc.insert("bin", "AB==");
doc.insert("label", "avatar.png");
let out = mongo_object_to_parse(&schema, &doc).expect("raise");
assert!(
matches!(out.get("pic"), Some(ParseValue::File { name, url: None }) if name == "avatar.png"),
"{:?}",
out.get("pic")
);
assert!(
matches!(
out.get("spot"),
Some(ParseValue::GeoPoint { latitude, longitude })
if *latitude == 1.0 && *longitude == 2.0
),
"{:?}",
out.get("spot")
);
assert_eq!(
out.get("bin").map(ParseValue::to_json).as_deref(),
Some(r#"{"__type":"Bytes","base64":"AB=="}"#),
"a legacy Bytes string is preserved, not canonicalized"
);
assert!(
matches!(out.get("label"), Some(ParseValue::String(s)) if s == "avatar.png"),
"{:?}",
out.get("label")
);
let mut doc = Document::new();
doc.insert("_id", "abc");
doc.insert("bin", "not base64!");
let out = mongo_object_to_parse(&schema, &doc).expect("raise");
assert!(
matches!(out.get("bin"), Some(ParseValue::String(s)) if s == "not base64!"),
"{:?}",
out.get("bin")
);
}
fn post_schema() -> ClassSchema {
ClassSchema::new("Post")
.with_field("title", FieldType::String)
.with_field("views", FieldType::Number)
.with_field(
"author",
FieldType::Pointer {
target_class: "_User".into(),
},
)
}
fn map(pairs: Vec<(&str, ParseValue)>) -> ParseMap {
let mut m = ParseMap::new();
for (k, v) in pairs {
m.insert(k.to_string(), v);
}
m
}
#[test]
fn renamed_keys_round_trip() {
let s = post_schema();
assert_eq!(storage_key(&s, "objectId"), "_id");
assert_eq!(storage_key(&s, "createdAt"), "_created_at");
assert_eq!(storage_key(&s, "updatedAt"), "_updated_at");
assert_eq!(storage_key(&s, "title"), "title");
assert_eq!(storage_key(&s, "author"), "_p_author");
assert_eq!(untransform_key("_id").as_deref(), Some("objectId"));
assert_eq!(untransform_key("_created_at").as_deref(), Some("createdAt"));
assert_eq!(untransform_key("_p_author").as_deref(), Some("author"));
assert_eq!(untransform_key("title"), None);
}
#[test]
fn integral_numbers_in_i32_range_store_as_int32() {
assert_eq!(to_bson_number(0.0), Bson::Int32(0));
assert_eq!(to_bson_number(42.0), Bson::Int32(42));
assert_eq!(to_bson_number(-42.0), Bson::Int32(-42));
assert_eq!(to_bson_number(i32::MAX as f64), Bson::Int32(i32::MAX));
assert_eq!(to_bson_number(i32::MIN as f64), Bson::Int32(i32::MIN));
}
#[test]
fn everything_else_stores_as_double() {
assert_eq!(to_bson_number(1.5), Bson::Double(1.5));
assert_eq!(
to_bson_number(i32::MAX as f64 + 1.0),
Bson::Double(i32::MAX as f64 + 1.0)
);
assert_eq!(to_bson_number(1e20), Bson::Double(1e20));
}
#[test]
fn a_pointer_field_collapses_to_class_dollar_id() {
let doc = parse_object_to_mongo_create(
&post_schema(),
&map(vec![(
"author",
ParseValue::Pointer {
class_name: "_User".into(),
object_id: "abc123".into(),
},
)]),
)
.expect("transform");
assert_eq!(doc.get_str("_p_author").expect("_p_author"), "_User$abc123");
assert!(
!doc.contains_key("author"),
"must not also store the raw key"
);
}
#[test]
fn a_nested_pointer_keeps_its_type_envelope() {
let doc = parse_object_to_mongo_create(
&post_schema(),
&map(vec![(
"tags",
ParseValue::Array(vec![ParseValue::Pointer {
class_name: "Tag".into(),
object_id: "t1".into(),
}]),
)]),
)
.expect("transform");
let arr = doc.get_array("tags").expect("tags");
let nested = arr[0].as_document().expect("document");
assert_eq!(nested.get_str("__type").expect("__type"), "Pointer");
assert_eq!(nested.get_str("className").expect("className"), "Tag");
}
#[test]
fn relation_values_are_dropped_not_stored() {
let doc = parse_object_to_mongo_create(
&post_schema(),
&map(vec![
("title", ParseValue::String("x".into())),
(
"comments",
ParseValue::Relation {
class_name: "Comment".into(),
},
),
]),
)
.expect("transform");
assert!(doc.contains_key("title"));
assert!(
!doc.contains_key("comments"),
"a Relation lives in a join table, not on the object"
);
}
#[test]
fn read_back_restores_keys_and_pointers() {
let mut doc = Document::new();
doc.insert("_id", "objid1");
doc.insert("title", "hello");
doc.insert("views", Bson::Int32(7));
doc.insert("_p_author", "_User$abc123");
doc.insert(
"_created_at",
Bson::DateTime(bson::DateTime::from_millis(1_700_000_000_000)),
);
let parsed = mongo_object_to_parse(&post_schema(), &doc).expect("untransform");
assert!(matches!(parsed.get("objectId"), Some(ParseValue::String(s)) if s == "objid1"));
assert!(matches!(parsed.get("views"), Some(ParseValue::Number(n)) if *n == 7.0));
assert!(matches!(
parsed.get("author"),
Some(ParseValue::Pointer { class_name, object_id })
if class_name == "_User" && object_id == "abc123"
));
assert!(matches!(parsed.get("createdAt"), Some(ParseValue::Date(_))));
}
#[test]
fn permission_columns_survive_for_the_acl_rebuild() {
let mut doc = Document::new();
doc.insert("title", "x");
doc.insert("_rperm", Bson::Array(vec![Bson::String("*".into())]));
doc.insert("_wperm", Bson::Array(vec![]));
doc.insert("_acl", Document::new());
let parsed = mongo_object_to_parse(&post_schema(), &doc).expect("untransform");
assert!(parsed.get("_rperm").is_some(), "raise_acl needs this");
assert!(parsed.get("_wperm").is_some(), "raise_acl needs this");
assert!(
parsed.get("_acl").is_none(),
"the legacy mirror is write-only and is dropped on read"
);
}
#[test]
fn internal_columns_survive_under_their_own_names() {
let mut doc = Document::new();
doc.insert("_hashed_password", "$2b$10$abc");
doc.insert("_session_token", "r:tok");
let parsed = mongo_object_to_parse(&post_schema(), &doc).expect("untransform");
assert!(parsed.get("_hashed_password").is_some());
assert!(
parsed.get("password").is_none(),
"the hash must never be raised under a user-facing name"
);
assert!(parsed.get("sessionToken").is_some());
}
#[test]
fn an_unknown_underscore_key_is_refused_rather_than_passed_through() {
let mut doc = Document::new();
doc.insert("_mystery", "x"); let err = mongo_object_to_parse(&post_schema(), &doc).unwrap_err();
assert!(err.message.contains("bad key in untransform"));
}
#[test]
fn int64_and_int32_both_raise_to_one_number_type() {
let mut doc = Document::new();
doc.insert("a", Bson::Int32(1));
doc.insert("b", Bson::Int64(2));
doc.insert("c", Bson::Double(3.5));
let parsed = mongo_object_to_parse(&post_schema(), &doc).expect("untransform");
for (k, expected) in [("a", 1.0), ("b", 2.0), ("c", 3.5)] {
assert!(matches!(parsed.get(k), Some(ParseValue::Number(n)) if *n == expected));
}
}
}
#[cfg(test)]
mod query_tree_tests {
use super::*;
use bson::doc;
use parse_rust_storage::{Constraint, FieldType};
fn post_schema() -> ClassSchema {
ClassSchema::new("Post")
.with_field("title", FieldType::String)
.with_field("tags", FieldType::Array)
.with_field(
"author",
FieldType::Pointer {
target_class: "_User".into(),
},
)
}
fn map(pairs: Vec<(&str, ParseValue)>) -> ParseMap {
let mut m = ParseMap::new();
for (k, v) in pairs {
m.insert(k.to_string(), v);
}
m
}
fn eq(field: &str, value: &str) -> Query {
Query::from_constraints(vec![Constraint::equal(
field,
ParseValue::String(value.into()),
)])
}
#[test]
fn a_disjunction_lowers_to_dollar_or() {
let mut q = Query::new();
q.push(Clause::Or(vec![eq("title", "a"), eq("title", "b")]));
let out = transform_where(&post_schema(), &q).expect("lower");
assert_eq!(out, doc! { "$or": [ { "title": "a" }, { "title": "b" } ] });
}
#[test]
fn and_and_nor_lower_to_their_own_operators() {
let mut q = Query::new();
q.push(Clause::And(vec![eq("title", "a")]));
assert_eq!(
transform_where(&post_schema(), &q).expect("lower"),
doc! { "$and": [ { "title": "a" } ] }
);
let mut q = Query::new();
q.push(Clause::Nor(vec![eq("title", "a")]));
assert_eq!(
transform_where(&post_schema(), &q).expect("lower"),
doc! { "$nor": [ { "title": "a" } ] }
);
}
#[test]
fn a_branch_gets_the_same_key_and_value_transform() {
let author = ParseValue::Pointer {
class_name: "_User".into(),
object_id: "u1".into(),
};
let mut q = Query::new();
q.push(Clause::Or(vec![
Query::from_constraints(vec![Constraint::equal("author", author)]),
Query::from_constraints(vec![Constraint::equal(
"objectId",
ParseValue::String("oid1".into()),
)]),
]));
assert_eq!(
transform_where(&post_schema(), &q).expect("lower"),
doc! { "$or": [ { "_p_author": "_User$u1" }, { "_id": "oid1" } ] }
);
}
#[test]
fn two_disjunctions_at_one_level_combine_rather_than_overwrite() {
let mut q = Query::new();
q.push(Clause::Or(vec![eq("title", "a"), eq("title", "b")]));
q.push(Clause::Or(vec![eq("title", "c"), eq("title", "d")]));
let out = transform_where(&post_schema(), &q).expect("lower");
assert!(
!out.contains_key("$or"),
"neither disjunction may survive alone"
);
let conjuncts = out.get_array("$and").expect("$and");
assert_eq!(conjuncts.len(), 2);
assert_eq!(
conjuncts[0],
Bson::Document(doc! { "$or": [ { "title": "a" }, { "title": "b" } ] })
);
assert_eq!(
conjuncts[1],
Bson::Document(doc! { "$or": [ { "title": "c" }, { "title": "d" } ] })
);
}
#[test]
fn two_conjunctions_at_one_level_concatenate() {
let mut q = Query::new();
q.push(Clause::And(vec![eq("title", "a")]));
q.push(Clause::And(vec![eq("title", "b")]));
assert_eq!(
transform_where(&post_schema(), &q).expect("lower"),
doc! { "$and": [ { "title": "a" }, { "title": "b" } ] }
);
}
#[test]
fn a_collided_disjunction_joins_an_existing_conjunction() {
let mut q = Query::new();
q.push(Clause::And(vec![eq("title", "keep")]));
q.push(Clause::Or(vec![eq("title", "a"), eq("title", "b")]));
q.push(Clause::Or(vec![eq("title", "c"), eq("title", "d")]));
let out = transform_where(&post_schema(), &q).expect("lower");
let conjuncts = out.get_array("$and").expect("$and");
assert_eq!(conjuncts.len(), 3, "the original conjunct must survive");
assert_eq!(conjuncts[0], Bson::Document(doc! { "title": "keep" }));
}
#[test]
fn repeated_constraints_on_one_field_still_merge() {
let mut q = Query::new();
q.push_constraint(Constraint {
field: "views".into(),
comparison: Comparison::GreaterThan(ParseValue::Number(1.0)),
});
q.push_constraint(Constraint {
field: "views".into(),
comparison: Comparison::LessThan(ParseValue::Number(9.0)),
});
assert_eq!(
transform_where(&post_schema(), &q).expect("lower"),
doc! { "views": { "$gt": 1, "$lt": 9 } }
);
}
#[test]
fn conflicting_equalities_on_one_field_are_an_error_not_a_silent_drop() {
let mut q = Query::new();
q.push_constraint(Constraint::equal("title", ParseValue::String("a".into())));
q.push_constraint(Constraint::equal("title", ParseValue::String("b".into())));
assert!(transform_where(&post_schema(), &q).is_err());
}
#[test]
fn all_lowers_to_dollar_all_with_interior_atoms() {
let mut q = Query::new();
q.push_constraint(Constraint {
field: "tags".into(),
comparison: Comparison::All(vec![
ParseValue::String("a".into()),
ParseValue::Pointer {
class_name: "Tag".into(),
object_id: "t1".into(),
},
]),
});
assert_eq!(
transform_where(&post_schema(), &q).expect("lower"),
doc! { "tags": { "$all": [
"a",
{ "__type": "Pointer", "className": "Tag", "objectId": "t1" }
] } }
);
}
#[test]
fn the_atom_list_is_chosen_by_the_field_not_by_the_parser() {
let raw_geo = || {
ParseValue::Object(map(vec![
("__type", ParseValue::String("GeoPoint".into())),
("latitude", ParseValue::Number(1.0)),
("longitude", ParseValue::Number(2.0)),
("extra", ParseValue::Number(7.0)),
]))
};
let schema = ClassSchema::new("Place")
.with_field("spot", FieldType::GeoPoint)
.with_field("spots", FieldType::Array);
let mut q = Query::new();
q.push_constraint(Constraint {
field: "spot".into(),
comparison: Comparison::NotEqual(raw_geo()),
});
assert_eq!(
transform_where(&schema, &q).expect("lower"),
doc! { "spot": { "$ne": [2.0, 1.0] } },
"a GeoPoint operand on a GeoPoint field is rebuilt, so the extra key cannot affect it"
);
let mut q = Query::new();
q.push_constraint(Constraint {
field: "spots".into(),
comparison: Comparison::NotEqual(raw_geo()),
});
assert_eq!(
transform_where(&schema, &q).expect("lower"),
doc! { "spots": { "$ne": {
"__type": "GeoPoint", "latitude": 1, "longitude": 2, "extra": 7
} } },
"an Array field takes the interior list, which has no GeoPoint on it"
);
}
#[test]
fn shorthand_equality_refuses_what_is_not_an_atom() {
let schema = ClassSchema::new("P")
.with_field("tags", FieldType::Array)
.with_field("meta", FieldType::Object);
let lower = |field: &str, v: ParseValue| {
let mut q = Query::new();
q.push_constraint(Constraint {
field: field.into(),
comparison: Comparison::Equal(v),
});
transform_where(&schema, &q)
};
let obj = || ParseValue::Object(map(vec![("a", ParseValue::Number(1.0))]));
for (field, value, rendered) in [
("meta", obj(), "[object Object]"),
(
"tags",
ParseValue::Array(vec![ParseValue::Number(1.0)]),
"1",
),
(
"meta",
ParseValue::Array(vec![
ParseValue::Array(vec![ParseValue::Number(1.0), ParseValue::Number(2.0)]),
ParseValue::Number(3.0),
]),
"1,2,3",
),
("meta", ParseValue::Array(vec![obj()]), "[object Object]"),
] {
let err = lower(field, value).expect_err("upstream refuses this");
assert_eq!(err.code, parse_rust_core::ErrorCode::InvalidJson, "{err:?}");
assert_eq!(
err.message,
format!("You cannot use {rendered} as a query parameter.")
);
}
for (field, value) in [
("tags", ParseValue::Array(Vec::new())),
("meta", ParseValue::Array(Vec::new())),
("tags", ParseValue::Object(ParseMap::new())),
("meta", ParseValue::Object(ParseMap::new())),
] {
assert_eq!(
lower(field, value).expect("an empty collection is not refused"),
doc! { field: {} },
"field {field}"
);
}
assert_eq!(
lower("meta.a", obj()).expect("the interior position has no refusal"),
doc! { "meta.a": { "a": 1 } }
);
let relation = ParseValue::Object(map(vec![
("__type", ParseValue::String("Relation".into())),
("className", ParseValue::String("X".into())),
]));
let err = lower("meta", relation.clone()).expect_err("not an atom at the top level");
assert_eq!(
err.message,
"You cannot use [object Object] as a query parameter."
);
let mut q = Query::new();
q.push_constraint(Constraint {
field: "meta".into(),
comparison: Comparison::NotEqual(relation),
});
let err = transform_where(&schema, &q).expect_err("not an atom under an operator either");
assert_eq!(
err.message,
r#"bad atom: {"__type":"Relation","className":"X"}"#
);
}
#[test]
fn regex_lowers_to_a_string_pattern_and_a_string_options() {
let mut q = Query::new();
q.push_constraint(Constraint {
field: "title".into(),
comparison: Comparison::Regex {
pattern: "^foo".into(),
options: Some("i".into()),
},
});
let out = transform_where(&post_schema(), &q).expect("lower");
assert_eq!(out, doc! { "title": { "$regex": "^foo", "$options": "i" } });
assert!(matches!(
out.get_document("title").expect("title").get("$regex"),
Some(Bson::String(_))
));
}
#[test]
fn regex_without_options_emits_no_options_key() {
let mut q = Query::new();
q.push_constraint(Constraint {
field: "title".into(),
comparison: Comparison::Regex {
pattern: "^foo".into(),
options: None,
},
});
assert_eq!(
transform_where(&post_schema(), &q).expect("lower"),
doc! { "title": { "$regex": "^foo" } }
);
}
#[test]
fn an_empty_query_lowers_to_an_empty_document() {
assert!(transform_where(&post_schema(), &Query::new())
.expect("lower")
.is_empty());
}
}
#[cfg(test)]
mod update_tests {
use super::*;
use bson::doc;
use parse_rust_storage::FieldType;
use parse_rust_storage::{Update, UpdateValue};
fn session_schema() -> ClassSchema {
ClassSchema::new("_Session")
.with_field("sessionToken", FieldType::String)
.with_field("expiresAt", FieldType::Date)
.with_field("timesUsed", FieldType::Number)
.with_field("counts", FieldType::Array)
.with_field(
"user",
FieldType::Pointer {
target_class: "_User".into(),
},
)
}
fn update(pairs: Vec<(&str, UpdateValue)>) -> Update {
let mut u = Update::new();
for (k, v) in pairs {
u.insert(k.to_string(), v);
}
u
}
#[test]
fn set_lowers_to_dollar_set_under_the_storage_key() {
let out = transform_update(
&session_schema(),
&update(vec![
(
"sessionToken",
UpdateValue::Set(ParseValue::String("r:tok".into())),
),
(
"user",
UpdateValue::Set(ParseValue::Pointer {
class_name: "_User".into(),
object_id: "u1".into(),
}),
),
]),
)
.expect("lower");
assert_eq!(
out,
doc! { "$set": { "_session_token": "r:tok", "_p_user": "_User$u1" } }
);
}
#[test]
fn increment_lowers_to_dollar_inc() {
let out = transform_update(
&session_schema(),
&update(vec![("timesUsed", UpdateValue::Increment(1.0))]),
)
.expect("lower");
assert_eq!(out, doc! { "$inc": { "times_used": 1 } });
}
#[test]
fn add_and_add_unique_wrap_their_values_in_each() {
let out = transform_update(
&session_schema(),
&update(vec![(
"counts",
UpdateValue::Add(vec![ParseValue::Number(1.0), ParseValue::Number(2.0)]),
)]),
)
.expect("lower");
assert_eq!(out, doc! { "$push": { "counts": { "$each": [1, 2] } } });
let out = transform_update(
&session_schema(),
&update(vec![(
"counts",
UpdateValue::AddUnique(vec![ParseValue::Number(1.0)]),
)]),
)
.expect("lower");
assert_eq!(out, doc! { "$addToSet": { "counts": { "$each": [1] } } });
}
#[test]
fn remove_lowers_to_pull_all_with_no_each_wrapper() {
let out = transform_update(
&session_schema(),
&update(vec![(
"counts",
UpdateValue::Remove(vec![ParseValue::Number(1.0)]),
)]),
)
.expect("lower");
assert_eq!(out, doc! { "$pullAll": { "counts": [1] } });
}
#[test]
fn unset_lowers_to_the_empty_string_argument() {
let out = transform_update(
&session_schema(),
&update(vec![("counts", UpdateValue::Unset)]),
)
.expect("lower");
assert_eq!(out, doc! { "$unset": { "counts": "" } });
}
#[test]
fn several_ops_group_under_their_operators_in_first_use_order() {
let out = transform_update(
&session_schema(),
&update(vec![
("timesUsed", UpdateValue::Increment(1.0)),
(
"sessionToken",
UpdateValue::Set(ParseValue::String("r:tok".into())),
),
("counts", UpdateValue::Add(vec![ParseValue::Number(1.0)])),
]),
)
.expect("lower");
let keys: Vec<&str> = out.keys().map(String::as_str).collect();
assert_eq!(keys, vec!["$inc", "$set", "$push"]);
}
#[test]
fn a_relation_set_is_skipped_rather_than_stored() {
let out = transform_update(
&session_schema(),
&update(vec![
(
"members",
UpdateValue::Set(ParseValue::Relation {
class_name: "_User".into(),
}),
),
(
"sessionToken",
UpdateValue::Set(ParseValue::String("r:tok".into())),
),
]),
)
.expect("lower");
assert_eq!(out, doc! { "$set": { "_session_token": "r:tok" } });
}
#[test]
fn expires_at_is_coerced_to_a_bson_date_even_from_a_string() {
let out = transform_update(
&session_schema(),
&update(vec![(
"expiresAt",
UpdateValue::Set(ParseValue::String("2026-08-14T13:34:33.581Z".into())),
)]),
)
.expect("lower");
let set = out.get_document("$set").expect("$set");
assert!(
matches!(set.get("expiresAt"), Some(Bson::DateTime(_))),
"expiresAt must be a BSON Date, not a string"
);
}
}
#[cfg(test)]
mod session_and_relation_tests {
use super::*;
use parse_rust_storage::FieldType;
fn session_schema() -> ClassSchema {
ClassSchema::new("_Session")
.with_field("sessionToken", FieldType::String)
.with_field("expiresAt", FieldType::Date)
.with_field("createdWith", FieldType::Object)
.with_field("installationId", FieldType::String)
.with_field("lastUsed", FieldType::Date)
.with_field("timesUsed", FieldType::Number)
.with_field(
"user",
FieldType::Pointer {
target_class: "_User".into(),
},
)
}
fn map(pairs: Vec<(&str, ParseValue)>) -> ParseMap {
let mut m = ParseMap::new();
for (k, v) in pairs {
m.insert(k.to_string(), v);
}
m
}
#[test]
fn session_columns_round_trip() {
let schema = session_schema();
let date = ParseDate::parse_iso("2026-08-14T13:34:33.581Z").expect("date");
let row = map(vec![
("objectId", ParseValue::String("s000000001".into())),
("sessionToken", ParseValue::String("r:tok".into())),
(
"user",
ParseValue::Pointer {
class_name: "_User".into(),
object_id: "u1".into(),
},
),
("expiresAt", ParseValue::Date(date)),
("lastUsed", ParseValue::Date(date)),
("timesUsed", ParseValue::Number(3.0)),
(
"createdWith",
ParseValue::Object(map(vec![("action", ParseValue::String("login".into()))])),
),
("installationId", ParseValue::String("inst1".into())),
]);
let doc = parse_object_to_mongo_create(&schema, &row).expect("lower");
assert_eq!(doc.get_str("_session_token").expect("token"), "r:tok");
assert_eq!(doc.get_str("_p_user").expect("user"), "_User$u1");
assert!(matches!(doc.get("expiresAt"), Some(Bson::DateTime(_))));
assert!(matches!(doc.get("_last_used"), Some(Bson::DateTime(_))));
assert!(doc.contains_key("times_used"), "no leading underscore");
assert!(doc.contains_key("createdWith"));
assert!(doc.contains_key("installationId"));
let back = mongo_object_to_parse(&schema, &doc).expect("raise");
assert!(matches!(back.get("sessionToken"), Some(ParseValue::String(s)) if s == "r:tok"));
assert!(matches!(
back.get("user"),
Some(ParseValue::Pointer { object_id, .. }) if object_id == "u1"
));
assert!(matches!(back.get("lastUsed"), Some(ParseValue::Date(_))));
assert!(matches!(back.get("timesUsed"), Some(ParseValue::Number(n)) if *n == 3.0));
assert!(matches!(back.get("expiresAt"), Some(ParseValue::Date(_))));
}
#[test]
fn a_relation_field_is_synthesized_on_read_and_has_no_column() {
let schema = ClassSchema::new("_Role")
.with_field("name", FieldType::String)
.with_field(
"users",
FieldType::Relation {
target_class: "_User".into(),
},
);
let doc = parse_object_to_mongo_create(
&schema,
&map(vec![
("name", ParseValue::String("Admins".into())),
(
"users",
ParseValue::Relation {
class_name: "_User".into(),
},
),
]),
)
.expect("lower");
assert!(!doc.contains_key("users"), "a Relation has no column");
let back = mongo_object_to_parse(&schema, &doc).expect("raise");
assert!(matches!(
back.get("users"),
Some(ParseValue::Relation { class_name }) if class_name == "_User"
));
}
#[test]
fn a_stray_stored_column_does_not_beat_the_synthesized_relation() {
let schema = ClassSchema::new("_Role").with_field(
"users",
FieldType::Relation {
target_class: "_User".into(),
},
);
let mut doc = Document::new();
doc.insert("users", "leftover");
let back = mongo_object_to_parse(&schema, &doc).expect("raise");
assert!(matches!(
back.get("users"),
Some(ParseValue::Relation { class_name }) if class_name == "_User"
));
}
}
#[cfg(test)]
mod eq_operator_tests {
use super::*;
use parse_rust_storage::{Clause, Comparison, Constraint, FieldType, Query};
fn schema() -> ClassSchema {
ClassSchema::new("Post")
.with_field("views", FieldType::Number)
.with_field("meta", FieldType::Object)
}
#[test]
fn an_all_of_regex_atoms_compiles_to_regular_expressions() {
let mut regex = ParseMap::new();
regex.insert("$regex".to_string(), ParseValue::String("^ba".to_string()));
let mut query = Query::default();
query.push(Clause::Field(Constraint {
field: "tags".into(),
comparison: Comparison::All(vec![ParseValue::Object(regex)]),
}));
let doc = transform_where(&schema(), &query).expect("lowers");
let all = doc
.get_document("tags")
.expect("tags")
.get_array("$all")
.expect("$all");
match &all[0] {
Bson::RegularExpression(r) => {
assert_eq!(r.pattern, "^ba");
assert_eq!(r.options, "");
}
other => panic!("expected a regex, got {other:?}"),
}
}
#[test]
fn a_dollar_key_is_refused_on_a_write_and_accepted_on_a_query() {
let mut regex = ParseMap::new();
regex.insert("$regex".to_string(), ParseValue::String("^x".to_string()));
let written = ParseValue::Array(vec![ParseValue::Object(regex.clone())]);
let refused = interior_value_to_bson(&written).expect_err("a write must refuse it");
assert_eq!(refused.code, parse_rust_core::ErrorCode::InvalidNestedKey);
assert_eq!(
refused.message,
"Nested keys should not contain the '$' or '.' characters"
);
let mut dotted = ParseMap::new();
dotted.insert("a.b".to_string(), ParseValue::Number(1.0));
assert_eq!(
interior_value_to_bson(&ParseValue::Object(dotted))
.expect_err("a dotted key is refused too")
.code,
parse_rust_core::ErrorCode::InvalidNestedKey
);
let queried = interior_query_atom_to_bson(&ParseValue::Object(regex)).expect("compiles");
assert!(
matches!(queried, Bson::RegularExpression(_)),
"a query atom still becomes a regex: {queried:?}"
);
}
#[test]
fn an_all_mixing_regexes_and_plain_values_is_refused() {
let mut regex = ParseMap::new();
regex.insert("$regex".to_string(), ParseValue::String("^ba".to_string()));
let mut query = Query::default();
query.push(Clause::Field(Constraint {
field: "tags".into(),
comparison: Comparison::All(vec![
ParseValue::Object(regex),
ParseValue::String("plain".to_string()),
]),
}));
let err = transform_where(&schema(), &query).expect_err("mixed $all");
assert_eq!(err.code, parse_rust_core::ErrorCode::InvalidJson);
}
#[test]
fn a_bare_object_id_is_prefixed_for_a_pointer_field() {
let schema = ClassSchema::new("Comment").with_field(
"author",
FieldType::Pointer {
target_class: "_User".to_string(),
},
);
for comparison in [
Comparison::Equal(ParseValue::String("abc123".into())),
Comparison::NotEqual(ParseValue::String("abc123".into())),
Comparison::In(vec![ParseValue::String("abc123".into())]),
] {
let mut query = Query::default();
query.push(Clause::Field(Constraint {
field: "author".into(),
comparison,
}));
let doc = transform_where(&schema, &query).expect("lowers");
let rendered = format!("{doc:?}");
assert!(
rendered.contains("_User$abc123"),
"the id must be prefixed with the declared target class: {rendered}"
);
}
}
#[test]
fn a_nested_geopoint_is_stored_as_its_envelope_rather_than_a_coordinate_pair() {
let nested = ParseValue::Array(vec![ParseValue::GeoPoint {
latitude: 1.0,
longitude: 2.0,
}]);
let Bson::Array(items) = interior_value_to_bson(&nested).expect("lowers") else {
panic!("expected an array");
};
let doc = match &items[0] {
Bson::Document(d) => d,
other => panic!("a nested GeoPoint must stay an object, got {other:?}"),
};
assert_eq!(doc.get_str("__type").ok(), Some("GeoPoint"));
}
#[test]
fn equality_against_an_array_field_becomes_a_single_element_all() {
let schema = ClassSchema::new("Post").with_field("tags", FieldType::Array);
let mut query = Query::default();
query.push(Clause::Field(Constraint {
field: "tags".into(),
comparison: Comparison::Equal(ParseValue::Pointer {
class_name: "Tag".into(),
object_id: "t1".into(),
}),
}));
let doc = transform_where(&schema, &query).expect("a pointer against an array field");
let all = doc
.get_document("tags")
.expect("tags")
.get_array("$all")
.expect("$all");
let Bson::Document(pointer) = &all[0] else {
panic!("expected the pointer envelope, got {:?}", all[0]);
};
assert_eq!(pointer.get_str("__type").ok(), Some("Pointer"));
assert_eq!(pointer.get_str("objectId").ok(), Some("t1"));
let mut query = Query::default();
query.push(Clause::Field(Constraint {
field: "tags".into(),
comparison: Comparison::Equal(ParseValue::Array(vec![ParseValue::String("a".into())])),
}));
let err = transform_where(&schema, &query).expect_err("upstream refuses this");
assert_eq!(err.code, parse_rust_core::ErrorCode::InvalidJson);
assert_eq!(err.message, "You cannot use a as a query parameter.");
}
#[test]
fn an_eq_keeps_its_wrapper_and_composes_with_another_operator() {
let mut query = Query::default();
query.push(Clause::Field(Constraint {
field: "views".into(),
comparison: Comparison::EqualOperator(ParseValue::Number(5.0)),
}));
query.push(Clause::Field(Constraint {
field: "views".into(),
comparison: Comparison::GreaterThan(ParseValue::Number(1.0)),
}));
let doc = transform_where(&schema(), &query).expect("lowers");
let views = doc
.get_document("views")
.expect("one document for the field");
assert_eq!(
views
.get_i32("$eq")
.or(views.get_f64("$eq").map(|v| v as i32))
.ok(),
Some(5)
);
assert!(
views.contains_key("$gt"),
"both operators survive: {views:?}"
);
}
#[test]
fn bare_equality_still_lowers_without_a_wrapper() {
let mut query = Query::default();
query.push(Clause::Field(Constraint {
field: "views".into(),
comparison: Comparison::Equal(ParseValue::Number(5.0)),
}));
let doc = transform_where(&schema(), &query).expect("lowers");
assert!(
doc.get_document("views").is_err(),
"shorthand equality is a plain value, not an operator document: {doc:?}"
);
}
}