Files
dbx/crates/dbx-core/src/mongo_shell.rs
T

2271 lines
89 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use chrono::{SecondsFormat, Utc};
use mongodb::bson::oid::ObjectId;
use serde::Serialize;
use serde_json::Value;
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "kind")]
pub enum MongoCommand {
#[serde(rename = "version")]
Version,
#[serde(rename = "use")]
Use { database: String },
#[serde(rename = "showDatabases")]
ShowDatabases,
#[serde(rename = "runCommand")]
RunCommand {
#[serde(rename = "commandJson")]
command_json: String,
},
#[serde(rename = "createUser")]
CreateUser {
#[serde(rename = "userJson")]
user_json: String,
#[serde(rename = "writeConcernJson")]
write_concern_json: Option<String>,
},
#[serde(rename = "find")]
Find {
collection: String,
filter: String,
projection: Option<String>,
sort: Option<String>,
collation: Option<String>,
skip: u64,
limit: i64,
},
#[serde(rename = "findExplain")]
FindExplain {
collection: String,
filter: String,
projection: Option<String>,
sort: Option<String>,
collation: Option<String>,
skip: u64,
limit: i64,
verbosity: String,
},
#[serde(rename = "findOne")]
FindOne { collection: String, filter: String, projection: Option<String>, options: Option<String> },
#[serde(rename = "countDocuments")]
Count { collection: String, filter: String, accurate: bool },
#[serde(rename = "aggregate")]
Aggregate { collection: String, pipeline: String, options: Option<String> },
#[serde(rename = "distinct")]
Distinct { collection: String, field: String, filter: Option<String> },
#[serde(rename = "getIndexes")]
GetIndexes { collection: String },
#[serde(rename = "collectionStats")]
CollectionStats { collection: String, metric: String, scale: Option<serde_json::Number> },
#[serde(rename = "insert")]
Insert {
collection: String,
#[serde(rename = "docsJson")]
documents: String,
},
#[serde(rename = "update")]
Update { collection: String, filter: String, update: String, options: Option<String>, many: bool },
#[serde(rename = "replace")]
Replace { collection: String, filter: String, replacement: String, options: Option<String> },
#[serde(rename = "delete")]
Delete { collection: String, filter: String, many: bool },
#[serde(rename = "createIndex")]
CreateIndex { collection: String, keys: String, options: Option<String> },
#[serde(rename = "dropIndexes")]
DropIndexes { collection: String, indexes: Option<String>, single: bool },
#[serde(rename = "dropCollection")]
DropCollection { collection: String },
#[serde(rename = "findOneAndUpdate")]
FindOneAndUpdate { collection: String, filter: String, update: String, options: Option<String> },
#[serde(rename = "findOneAndReplace")]
FindOneAndReplace { collection: String, filter: String, replacement: String, options: Option<String> },
#[serde(rename = "findOneAndDelete")]
FindOneAndDelete { collection: String, filter: String, options: Option<String> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MongoSafetyError {
WritesDisabled,
EmptyFilter,
Dangerous,
ProductionWrite,
}
impl MongoCommand {
pub fn is_mutating(&self) -> bool {
matches!(
self,
Self::RunCommand { .. }
| Self::CreateUser { .. }
| Self::Insert { .. }
| Self::Update { .. }
| Self::Replace { .. }
| Self::Delete { .. }
| Self::CreateIndex { .. }
| Self::DropIndexes { .. }
| Self::DropCollection { .. }
| Self::FindOneAndUpdate { .. }
| Self::FindOneAndReplace { .. }
| Self::FindOneAndDelete { .. }
) || matches!(self, Self::Aggregate { pipeline, .. } if aggregate_writes(pipeline))
}
pub fn is_dangerous(&self) -> bool {
matches!(self, Self::RunCommand { .. } | Self::CreateUser { .. } | Self::DropCollection { .. })
|| matches!(self, Self::DropIndexes { indexes: None, single: false, .. })
|| matches!(self, Self::Aggregate { pipeline, .. } if aggregate_writes(pipeline))
}
pub fn has_empty_filter(&self) -> bool {
match self {
Self::Update { filter, .. }
| Self::Replace { filter, .. }
| Self::Delete { filter, .. }
| Self::FindOneAndUpdate { filter, .. }
| Self::FindOneAndReplace { filter, .. }
| Self::FindOneAndDelete { filter, .. } => is_empty_object(filter),
_ => false,
}
}
pub fn has_effectively_unbounded_filter(&self) -> bool {
match self {
Self::Update { filter, .. }
| Self::Replace { filter, .. }
| Self::Delete { filter, .. }
| Self::FindOneAndUpdate { filter, .. }
| Self::FindOneAndReplace { filter, .. }
| Self::FindOneAndDelete { filter, .. } => mongo_filter_is_effectively_unbounded(filter),
_ => false,
}
}
}
pub fn validate_safety(
command: &MongoCommand,
allow_writes: bool,
allow_dangerous: bool,
production_database: bool,
) -> Result<(), MongoSafetyError> {
if command.is_mutating() && !allow_writes {
return Err(MongoSafetyError::WritesDisabled);
}
if command.has_effectively_unbounded_filter() && !allow_dangerous {
return Err(MongoSafetyError::EmptyFilter);
}
if command.is_dangerous() && !allow_dangerous {
return Err(MongoSafetyError::Dangerous);
}
if command.is_mutating() && production_database {
return Err(MongoSafetyError::ProductionWrite);
}
Ok(())
}
pub fn mongo_filter_is_effectively_unbounded(filter_json: &str) -> bool {
serde_json::from_str::<serde_json::Value>(filter_json)
.ok()
.as_ref()
.is_none_or(|value| mongo_filter_contains_opaque_logic(value) || mongo_filter_value_is_unbounded(value))
}
fn mongo_filter_contains_opaque_logic(value: &serde_json::Value) -> bool {
let Some(filter) = value.as_object() else {
return true;
};
filter.iter().any(|(key, value)| match key.as_str() {
"$comment" => false,
"$where" | "$expr" | "$nor" => true,
"$and" | "$or" => {
let Some(clauses) = value.as_array() else {
return true;
};
clauses.is_empty()
|| clauses.iter().any(|clause| !clause.is_object() || mongo_filter_contains_opaque_logic(clause))
|| (key == "$or"
&& clauses
.iter()
.any(|clause| clause.as_object().is_some_and(|document| document.contains_key("$and"))))
|| (key == "$or" && mongo_or_has_complementary_field_clauses(clauses))
}
_ => key.starts_with('$') || mongo_field_predicate_contains_opaque_logic(value),
})
}
fn mongo_field_predicate_contains_opaque_logic(value: &serde_json::Value) -> bool {
let Some(predicate) = value.as_object() else {
return false;
};
if mongo_extended_json_scalar_literal_is_valid(value) {
return false;
}
let has_operator = predicate.keys().any(|key| key.starts_with('$'));
has_operator
&& predicate.keys().any(|key| {
!matches!(key.as_str(), "$eq" | "$ne" | "$gt" | "$gte" | "$lt" | "$lte" | "$in" | "$nin" | "$exists")
})
}
fn mongo_extended_json_scalar_literal_is_valid(value: &serde_json::Value) -> bool {
let Some(wrapper) = value.as_object().filter(|wrapper| wrapper.len() == 1) else {
return false;
};
if let Some(value) = wrapper.get("$oid").and_then(serde_json::Value::as_str) {
return value.len() == 24 && value.bytes().all(|byte| byte.is_ascii_hexdigit());
}
if let Some(value) = wrapper.get("$numberLong").and_then(serde_json::Value::as_str) {
return value.parse::<i64>().is_ok();
}
wrapper
.get("$date")
.and_then(serde_json::Value::as_str)
.is_some_and(|value| chrono::DateTime::parse_from_rfc3339(value).is_ok())
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum MongoFieldOperator {
Eq,
Ne,
Gt,
Gte,
Lt,
Lte,
In,
Nin,
Exists,
}
struct MongoPureFieldPredicate<'a> {
field: &'a str,
operator: MongoFieldOperator,
operand: &'a serde_json::Value,
}
fn mongo_or_has_complementary_field_clauses(clauses: &[serde_json::Value]) -> bool {
clauses.iter().enumerate().any(|(index, clause)| {
let Some(predicate) = mongo_pure_field_predicate(clause) else {
return false;
};
clauses[index + 1..]
.iter()
.filter_map(mongo_pure_field_predicate)
.any(|other| mongo_field_predicates_are_complementary(&predicate, &other))
})
}
fn mongo_pure_field_predicate(value: &serde_json::Value) -> Option<MongoPureFieldPredicate<'_>> {
let filter = value.as_object()?;
let mut entries = filter.iter().filter(|(key, _)| key.as_str() != "$comment");
let (field, predicate) = entries.next()?;
if entries.next().is_some() {
return None;
}
if field == "$and" {
let clauses = predicate.as_array()?;
let mut bounded = clauses.iter().filter(|clause| !mongo_filter_value_is_unbounded(clause));
let clause = bounded.next()?;
if bounded.next().is_some() {
return None;
}
return mongo_pure_field_predicate(clause);
}
if field == "$or" {
let clauses = predicate.as_array()?;
return (clauses.len() == 1).then(|| mongo_pure_field_predicate(&clauses[0])).flatten();
}
if field.starts_with('$') {
return None;
}
let Some(operator_document) = predicate.as_object() else {
return Some(MongoPureFieldPredicate { field, operator: MongoFieldOperator::Eq, operand: predicate });
};
if mongo_extended_json_scalar_literal_is_valid(predicate)
|| !operator_document.keys().any(|key| key.starts_with('$'))
{
return Some(MongoPureFieldPredicate { field, operator: MongoFieldOperator::Eq, operand: predicate });
}
let mut operators = operator_document.iter();
let (operator, operand) = operators.next()?;
if operators.next().is_some() {
return None;
}
let operator = match operator.as_str() {
"$eq" => MongoFieldOperator::Eq,
"$ne" => MongoFieldOperator::Ne,
"$gt" => MongoFieldOperator::Gt,
"$gte" => MongoFieldOperator::Gte,
"$lt" => MongoFieldOperator::Lt,
"$lte" => MongoFieldOperator::Lte,
"$in" => MongoFieldOperator::In,
"$nin" => MongoFieldOperator::Nin,
"$exists" => MongoFieldOperator::Exists,
_ => return None,
};
Some(MongoPureFieldPredicate { field, operator, operand })
}
fn mongo_field_predicates_are_complementary(
left: &MongoPureFieldPredicate<'_>,
right: &MongoPureFieldPredicate<'_>,
) -> bool {
if left.field != right.field {
return false;
}
use MongoFieldOperator::{Eq, Exists, Gt, Gte, In, Lt, Lte, Ne, Nin};
match (left.operator, right.operator) {
(Exists, Exists) => {
left.operand.as_bool().zip(right.operand.as_bool()).is_some_and(|(left, right)| left != right)
}
(In, Nin) | (Nin, In) => mongo_json_sets_equal(left.operand, right.operand),
(Eq, Ne) | (Ne, Eq) | (Gt, Lte) | (Lte, Gt) | (Gte, Lt) | (Lt, Gte) => left.operand == right.operand,
_ => false,
}
}
fn mongo_json_sets_equal(left: &serde_json::Value, right: &serde_json::Value) -> bool {
let (Some(left), Some(right)) = (left.as_array(), right.as_array()) else {
return false;
};
left.iter().all(|value| right.contains(value)) && right.iter().all(|value| left.contains(value))
}
fn mongo_filter_value_is_unbounded(value: &serde_json::Value) -> bool {
let Some(filter) = value.as_object() else {
return true;
};
if filter.is_empty() || filter.contains_key("$where") || filter.contains_key("$expr") {
return true;
}
filter.iter().all(|(key, value)| match key.as_str() {
"$comment" => true,
"$and" => value
.as_array()
.is_none_or(|clauses| clauses.is_empty() || clauses.iter().all(mongo_filter_value_is_unbounded)),
"$or" => value
.as_array()
.is_none_or(|clauses| clauses.is_empty() || clauses.iter().any(mongo_filter_value_is_unbounded)),
"$nor" => true,
_ if mongo_field_predicate_is_empty_nin(value) => true,
"_id" if mongo_field_predicate_is_exists_true(value) => true,
_ => key.starts_with('$'),
})
}
fn mongo_field_predicate_is_empty_nin(value: &serde_json::Value) -> bool {
value.as_object().is_some_and(|predicate| {
predicate.len() == 1 && predicate.get("$nin").and_then(serde_json::Value::as_array).is_some_and(Vec::is_empty)
})
}
fn mongo_field_predicate_is_exists_true(value: &serde_json::Value) -> bool {
value.as_object().is_some_and(|predicate| {
predicate.len() == 1 && predicate.get("$exists").and_then(serde_json::Value::as_bool) == Some(true)
})
}
pub fn parse(input: &str) -> Result<MongoCommand, String> {
let show_source = trim_mongo_outer_comments(input).trim_end_matches(';').trim();
if parse_show_databases(show_source) {
return Ok(MongoCommand::ShowDatabases);
}
let source = input.trim().trim_end_matches(';').trim();
if source.eq_ignore_ascii_case("db.version()") {
return Ok(MongoCommand::Version);
}
if let Some(database) = parse_use_database(source) {
return Ok(MongoCommand::Use { database });
}
// `db.stats()` and `db.serverStatus()` are the shell's shorthand for the
// matching runCommand, so they execute through the same supported path.
for (method, command) in [("stats", "dbStats"), ("serverStatus", "serverStatus")] {
if let Some((args, tail)) = database_method_call(source, method) {
if !tail.is_empty() || !args.iter().all(|arg| arg.trim().is_empty()) {
return Err(format!("MongoDB db.{method}() takes no arguments."));
}
return Ok(MongoCommand::RunCommand { command_json: format!(r#"{{"{command}":1}}"#) });
}
}
if let Some((args, tail)) = database_method_call(source, "runCommand") {
if !tail.is_empty() || args.len() != 1 {
return Err("MongoDB runCommand() requires exactly one command document.".to_string());
}
let command_json = normalized_json(&args[0])?;
let command = parse_json_value(&command_json)
.and_then(|value| value.as_object().cloned())
.ok_or("MongoDB runCommand() requires a command document.")?;
if command.is_empty() {
return Err("MongoDB runCommand() requires a non-empty command document.".to_string());
}
return Ok(MongoCommand::RunCommand { command_json });
}
if let Some((args, tail)) = database_method_call(source, "createUser") {
if !tail.is_empty() || !(1..=2).contains(&args.len()) {
return Err("MongoDB createUser() requires a user document and optional write concern.".to_string());
}
let user_json = normalized_json(&args[0])?;
let user = parse_json_value(&user_json)
.and_then(|value| value.as_object().cloned())
.ok_or("MongoDB createUser() requires a user document.")?;
if user.get("user").and_then(Value::as_str).is_none_or(|user| user.trim().is_empty()) {
return Err("MongoDB createUser() requires a non-empty user name.".to_string());
}
let write_concern_json = optional_json_argument(args.get(1))?;
if write_concern_json.as_deref().and_then(parse_json_value).is_some_and(|value| !value.is_object()) {
return Err("MongoDB createUser() write concern must be a document.".to_string());
}
return Ok(MongoCommand::CreateUser { user_json, write_concern_json });
}
let (collection, prefix_end) = parse_collection_prefix(source)?;
if let Some((args, tail)) = method_call(source, prefix_end, "find") {
let filter = normalized_json(args.first().map(String::as_str).unwrap_or("{}"))?;
let projection =
if args.get(1).is_some_and(|arg| !arg.trim().is_empty()) { Some(normalized_json(&args[1])?) } else { None };
if args.len() > 2 {
return Err("MongoDB find() accepts at most filter and projection arguments.".to_string());
}
let mut sort = None;
let mut collation = None;
let mut skip = 0;
let mut limit = 100;
let calls = chained_calls(&tail)?;
let call_count = calls.len();
for (index, (name, call_args)) in calls.into_iter().enumerate() {
match name.as_str() {
"sort" => sort = Some(normalized_json(call_args.first().map(String::as_str).unwrap_or("{}"))?),
"collation" => {
if call_args.len() != 1 {
return Err("MongoDB collation() requires one options object.".to_string());
}
collation = Some(normalized_json(&call_args[0])?);
}
"skip" => skip = parse_integer(&call_args, "skip")? as u64,
"limit" => limit = parse_integer(&call_args, "limit")?,
"count" if call_args.is_empty() => {
return Ok(MongoCommand::Count { collection, filter, accurate: false });
}
"explain" => {
if index + 1 != call_count {
return Err("MongoDB explain() must be the final find() chain operation.".to_string());
}
return Ok(MongoCommand::FindExplain {
collection,
filter,
projection,
sort,
collation,
skip,
limit,
verbosity: parse_explain_verbosity(&call_args)?,
});
}
_ if is_noop_cursor_call(&name, &call_args) => {}
_ => return Err(format!("Unsupported MongoDB find() chain: {name}()")),
}
}
return Ok(MongoCommand::Find { collection, filter, projection, sort, collation, skip, limit });
}
if let Some((args, tail)) = method_call(source, prefix_end, "findOne") {
if !tail.is_empty() || args.len() > 3 {
return Err("Invalid MongoDB findOne() command.".to_string());
}
return Ok(MongoCommand::FindOne {
collection,
filter: normalized_json(args.first().map(String::as_str).unwrap_or("{}"))?,
projection: optional_json_argument(args.get(1))?,
options: optional_json_argument(args.get(2))?,
});
}
for method in ["findOneAndUpdate", "findOneAndReplace"] {
if let Some((args, tail)) = method_call(source, prefix_end, method) {
if !tail.is_empty() || !(2..=3).contains(&args.len()) {
return Err(format!("Invalid MongoDB {method}() command."));
}
let filter = normalized_json(&args[0])?;
let value = normalized_json(&args[1])?;
let options = optional_json_argument(args.get(2))?;
return Ok(if method == "findOneAndUpdate" {
MongoCommand::FindOneAndUpdate { collection, filter, update: value, options }
} else {
MongoCommand::FindOneAndReplace { collection, filter, replacement: value, options }
});
}
}
if let Some((args, tail)) = method_call(source, prefix_end, "findOneAndDelete") {
if !tail.is_empty() || !(1..=2).contains(&args.len()) {
return Err("Invalid MongoDB findOneAndDelete() command.".to_string());
}
return Ok(MongoCommand::FindOneAndDelete {
collection,
filter: normalized_json(&args[0])?,
options: optional_json_argument(args.get(1))?,
});
}
// estimatedDocumentCount() takes no filter and is metadata-backed, which is
// exactly the legacy count() fast path the driver already uses.
if let Some((args, tail)) = method_call(source, prefix_end, "estimatedDocumentCount") {
if !args.iter().all(|arg| arg.trim().is_empty()) {
return Err("MongoDB estimatedDocumentCount() takes no filter.".to_string());
}
if !tail.is_empty() {
return Err("MongoDB estimatedDocumentCount() does not support chained methods.".to_string());
}
return Ok(MongoCommand::Count { collection, filter: "{}".to_string(), accurate: false });
}
for (method, accurate) in [("countDocuments", true), ("count", false)] {
if let Some((args, tail)) = method_call(source, prefix_end, method) {
if !tail.is_empty() || args.len() > 1 {
return Err(format!("Invalid MongoDB {method}() command."));
}
return Ok(MongoCommand::Count {
collection,
filter: normalized_json(args.first().map(String::as_str).unwrap_or("{}"))?,
accurate,
});
}
}
if let Some((args, tail)) = method_call(source, prefix_end, "aggregate") {
if !(1..=2).contains(&args.len()) {
return Err("Invalid MongoDB aggregate() command.".to_string());
}
for (name, call_args) in chained_calls(&tail)? {
if !is_noop_cursor_call(&name, &call_args) {
return Err(format!(
"Unsupported MongoDB aggregate() chain: {name}(). Use pipeline stages such as $sort and $limit instead."
));
}
}
let pipeline = normalized_json(&args[0])?;
if !parse_json_value(&pipeline).is_some_and(|value| value.is_array()) {
return Err("MongoDB aggregate() requires a pipeline array.".to_string());
}
let options = args.get(1).filter(|arg| !arg.trim().is_empty()).map(|arg| normalized_json(arg)).transpose()?;
return Ok(MongoCommand::Aggregate { collection, pipeline, options });
}
if let Some((args, tail)) = method_call(source, prefix_end, "distinct") {
if !tail.is_empty() || !(1..=2).contains(&args.len()) {
return Err("Invalid MongoDB distinct() command.".to_string());
}
let field = parse_string_arg(&args[0])?;
let filter = args.get(1).filter(|arg| !arg.trim().is_empty()).map(|arg| normalized_json(arg)).transpose()?;
return Ok(MongoCommand::Distinct { collection, field, filter });
}
if let Some((args, tail)) = method_call(source, prefix_end, "getIndexes") {
if !tail.is_empty() || !args.is_empty() {
return Err("Invalid MongoDB getIndexes() command.".to_string());
}
return Ok(MongoCommand::GetIndexes { collection });
}
for metric in ["stats", "dataSize", "storageSize", "totalIndexSize"] {
if let Some((args, tail)) = method_call(source, prefix_end, metric) {
if !tail.is_empty() || args.len() > 1 {
return Err(format!("Invalid MongoDB {metric}() command."));
}
let scale = args
.first()
.filter(|arg| !arg.trim().is_empty())
.map(|arg| {
arg.trim()
.parse::<f64>()
.ok()
.and_then(serde_json::Number::from_f64)
.ok_or_else(|| format!("Invalid {metric} scale."))
})
.transpose()?;
return Ok(MongoCommand::CollectionStats { collection, metric: metric.to_string(), scale });
}
}
if let Some((args, tail)) = method_call(source, prefix_end, "insertOne") {
if !tail.is_empty() || args.len() != 1 {
return Err("Invalid MongoDB insertOne() command.".to_string());
}
return Ok(MongoCommand::Insert { collection, documents: normalized_json(&args[0])? });
}
if let Some((args, tail)) = method_call(source, prefix_end, "insertMany") {
if !tail.is_empty() || args.len() != 1 {
return Err("Invalid MongoDB insertMany() command.".to_string());
}
let documents = normalized_json(&args[0])?;
if !parse_json_value(&documents).is_some_and(|value| value.is_array()) {
return Err("MongoDB insertMany() requires an array.".to_string());
}
return Ok(MongoCommand::Insert { collection, documents });
}
// MongoDB keeps insert() for legacy shell compatibility; preserve its
// single-document-or-array contract without silently ignoring options.
if let Some((args, tail)) = method_call(source, prefix_end, "insert") {
if !tail.is_empty() || args.len() != 1 {
return Err("Invalid MongoDB insert() command.".to_string());
}
let documents = normalized_json(&args[0])?;
if !parse_json_value(&documents).is_some_and(|value| value.is_object() || value.is_array()) {
return Err("MongoDB insert() requires a document or document array.".to_string());
}
return Ok(MongoCommand::Insert { collection, documents });
}
if let Some((args, tail)) = method_call(source, prefix_end, "replaceOne") {
if !tail.is_empty() || !(2..=3).contains(&args.len()) {
return Err(
"MongoDB replaceOne() requires a filter, a replacement document, and optional options.".to_string()
);
}
let replacement = normalized_json(&args[1])?;
require_replacement_document(&replacement)?;
return Ok(MongoCommand::Replace {
collection,
filter: normalized_json(&args[0])?,
replacement,
options: args.get(2).filter(|arg| !arg.trim().is_empty()).map(|arg| normalized_json(arg)).transpose()?,
});
}
for (method, many) in [("updateOne", false), ("updateMany", true)] {
if let Some((args, tail)) = method_call(source, prefix_end, method) {
if !tail.is_empty() || !(2..=3).contains(&args.len()) {
return Err(format!("Invalid MongoDB {method}() command."));
}
return Ok(MongoCommand::Update {
collection,
filter: normalized_json(&args[0])?,
update: normalized_json(&args[1])?,
options: args
.get(2)
.filter(|arg| !arg.trim().is_empty())
.map(|arg| normalized_json(arg))
.transpose()?,
many,
});
}
}
if let Some((args, tail)) = method_call(source, prefix_end, "update") {
if !tail.is_empty() || !(2..=3).contains(&args.len()) {
return Err("Invalid MongoDB update() command.".to_string());
}
let (options, many) = legacy_update_options(args.get(2))?;
return Ok(MongoCommand::Update {
collection,
filter: normalized_json(&args[0])?,
update: normalized_json(&args[1])?,
options,
many,
});
}
for (method, many) in [("deleteOne", false), ("deleteMany", true)] {
if let Some((args, tail)) = method_call(source, prefix_end, method) {
if !tail.is_empty() || args.len() != 1 {
return Err(format!("Invalid MongoDB {method}() command."));
}
return Ok(MongoCommand::Delete { collection, filter: normalized_json(&args[0])?, many });
}
}
if let Some((args, tail)) = method_call(source, prefix_end, "createIndex") {
if !tail.is_empty() || !(1..=2).contains(&args.len()) {
return Err("Invalid MongoDB createIndex() command.".to_string());
}
return Ok(MongoCommand::CreateIndex {
collection,
keys: normalized_json(&args[0])?,
options: args.get(1).filter(|arg| !arg.trim().is_empty()).map(|arg| normalized_json(arg)).transpose()?,
});
}
if let Some((args, tail)) = method_call(source, prefix_end, "dropIndex") {
if !tail.is_empty() || args.len() != 1 {
return Err("Invalid MongoDB dropIndex() command.".to_string());
}
return Ok(MongoCommand::DropIndexes { collection, indexes: Some(normalized_json(&args[0])?), single: true });
}
if let Some((args, tail)) = method_call(source, prefix_end, "dropIndexes") {
if !tail.is_empty() || args.len() > 1 {
return Err("Invalid MongoDB dropIndexes() command.".to_string());
}
return Ok(MongoCommand::DropIndexes {
collection,
indexes: args.first().filter(|arg| !arg.trim().is_empty()).map(|arg| normalized_json(arg)).transpose()?,
single: false,
});
}
if let Some((args, tail)) = method_call(source, prefix_end, "drop") {
if !tail.is_empty() || !args.is_empty() {
return Err("Invalid MongoDB drop() command.".to_string());
}
return Ok(MongoCommand::DropCollection { collection });
}
Err("Unsupported MongoDB shell command.".to_string())
}
fn parse_show_databases(source: &str) -> bool {
let mut words = source.split_whitespace();
words.next().is_some_and(|word| word.eq_ignore_ascii_case("show"))
&& words.next().is_some_and(|word| word.eq_ignore_ascii_case("dbs") || word.eq_ignore_ascii_case("databases"))
&& words.next().is_none()
}
fn trim_mongo_outer_comments(mut source: &str) -> &str {
loop {
source = source.trim_start();
if source.starts_with("//") || source.starts_with("--") {
source = source
.char_indices()
.find_map(|(index, character)| (character == '\n' || character == '\r').then_some(&source[index + 1..]))
.unwrap_or("");
continue;
}
if source.starts_with("/*") {
let Some(end) = source.find("*/") else {
return source;
};
source = &source[end + 2..];
continue;
}
break;
}
let source = source.trim_end();
let mut body_end = 0;
let mut quote = None;
let mut escaped = false;
let mut line_comment = false;
let mut block_comment = false;
let mut characters = source.char_indices().peekable();
while let Some((index, character)) = characters.next() {
let next = characters.peek().map(|(_, character)| *character);
if line_comment {
if character == '\n' || character == '\r' {
line_comment = false;
}
continue;
}
if block_comment {
if character == '*' && next == Some('/') {
block_comment = false;
characters.next();
}
continue;
}
if let Some(delimiter) = quote {
body_end = index + character.len_utf8();
if escaped {
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == delimiter {
quote = None;
}
continue;
}
if (character == '/' && next == Some('/')) || (character == '-' && next == Some('-')) {
line_comment = true;
characters.next();
continue;
}
if character == '/' && next == Some('*') {
block_comment = true;
characters.next();
continue;
}
if matches!(character, '"' | '\'' | '`') {
quote = Some(character);
}
if !character.is_whitespace() {
body_end = index + character.len_utf8();
}
}
source[..body_end].trim_end()
}
fn parse_collection_prefix(source: &str) -> Result<(String, usize), String> {
// db["orders-2024"] reaches names that are not valid identifiers, the same way the shell does.
if source.get(..3).is_some_and(|prefix| prefix.eq_ignore_ascii_case("db[")) {
let close = matching_bracket(source, 2).ok_or("Invalid db[\"collection\"] accessor.")?;
let collection = parse_string_arg(source[3..close].trim())?;
if collection.is_empty() {
return Err("Invalid MongoDB collection name.".to_string());
}
let end = close + 1;
let suffix = &source[end..];
let trimmed = suffix.trim_start();
if !trimmed.starts_with('.') {
return Err("MongoDB collection method is required.".to_string());
}
return Ok((collection, end + suffix.len() - trimmed.len()));
}
if !source.get(..3).is_some_and(|prefix| prefix.eq_ignore_ascii_case("db.")) {
return Err("MongoDB command must start with db.<collection>.".to_string());
}
let rest = &source[3..];
if rest.starts_with("getCollection") {
let open = rest.find('(').ok_or("Invalid db.getCollection() command.")?;
let close = matching_paren(rest, open).ok_or("Invalid db.getCollection() command.")?;
let args = split_top_level(&rest[open + 1..close]);
if args.len() != 1 {
return Err("db.getCollection() requires one collection name.".to_string());
}
let collection = parse_string_arg(&args[0])?;
let end = 3 + close + 1;
let suffix = &source[end..];
let trimmed = suffix.trim_start();
if !trimmed.starts_with('.') {
return Err("MongoDB collection method is required.".to_string());
}
return Ok((collection, end + suffix.len() - trimmed.len()));
}
let collection_end = rest
.char_indices()
.find_map(|(index, ch)| (ch == '.' || ch.is_whitespace()).then_some(index))
.ok_or("MongoDB collection method is required.")?;
let collection = &rest[..collection_end];
if collection.is_empty() {
return Err("Invalid MongoDB collection name.".to_string());
}
let suffix = &rest[collection_end..];
let dot = suffix.find('.').ok_or("MongoDB collection method is required.")?;
if !suffix[..dot].trim().is_empty() {
return Err("Invalid MongoDB collection name.".to_string());
}
Ok((collection.to_string(), 3 + collection_end + dot))
}
fn method_call(source: &str, prefix_end: usize, method: &str) -> Option<(Vec<String>, String)> {
let raw_suffix = &source[prefix_end..];
let suffix = raw_suffix.trim_start();
let whitespace = raw_suffix.len() - suffix.len();
let expected = format!(".{method}");
if !suffix.starts_with(&expected) || !suffix[expected.len()..].starts_with('(') {
return None;
}
let open = prefix_end + whitespace + expected.len();
let close = matching_paren(source, open)?;
Some((split_top_level(&source[open + 1..close]), source[close + 1..].trim().to_string()))
}
fn database_method_call(source: &str, method: &str) -> Option<(Vec<String>, String)> {
if !source.get(..2).is_some_and(|prefix| prefix.eq_ignore_ascii_case("db")) {
return None;
}
let after_db = source.get(2..)?.trim_start();
let after_dot = after_db.strip_prefix('.')?.trim_start();
if !after_dot.get(..method.len()).is_some_and(|name| name.eq_ignore_ascii_case(method)) {
return None;
}
let after_method = after_dot.get(method.len()..)?.trim_start();
if !after_method.starts_with('(') {
return None;
}
let open = source.len() - after_method.len();
let close = matching_paren(source, open)?;
Some((split_top_level(&source[open + 1..close]), source[close + 1..].trim().to_string()))
}
/// Cursor methods that change nothing here: results are always materialised, so
/// the `.toArray()` that mongosh and Compass append can simply be dropped.
fn is_noop_cursor_call(name: &str, args: &[String]) -> bool {
matches!(name, "toArray" | "pretty") && args.is_empty()
}
fn chained_calls(chain: &str) -> Result<Vec<(String, Vec<String>)>, String> {
let mut rest = chain.trim();
let mut calls = Vec::new();
while !rest.is_empty() {
let Some(rest_after_dot) = rest.strip_prefix('.') else {
return Err("Invalid MongoDB method chain.".to_string());
};
let open = rest_after_dot.find('(').ok_or("Invalid MongoDB method chain.")?;
let name = rest_after_dot[..open].trim().to_string();
let close = matching_paren(rest_after_dot, open).ok_or("Invalid MongoDB method chain.")?;
calls.push((name, split_top_level(&rest_after_dot[open + 1..close])));
rest = rest_after_dot[close + 1..].trim();
}
Ok(calls)
}
fn parse_integer(args: &[String], name: &str) -> Result<i64, String> {
if args.len() != 1 {
return Err(format!("MongoDB {name}() requires one integer."));
}
let value =
args[0].trim().parse::<i64>().map_err(|_| format!("MongoDB {name}() requires a non-negative integer."))?;
if value < 0 {
return Err(format!("MongoDB {name}() requires a non-negative integer."));
}
Ok(value)
}
fn parse_string_arg(arg: &str) -> Result<String, String> {
let value = parse_json_value(&normalized_json(arg)?).ok_or("Invalid MongoDB string argument.")?;
value.as_str().map(ToOwned::to_owned).ok_or_else(|| "MongoDB argument must be a string.".to_string())
}
fn parse_explain_verbosity(args: &[String]) -> Result<String, String> {
if args.len() > 1 {
return Err("MongoDB explain() accepts at most one verbosity string.".to_string());
}
let verbosity = match args.first() {
Some(value) => parse_string_arg(value)?,
None => "queryPlanner".to_string(),
};
match verbosity.as_str() {
"queryPlanner" | "executionStats" | "allPlansExecution" => Ok(verbosity),
_ => Err("MongoDB explain() verbosity must be queryPlanner, executionStats, or allPlansExecution.".to_string()),
}
}
fn normalized_json(input: &str) -> Result<String, String> {
let transformed = transform_shell_regex_literals(input.trim())?;
let transformed = transform_shell_constructors(&transformed)?;
let value: Value =
json5::from_str(&transformed).map_err(|error| format!("Invalid MongoDB JSON argument: {error}"))?;
serde_json::to_string(&value).map_err(|error| error.to_string())
}
struct ShellRegexLiteral {
end: usize,
pattern: String,
options: String,
}
fn shell_regex_literal_at(input: &str, index: usize) -> Result<Option<ShellRegexLiteral>, String> {
if !input[index..].starts_with('/') || !is_shell_regex_value_position(input, index) {
return Ok(None);
}
read_shell_regex_literal(input, index).map(Some)
}
fn read_shell_regex_literal(input: &str, index: usize) -> Result<ShellRegexLiteral, String> {
let mut cursor = index + 1;
let mut pattern = String::new();
let mut escaped = false;
let mut in_character_class = false;
let mut closed = false;
while cursor < input.len() {
let current = input[cursor..].chars().next().ok_or("Invalid MongoDB regex literal.")?;
if matches!(current, '\n' | '\r' | '\u{2028}' | '\u{2029}') {
return Err("MongoDB regex literals cannot contain an unescaped line break.".to_string());
}
cursor += current.len_utf8();
if escaped {
pattern.push(current);
escaped = false;
continue;
}
if current == '\\' {
pattern.push(current);
escaped = true;
continue;
}
if current == '[' {
in_character_class = true;
} else if current == ']' && in_character_class {
in_character_class = false;
} else if current == '/' && !in_character_class {
closed = true;
break;
}
pattern.push(current);
}
if !closed {
return Err("Unclosed MongoDB regex literal.".to_string());
}
let mut options = Vec::new();
while cursor < input.len() {
let option = input[cursor..].chars().next().ok_or("Invalid MongoDB regex literal.")?;
if !option.is_ascii_alphabetic() {
break;
}
// JS-only regex flags (d/g/v/y) have no server-side meaning for a
// stored regex literal — MongoDB's $regex has no global modifier — so
// drop them instead of failing the whole command.
if matches!(option, 'd' | 'g' | 'v' | 'y') {
cursor += option.len_utf8();
continue;
}
if !matches!(option, 'i' | 'm' | 's' | 'u') || options.contains(&option) {
return Err(format!("Unsupported or duplicate MongoDB regex option: {option}"));
}
options.push(option);
cursor += option.len_utf8();
}
options.sort_unstable();
Ok(ShellRegexLiteral { end: cursor, pattern, options: options.into_iter().collect() })
}
fn transform_shell_regex_literals(input: &str) -> Result<String, String> {
let mut output = String::with_capacity(input.len());
let mut index = 0;
while index < input.len() {
let rest = &input[index..];
let ch = rest.chars().next().ok_or("Invalid MongoDB argument.")?;
if matches!(ch, '"' | '\'') {
let start = index;
index += ch.len_utf8();
let mut escaped = false;
while index < input.len() {
let current = input[index..].chars().next().ok_or("Invalid MongoDB argument.")?;
index += current.len_utf8();
if escaped {
escaped = false;
} else if current == '\\' {
escaped = true;
} else if current == ch {
break;
}
}
output.push_str(&input[start..index]);
continue;
}
if rest.starts_with("//") {
let end = rest.find(['\n', '\r']).map(|offset| index + offset).unwrap_or(input.len());
output.push_str(&input[index..end]);
index = end;
continue;
}
if rest.starts_with("/*") {
let end = rest.find("*/").map(|offset| index + offset + 2).unwrap_or(input.len());
output.push_str(&input[index..end]);
index = end;
continue;
}
if ch != '/' || !is_shell_regex_value_position(input, index) {
output.push(ch);
index += ch.len_utf8();
continue;
}
let literal = shell_regex_literal_at(input, index)?.ok_or("Invalid MongoDB regex literal.")?;
output.push_str(
&serde_json::to_string(&serde_json::json!({
"$regularExpression": {
"pattern": literal.pattern,
"options": literal.options,
}
}))
.map_err(|error| error.to_string())?,
);
index = literal.end;
}
Ok(output)
}
fn is_shell_regex_value_position(input: &str, index: usize) -> bool {
let mut previous_significant = None;
let mut cursor = 0;
while cursor < index {
let rest = &input[cursor..];
let current = rest.chars().next().expect("cursor is on a character boundary");
if matches!(current, '"' | '\'') {
let quote = current;
cursor += current.len_utf8();
let mut escaped = false;
while cursor < index {
let character = input[cursor..].chars().next().expect("cursor is on a character boundary");
cursor += character.len_utf8();
if escaped {
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == quote {
break;
}
}
previous_significant = Some('\0');
continue;
}
if rest.starts_with("//") {
cursor = rest.find(['\n', '\r']).map(|offset| cursor + offset).unwrap_or(index).min(index);
continue;
}
if rest.starts_with("/*") {
cursor = rest.find("*/").map(|offset| cursor + offset + 2).unwrap_or(index).min(index);
continue;
}
if current == '/' && is_shell_regex_value_prefix(previous_significant) {
if let Ok(literal) = read_shell_regex_literal(input, cursor) {
if literal.end <= index {
previous_significant = Some('\0');
cursor = literal.end;
continue;
}
}
}
if !current.is_whitespace() {
previous_significant = Some(current);
}
cursor += current.len_utf8();
}
is_shell_regex_value_prefix(previous_significant)
}
fn is_shell_regex_value_prefix(previous_significant: Option<char>) -> bool {
previous_significant.is_none_or(|character| matches!(character, ':' | '[' | ',' | '('))
}
/// Shell value constructors rewritten to extended JSON before json5 parsing.
/// `Date` is only recognised after `new`, matching the shell where a bare `Date()`
/// returns a string rather than a date.
const SHELL_CONSTRUCTORS: [&str; 11] = [
"ObjectId",
"ISODate",
"Date",
"NumberLong",
"NumberInt",
"NumberDecimal",
"UUID",
"BinData",
"Timestamp",
"MinKey",
"MaxKey",
];
fn transform_shell_constructors(input: &str) -> Result<String, String> {
let mut output = String::with_capacity(input.len());
let mut index = 0;
while index < input.len() {
let rest = &input[index..];
let ch = rest.chars().next().ok_or("Invalid MongoDB argument.")?;
if matches!(ch, '"' | '\'') {
let start = index;
index += ch.len_utf8();
let mut escaped = false;
while index < input.len() {
let current = input[index..].chars().next().ok_or("Invalid MongoDB argument.")?;
index += current.len_utf8();
if escaped {
escaped = false;
} else if current == '\\' {
escaped = true;
} else if current == ch {
break;
}
}
output.push_str(&input[start..index]);
continue;
}
if let Some((json, end)) = shell_constructor_call(input, index)? {
output.push_str(&json);
index = end;
continue;
}
if let Some((json, end)) = shell_bare_constant(input, index) {
output.push_str(&json);
index = end;
continue;
}
output.push(ch);
index += ch.len_utf8();
}
Ok(output)
}
/// Rewrite a bare `MinKey` / `MaxKey` at `index`, as in `{ $lt: MaxKey }`.
/// A following `:` means it is an object key, not a value.
fn shell_bare_constant(input: &str, index: usize) -> Option<(String, usize)> {
if input[..index].ends_with(|ch: char| ch.is_alphanumeric() || matches!(ch, '_' | '$' | '.')) {
return None;
}
let rest = &input[index..];
let name = ["MinKey", "MaxKey"].into_iter().find(|name| rest.starts_with(name))?;
let after = &rest[name.len()..];
if after.starts_with(|ch: char| ch.is_alphanumeric() || matches!(ch, '_' | '$')) {
return None;
}
if after.trim_start().starts_with([':', '(']) {
return None;
}
Some((key_constant_json(name), index + name.len()))
}
fn key_constant_json(name: &str) -> String {
if name == "MinKey" {
r#"{"$minKey":1}"#.to_string()
} else {
r#"{"$maxKey":1}"#.to_string()
}
}
/// Rewrite one `Name(...)` / `new Name(...)` call at `index`, or None when it is not a known constructor.
fn shell_constructor_call(input: &str, index: usize) -> Result<Option<(String, usize)>, String> {
let rest = &input[index..];
let (name_start, is_new) = match rest.strip_prefix("new") {
Some(after) if after.starts_with(|ch: char| ch.is_whitespace()) => {
(index + 3 + after.len() - after.trim_start().len(), true)
}
_ => (index, false),
};
let name_len = input[name_start..]
.find(|ch: char| !ch.is_alphanumeric() && ch != '_' && ch != '$')
.unwrap_or(input.len() - name_start);
let name = &input[name_start..name_start + name_len];
if !SHELL_CONSTRUCTORS.contains(&name) {
return Ok(None);
}
if name == "Date" && !is_new {
return Ok(None);
}
let after_name = &input[name_start + name_len..];
let paren_offset = after_name.len() - after_name.trim_start().len();
if !after_name[paren_offset..].starts_with('(') {
return Ok(None);
}
let open = name_start + name_len + paren_offset;
let close = matching_paren(input, open).ok_or("Unclosed MongoDB value constructor.")?;
let inner = input[open + 1..close].trim();
Ok(Some((shell_constructor_to_extended_json(name, inner)?, close + 1)))
}
fn shell_constructor_to_extended_json(name: &str, inner: &str) -> Result<String, String> {
if matches!(name, "BinData" | "Timestamp") {
return two_argument_constructor_to_extended_json(name, inner);
}
let integer = inner.parse::<i64>().ok();
match name {
"MinKey" | "MaxKey" if inner.is_empty() => Ok(key_constant_json(name)),
"MinKey" | "MaxKey" => Err(format!("MongoDB {name}() takes no arguments.")),
"UUID" if inner.is_empty() => Ok(extended_json("$uuid", &Uuid::new_v4().to_string())),
"UUID" => Ok(extended_json("$uuid", &parse_uuid_arg(inner)?)),
"ObjectId" if inner.is_empty() => Ok(extended_json("$oid", &ObjectId::new().to_hex())),
"ObjectId" => Ok(extended_json("$oid", &parse_string_arg(inner)?)),
"ISODate" | "Date" if inner.is_empty() => {
Ok(extended_json("$date", &Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)))
}
// `new Date(1735689600000)` takes epoch milliseconds, which extended JSON
// carries as a nested $numberLong rather than a bare number.
"ISODate" | "Date" => match integer {
Some(millis) => Ok(format!(r#"{{"$date":{{"$numberLong":"{millis}"}}}}"#)),
None => Ok(extended_json("$date", &parse_string_arg(inner)?)),
},
"NumberLong" | "NumberInt" => {
let key = if name == "NumberLong" { "$numberLong" } else { "$numberInt" };
let value = match integer {
Some(value) => value.to_string(),
None => parse_string_arg(inner)?,
};
value.parse::<i64>().map_err(|_| format!("MongoDB {name}() requires an integer argument."))?;
Ok(extended_json(key, &value))
}
"NumberDecimal" => {
let value = match parse_json_value(inner) {
Some(Value::Number(number)) => number.to_string(),
_ => parse_string_arg(inner)?,
};
value.parse::<f64>().map_err(|_| "MongoDB NumberDecimal() requires a numeric argument.".to_string())?;
Ok(extended_json("$numberDecimal", &value))
}
_ => Err(format!("Unsupported MongoDB value constructor {name}().")),
}
}
/// `UUID("...")` takes the canonical 8-4-4-4-12 hex form, matching mongosh
/// (hex digits only, dashes required, upper or lower case).
fn parse_uuid_arg(arg: &str) -> Result<String, String> {
let value = parse_string_arg(arg)?;
if is_canonical_uuid(&value) {
Ok(value)
} else {
Err("MongoDB UUID() requires a canonical 8-4-4-4-12 hex string.".to_string())
}
}
fn is_canonical_uuid(value: &str) -> bool {
let bytes = value.as_bytes();
bytes.len() == 36
&& bytes.iter().enumerate().all(|(index, byte)| match index {
8 | 13 | 18 | 23 => *byte == b'-',
_ => byte.is_ascii_hexdigit(),
})
}
fn two_argument_constructor_to_extended_json(name: &str, inner: &str) -> Result<String, String> {
let args = split_top_level(inner);
if args.len() != 2 {
return Err(format!("MongoDB {name}() requires exactly two arguments."));
}
if name == "Timestamp" {
// Timestamp(t, i): two unsigned 32-bit integers, seconds and ordinal.
let parse = |value: &str| value.trim().parse::<u32>();
return match (parse(&args[0]), parse(&args[1])) {
(Ok(seconds), Ok(ordinal)) => Ok(format!(r#"{{"$timestamp":{{"t":{seconds},"i":{ordinal}}}}}"#)),
_ => Err("MongoDB Timestamp() requires two unsigned 32-bit integers.".to_string()),
};
}
// BinData(subType, base64): extended JSON carries the subtype as two hex digits.
let sub_type = args[0]
.trim()
.parse::<u8>()
.map_err(|_| "MongoDB BinData() subtype must be an integer from 0 to 255.".to_string())?;
let base64 = parse_string_arg(&args[1])?;
Ok(format!(
r#"{{"$binary":{{"base64":{},"subType":"{sub_type:02x}"}}}}"#,
serde_json::to_string(&base64).unwrap_or_else(|_| "null".to_string())
))
}
fn extended_json(key: &str, value: &str) -> String {
format!("{{\"{key}\":{}}}", serde_json::to_string(value).unwrap_or_else(|_| "null".to_string()))
}
fn parse_json_value(value: &str) -> Option<Value> {
serde_json::from_str(value).ok()
}
fn optional_json_argument(value: Option<&String>) -> Result<Option<String>, String> {
value.filter(|value| !value.trim().is_empty()).map(|value| normalized_json(value)).transpose()
}
fn legacy_update_options(value: Option<&String>) -> Result<(Option<String>, bool), String> {
let Some(value) = value.filter(|value| !value.trim().is_empty()) else {
return Ok((None, false));
};
let normalized = normalized_json(value)?;
let value = parse_json_value(&normalized).ok_or("Invalid MongoDB update() options.")?;
let Value::Object(mut options) = value else {
return Err("MongoDB update() options must be a document.".to_string());
};
let many = match options.remove("multi") {
Some(Value::Bool(many)) => many,
Some(_) => return Err("MongoDB update() multi option must be a boolean.".to_string()),
None => false,
};
let options = if options.is_empty() {
None
} else {
Some(serde_json::to_string(&Value::Object(options)).map_err(|error| error.to_string())?)
};
Ok((options, many))
}
fn parse_use_database(source: &str) -> Option<String> {
let mut parts = source.split_whitespace();
if !parts.next()?.eq_ignore_ascii_case("use") {
return None;
}
let database = parts.next()?;
if parts.next().is_some()
|| database.is_empty()
|| !database.chars().all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
{
return None;
}
Some(database.to_string())
}
/// A replacement is a whole document; `{$set: ...}` here almost always means updateOne() was intended.
fn require_replacement_document(replacement: &str) -> Result<(), String> {
let Some(Value::Object(document)) = parse_json_value(replacement) else {
return Err("MongoDB replaceOne() replacement must be a document.".to_string());
};
match document.keys().find(|key| key.starts_with('$')) {
Some(operator) => Err(format!(
"MongoDB replaceOne() replacement must not contain update operators such as {operator}; use updateOne() to modify fields."
)),
None => Ok(()),
}
}
fn is_empty_object(value: &str) -> bool {
parse_json_value(value).is_some_and(|value| value.as_object().is_some_and(|object| object.is_empty()))
}
fn aggregate_writes(pipeline: &str) -> bool {
parse_json_value(pipeline).is_some_and(|value| {
value.as_array().is_some_and(|stages| {
stages.iter().any(|stage| {
stage
.as_object()
.is_some_and(|object| object.keys().any(|key| matches!(key.as_str(), "$out" | "$merge")))
})
})
})
}
fn matching_paren(source: &str, open: usize) -> Option<usize> {
let mut depth = 0;
let mut quote = None;
let mut escape = false;
let mut index = open;
while index < source.len() {
let ch = source[index..].chars().next()?;
if escape {
escape = false;
index += ch.len_utf8();
continue;
}
if quote.is_some() {
if ch == '\\' {
escape = true;
} else if Some(ch) == quote {
quote = None;
}
index += ch.len_utf8();
continue;
}
if ch == '\'' || ch == '"' || ch == '`' {
quote = Some(ch);
} else if ch == '/' && is_shell_regex_value_position(source, index) {
if let Ok(Some(literal)) = shell_regex_literal_at(source, index) {
index = literal.end;
continue;
}
} else if ch == '(' {
depth += 1;
} else if ch == ')' {
depth -= 1;
if depth == 0 {
return Some(index);
}
}
index += ch.len_utf8();
}
None
}
/// Offset of the `]` closing the bracket at `open`, ignoring brackets inside strings.
fn matching_bracket(source: &str, open: usize) -> Option<usize> {
let mut depth = 0;
let mut quote = None;
let mut escape = false;
let mut index = open;
while index < source.len() {
let ch = source[index..].chars().next()?;
if escape {
escape = false;
index += ch.len_utf8();
continue;
}
if quote.is_some() {
if ch == '\\' {
escape = true;
} else if Some(ch) == quote {
quote = None;
}
index += ch.len_utf8();
continue;
}
if ch == '\'' || ch == '"' {
quote = Some(ch);
} else if ch == '[' {
depth += 1;
} else if ch == ']' {
depth -= 1;
if depth == 0 {
return Some(index);
}
}
index += ch.len_utf8();
}
None
}
fn split_top_level(source: &str) -> Vec<String> {
if source.trim().is_empty() {
return Vec::new();
}
let mut result = Vec::new();
let mut start = 0;
let mut depth = 0;
let mut quote = None;
let mut escape = false;
let mut index = 0;
while index < source.len() {
let ch = source[index..].chars().next().expect("index is on a character boundary");
if escape {
escape = false;
index += ch.len_utf8();
continue;
}
if quote.is_some() {
if ch == '\\' {
escape = true;
} else if Some(ch) == quote {
quote = None;
}
index += ch.len_utf8();
continue;
}
if ch == '\'' || ch == '"' || ch == '`' {
quote = Some(ch);
} else if ch == '/' && is_shell_regex_value_position(source, index) {
if let Ok(Some(literal)) = shell_regex_literal_at(source, index) {
index = literal.end;
continue;
}
} else if matches!(ch, '(' | '[' | '{') {
depth += 1;
} else if matches!(ch, ')' | ']' | '}') {
depth -= 1;
} else if ch == ',' && depth == 0 {
result.push(source[start..index].trim().to_string());
start = index + 1;
}
index += ch.len_utf8();
}
result.push(source[start..].trim().to_string());
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_find_with_compass_syntax_and_chain() {
assert_eq!(
parse("db.products.find({_id: ObjectId('507f1f77bcf86cd799439011')}, {title: 1, _id: 0}).sort({title: 1}).limit(1)").unwrap(),
MongoCommand::Find {
collection: "products".to_string(),
filter: r#"{"_id":{"$oid":"507f1f77bcf86cd799439011"}}"#.to_string(),
projection: Some(r#"{"title":1,"_id":0}"#.to_string()),
sort: Some(r#"{"title":1}"#.to_string()),
collation: None,
skip: 0,
limit: 1,
}
);
}
#[test]
fn parses_find_with_collation_chain() {
assert_eq!(
parse(r#"db.t_user.find({name: 'xxx'}).collation({ locale: "en", strength: 1 }).limit(20)"#).unwrap(),
MongoCommand::Find {
collection: "t_user".to_string(),
filter: r#"{"name":"xxx"}"#.to_string(),
projection: None,
sort: None,
collation: Some(r#"{"locale":"en","strength":1}"#.to_string()),
skip: 0,
limit: 20,
}
);
}
#[test]
fn parses_find_explain_with_query_options_and_verbosity() {
let command = parse(
r#"db.im_msg.find({active: true}, {email: 1}).sort({email: 1}).collation({locale: "en", strength: 1}).skip(2).limit(5).explain("executionStats")"#,
)
.unwrap();
assert_eq!(
serde_json::to_value(command).unwrap(),
serde_json::json!({
"kind": "findExplain",
"collection": "im_msg",
"filter": "{\"active\":true}",
"projection": "{\"email\":1}",
"sort": "{\"email\":1}",
"collation": "{\"locale\":\"en\",\"strength\":1}",
"skip": 2,
"limit": 5,
"verbosity": "executionStats"
})
);
}
#[test]
fn find_explain_defaults_and_validates_verbosity() {
let default = serde_json::to_value(parse("db.items.find({}).explain()").unwrap()).unwrap();
assert_eq!(default["verbosity"], "queryPlanner");
let all_plans = serde_json::to_value(parse("db.items.find({}).explain('allPlansExecution')").unwrap()).unwrap();
assert_eq!(all_plans["verbosity"], "allPlansExecution");
assert!(parse("db.items.find({}).explain('invalid')").unwrap_err().contains("verbosity"));
assert!(parse("db.items.find({}).explain('executionStats').limit(1)").unwrap_err().contains("final"));
}
#[test]
fn parses_bracket_collection_accessor() {
assert_eq!(
parse(r#"db["orders-2024"].find({a: 1})"#).unwrap(),
MongoCommand::Find {
collection: "orders-2024".to_string(),
filter: r#"{"a":1}"#.to_string(),
projection: None,
sort: None,
collation: None,
skip: 0,
limit: 100,
}
);
// Single quotes, a dotted name, and a chained method all behave like db.<name>.
assert_eq!(
parse("db['audit.logs'].count()").unwrap(),
MongoCommand::Count { collection: "audit.logs".to_string(), filter: "{}".to_string(), accurate: false }
);
assert!(matches!(
parse(r#"db["orders-2024"].updateOne({a: 1}, {$set: {b: 2}}, {upsert: true})"#).unwrap(),
MongoCommand::Update { many: false, .. }
));
for source in [r#"db[].find({})"#, r#"db[""].find({})"#, r#"db["x"]find({})"#, r#"db["x"]"#] {
assert!(parse(source).is_err(), "{source}");
}
}
#[test]
fn parses_get_collection_and_count() {
assert_eq!(
parse("db.getCollection('audit.logs').count()").unwrap(),
MongoCommand::Count { collection: "audit.logs".to_string(), filter: "{}".to_string(), accurate: false }
);
}
#[test]
fn identifies_dangerous_aggregate_and_empty_writes() {
let aggregate = parse(r#"db.projects.aggregate([{"$out":"backup"}])"#).unwrap();
assert!(aggregate.is_mutating());
assert!(aggregate.is_dangerous());
let update = parse("db.projects.updateMany({}, {$set: {active: false}})").unwrap();
assert!(update.has_empty_filter());
let legacy_update = parse("db.projects.update({}, {$set: {active: false}}, {multi: true})").unwrap();
assert!(legacy_update.has_empty_filter());
assert_eq!(validate_safety(&legacy_update, true, false, false), Err(MongoSafetyError::EmptyFilter));
}
#[test]
fn parses_create_user_as_a_dangerous_write() {
let command = parse(
r#"db . createUser({user: "test-db", pwd: "test-password", roles: [{role: "readWrite", db: "db1"}]}, {w: "majority"})"#,
)
.unwrap();
assert_eq!(
command,
MongoCommand::CreateUser {
user_json: r#"{"user":"test-db","pwd":"test-password","roles":[{"role":"readWrite","db":"db1"}]}"#
.to_string(),
write_concern_json: Some(r#"{"w":"majority"}"#.to_string()),
}
);
assert!(command.is_mutating());
assert!(command.is_dangerous());
assert_eq!(validate_safety(&command, true, false, false), Err(MongoSafetyError::Dangerous));
assert_eq!(validate_safety(&command, true, true, false), Ok(()));
assert!(parse(r#"db.createUser({pwd: "missing-user", roles: []})"#).is_err());
assert!(parse(r#"db.createUser({user: "test"}, "majority")"#).is_err());
}
#[test]
fn parses_run_command_as_a_dangerous_write() {
let command = parse(
r#"db.runCommand({
find: "orders",
filter: {_id: ObjectId("507f1f77bcf86cd799439011")},
createdAt: ISODate("2025-01-01T00:00:00Z")
})"#,
)
.unwrap();
assert_eq!(
command,
MongoCommand::RunCommand {
command_json: r#"{"find":"orders","filter":{"_id":{"$oid":"507f1f77bcf86cd799439011"}},"createdAt":{"$date":"2025-01-01T00:00:00Z"}}"#.to_string(),
}
);
assert!(command.is_mutating());
assert!(command.is_dangerous());
assert_eq!(validate_safety(&command, false, false, false), Err(MongoSafetyError::WritesDisabled));
assert_eq!(validate_safety(&command, true, false, false), Err(MongoSafetyError::Dangerous));
assert_eq!(validate_safety(&command, true, true, false), Ok(()));
}
#[test]
fn fills_in_zero_argument_value_constructors() {
let MongoCommand::Update { update, filter, options, .. } = parse(
r#"db.reports.updateOne(
{phone_number: "+84905421172", code: "VN"},
{$set: {type: ObjectId("5bbc28701f3fd80f00e0211c"), created_at: new Date(), updated_at: ISODate()}},
{upsert: true}
)"#,
)
.unwrap() else {
panic!("expected an update command");
};
assert_eq!(filter, r#"{"phone_number":"+84905421172","code":"VN"}"#);
assert_eq!(options.as_deref(), Some(r#"{"upsert":true}"#));
let set = &parse_json_value(&update).unwrap()["$set"];
assert_eq!(set["type"]["$oid"], "5bbc28701f3fd80f00e0211c");
for field in ["created_at", "updated_at"] {
let filled = set[field]["$date"].as_str().expect("filled-in date");
let filled = chrono::DateTime::parse_from_rfc3339(filled).expect("rfc3339 date");
assert!(
(Utc::now() - filled.with_timezone(&Utc)).num_seconds().abs() < 60,
"{field} is not the current time"
);
}
let MongoCommand::Find { filter, .. } = parse("db.reports.find({_id: ObjectId()})").unwrap() else {
panic!("expected a find command");
};
let oid = parse_json_value(&filter).unwrap()["_id"]["$oid"].as_str().unwrap().to_string();
assert!(oid.len() == 24 && oid.chars().all(|ch| ch.is_ascii_hexdigit()), "{oid}");
}
#[test]
fn rewrites_epoch_millisecond_and_numeric_value_constructors() {
let MongoCommand::Find { filter, .. } =
parse(r#"db.orders.find({at: new Date(1735689600000), qty: NumberInt(3), sequence: NumberLong("9223372036854775807"), total: NumberDecimal("12.34")})"#)
.unwrap()
else {
panic!("expected a find command");
};
assert_eq!(
filter,
r#"{"at":{"$date":{"$numberLong":"1735689600000"}},"qty":{"$numberInt":"3"},"sequence":{"$numberLong":"9223372036854775807"},"total":{"$numberDecimal":"12.34"}}"#
);
}
#[test]
fn rewrites_uuid_binary_timestamp_and_key_constants() {
let MongoCommand::Find { filter, .. } = parse(
r#"db.c.find({
u: UUID("3b241101-e2bb-4255-8caf-4136c566a962"),
b: BinData(128, "AQID"),
t: Timestamp(1735689600, 7),
lo: MinKey,
hi: MaxKey(),
range: {$gt: MinKey(), $lt: MaxKey},
list: [MinKey, MaxKey]
})"#,
)
.unwrap() else {
panic!("expected a find command");
};
assert_eq!(
parse_json_value(&filter).unwrap(),
serde_json::json!({
"u": {"$uuid": "3b241101-e2bb-4255-8caf-4136c566a962"},
"b": {"$binary": {"base64": "AQID", "subType": "80"}},
"t": {"$timestamp": {"t": 1735689600, "i": 7}},
"lo": {"$minKey": 1},
"hi": {"$maxKey": 1},
"range": {"$gt": {"$minKey": 1}, "$lt": {"$maxKey": 1}},
"list": [{"$minKey": 1}, {"$maxKey": 1}],
})
);
let MongoCommand::Find { filter, .. } = parse("db.c.find({u: UUID()})").unwrap() else {
panic!("expected a find command");
};
let generated = parse_json_value(&filter).unwrap()["u"]["$uuid"].as_str().unwrap().to_string();
assert!(Uuid::parse_str(&generated).is_ok(), "{generated}");
}
#[test]
fn leaves_key_constant_names_alone_outside_value_positions() {
let MongoCommand::Find { filter, .. } =
parse(r#"db.c.find({MinKey: 1, MaxKey: 2, label: "MinKey", nested: {MaxKey: true}})"#).unwrap()
else {
panic!("expected a find command");
};
assert_eq!(filter, r#"{"MinKey":1,"MaxKey":2,"label":"MinKey","nested":{"MaxKey":true}}"#);
}
#[test]
fn rejects_malformed_uuid_binary_timestamp_and_key_constants() {
for (source, expected) in [
(r#"db.c.find({b: BinData(256, "x")})"#, "BinData"),
(r#"db.c.find({b: BinData(0)})"#, "BinData"),
(r#"db.c.find({b: BinData("00", "x")})"#, "BinData"),
(r#"db.c.find({t: Timestamp(1)})"#, "Timestamp"),
(r#"db.c.find({t: Timestamp(-1, 0)})"#, "Timestamp"),
(r#"db.c.find({t: Timestamp(4294967296, 0)})"#, "Timestamp"),
(r#"db.c.find({u: UUID(1)})"#, "string"),
(r#"db.c.find({a: MinKey(1)})"#, "MinKey"),
(r#"db.c.find({a: MaxKey("x")})"#, "MaxKey"),
] {
let error = parse(source).unwrap_err();
assert!(error.contains(expected), "{source} => {error}");
}
}
#[test]
fn validates_uuid_strings_at_parse_time() {
// mongosh requires the canonical 8-4-4-4-12 hex form: dashes at fixed
// positions, hex digits elsewhere, upper or lower case.
for source in [
r#"db.c.find({u: UUID("3b241101e2bb42558caf4136c566a962")})"#,
r#"db.c.find({u: UUID("3b241101-e2bb-4255-8caf-4136c566a96")})"#,
r#"db.c.find({u: UUID("3b241101-e2bb-4255-8caf-4136c566a9620")})"#,
r#"db.c.find({u: UUID("zb241101-e2bb-4255-8caf-4136c566a962")})"#,
r#"db.c.find({u: UUID("3b241101_e2bb_4255_8caf_4136c566a962")})"#,
] {
let error = parse(source).unwrap_err();
assert!(error.contains("UUID() requires"), "{source} => {error}");
}
// Upper-case hex is accepted and preserved verbatim.
let MongoCommand::Find { filter, .. } =
parse(r#"db.c.find({u: UUID("3B241101-E2BB-4255-8CAF-4136C566A962")})"#).unwrap()
else {
panic!("expected a find command");
};
assert_eq!(filter, r#"{"u":{"$uuid":"3B241101-E2BB-4255-8CAF-4136C566A962"}}"#);
}
#[test]
fn drops_noop_cursor_methods_after_find_and_aggregate() {
// mongosh and Compass append .toArray(); results are always materialised
// here, so it changes nothing and must not be an error.
let expected = MongoCommand::Aggregate {
collection: "orders".to_string(),
pipeline: r#"[{"$match":{"a":1}}]"#.to_string(),
options: None,
};
for source in [
"db.orders.aggregate([{$match: {a: 1}}]).toArray()",
"db.orders.aggregate([{$match: {a: 1}}]).pretty()",
"db.orders.aggregate([{$match: {a: 1}}]).toArray().pretty()",
"db.orders.aggregate([{$match: {a: 1}}])\n .toArray()",
] {
assert_eq!(parse(source).unwrap(), expected, "{source}");
}
let with_chain = parse("db.orders.find({a: 1}).sort({b: 1}).limit(5).toArray()").unwrap();
assert!(matches!(with_chain, MongoCommand::Find { limit: 5, sort: Some(_), .. }), "{with_chain:?}");
assert!(matches!(parse("db.orders.find({a: 1}).pretty()").unwrap(), MongoCommand::Find { .. }));
}
#[test]
fn still_rejects_real_cursor_methods_after_aggregate() {
for source in [
"db.orders.aggregate([]).limit(5)",
"db.orders.aggregate([]).sort({a: 1})",
"db.orders.aggregate([]).toArray().limit(5)",
"db.orders.aggregate([]).toArray(1)",
] {
let error = parse(source).unwrap_err();
assert!(error.contains("aggregate() chain"), "{source} => {error}");
}
assert!(parse("db.orders.find({}).toArray(1)").unwrap_err().contains("find() chain"));
}
#[test]
fn parses_estimated_document_count_as_a_metadata_backed_count() {
// The driver already takes the metadata fast path for a filterless legacy
// count(), which is exactly what estimatedDocumentCount() asks for.
let expected =
MongoCommand::Count { collection: "orders".to_string(), filter: "{}".to_string(), accurate: false };
for source in [
"db.orders.estimatedDocumentCount()",
r#"db["orders"].estimatedDocumentCount()"#,
"db.getCollection('orders').estimatedDocumentCount();",
] {
assert_eq!(parse(source).unwrap(), expected, "{source}");
}
assert!(!parse("db.orders.estimatedDocumentCount()").unwrap().is_mutating());
assert!(parse("db.orders.estimatedDocumentCount({a: 1})").unwrap_err().contains("no filter"));
assert!(parse("db.orders.estimatedDocumentCount().limit(5)").unwrap_err().contains("chained"));
}
#[test]
fn parses_db_stats_and_server_status_as_run_commands() {
assert_eq!(
parse("db.stats()").unwrap(),
MongoCommand::RunCommand { command_json: r#"{"dbStats":1}"#.to_string() }
);
assert_eq!(
parse("db . serverStatus ( ) ;").unwrap(),
MongoCommand::RunCommand { command_json: r#"{"serverStatus":1}"#.to_string() }
);
for source in ["db.stats(1)", "db.serverStatus({})"] {
assert!(parse(source).unwrap_err().contains("takes no arguments"), "{source}");
}
// db.collection.stats() still parses as collection stats, not a run command.
assert!(matches!(parse("db.orders.stats()").unwrap(), MongoCommand::CollectionStats { .. }));
}
#[test]
fn parses_replace_one_as_a_filtered_write() {
let command = parse(
r#"db.orders.replaceOne({_id: ObjectId("507f1f77bcf86cd799439011")}, {name: "new", tags: []}, {upsert: true})"#,
)
.unwrap();
assert_eq!(
command,
MongoCommand::Replace {
collection: "orders".to_string(),
filter: r#"{"_id":{"$oid":"507f1f77bcf86cd799439011"}}"#.to_string(),
replacement: r#"{"name":"new","tags":[]}"#.to_string(),
options: Some(r#"{"upsert":true}"#.to_string()),
}
);
assert!(command.is_mutating());
assert!(!command.is_dangerous());
assert!(!command.has_empty_filter());
assert_eq!(validate_safety(&command, true, false, false), Ok(()));
assert_eq!(validate_safety(&command, false, false, false), Err(MongoSafetyError::WritesDisabled));
let without_options = parse("db.orders.replaceOne({a: 1}, {b: 2})").unwrap();
assert!(matches!(without_options, MongoCommand::Replace { options: None, .. }));
// An empty filter replaces an arbitrary document, so it is guarded like an update.
let unbounded = parse("db.orders.replaceOne({}, {b: 2})").unwrap();
assert!(unbounded.has_empty_filter());
assert_eq!(validate_safety(&unbounded, true, false, false), Err(MongoSafetyError::EmptyFilter));
}
#[test]
fn rejects_replace_one_with_operators_or_the_wrong_shape() {
let error = parse("db.orders.replaceOne({a: 1}, {$set: {b: 2}})").unwrap_err();
assert!(error.contains("$set") && error.contains("updateOne"), "{error}");
for source in [
"db.orders.replaceOne({a: 1})",
"db.orders.replaceOne({a: 1}, {b: 2}, {upsert: true}, 4)",
"db.orders.replaceOne({a: 1}, [{b: 2}])",
"db.orders.replaceOne({a: 1}, {b: 2}).limit(1)",
] {
assert!(parse(source).is_err(), "{source}");
}
}
#[test]
fn parses_show_databases_aliases_as_read_only() {
for source in [
"show dbs",
"SHOW DATABASES;",
"ShOw DbS ;",
"/* databases */ show dbs -- list",
"// databases\nshow databases; /* list */",
] {
let command = parse(source).unwrap();
assert_eq!(command, MongoCommand::ShowDatabases, "{source}");
assert!(!command.is_mutating(), "{source}");
assert!(!command.is_dangerous(), "{source}");
assert_eq!(validate_safety(&command, false, false, true), Ok(()), "{source}");
}
for source in ["show dbs extra", "show database", "show collections", "show"] {
assert!(parse(source).is_err(), "{source}");
}
}
#[test]
fn rejects_unsupported_run_command_shapes() {
for source in [
"db.runCommand()",
"db.runCommand({})",
"db.runCommand('ping')",
"db.runCommand({ping: 1}, {readPreference: 'primary'})",
"db.runCommand({ping: 1}).valueOf()",
"db.runCommand([1, 2, 3])",
] {
assert!(parse(source).unwrap_err().contains("runCommand"), "{source}");
}
}
#[test]
fn treats_effectively_unbounded_write_filters_as_dangerous() {
for command in [
r#"db.items.deleteMany({_id: {$exists: true}})"#,
r#"db.items.deleteMany({id: {$nin: []}})"#,
r#"db.items.deleteMany({$expr: true})"#,
r#"db.items.deleteMany({$or: [{id: 1}, {id: {$ne: 1}}]})"#,
] {
let command = parse(command).unwrap();
assert!(command.has_effectively_unbounded_filter(), "{command:?}");
assert_eq!(
validate_safety(&command, true, false, false),
Err(MongoSafetyError::EmptyFilter),
"{command:?}"
);
}
for command in [
r#"db.items.deleteMany({_id: ObjectId('507f1f77bcf86cd799439011')})"#,
r#"db.items.updateMany({tenant_id: 7}, {$set: {active: false}})"#,
] {
let command = parse(command).unwrap();
assert!(!command.has_effectively_unbounded_filter(), "{command:?}");
assert_eq!(validate_safety(&command, true, false, false), Ok(()), "{command:?}");
}
}
#[test]
fn accepts_multiline_chains_and_update_options() {
let command = parse(
r#"db.getCollection("operation_logs")
.find({_id: ObjectId("68ad51ca84c8127bc7d44cb3")})
.sort({ts: -1})
.skip(5)
.limit(10)"#,
)
.unwrap();
assert!(matches!(command, MongoCommand::Find { skip: 5, limit: 10, .. }));
let update = parse(
r#"db.orders.updateMany({status: "open"}, {$set: {"items.$[item].status": "done"}}, {arrayFilters: [{"item.id": 7}]})"#,
)
.unwrap();
assert!(matches!(update, MongoCommand::Update { many: true, options: Some(_), .. }));
}
#[test]
fn parses_mongo_shell_regex_literals_in_update_filters() {
let command = parse(
r#"db.code_info.updateMany(
{ level: "SECOND_LAYER", position: "B", packagingRatio: /^[^::]*盒/, type: { $ne: "PACK_IN" } },
{ $set: { type: "PACK_IN" }, $currentDate: { lastUpdateTime: true } }
)"#,
)
.unwrap();
let MongoCommand::Update { collection, filter, many, .. } = command else {
panic!("expected update command");
};
assert_eq!(collection, "code_info");
assert!(many);
assert_eq!(
serde_json::from_str::<Value>(&filter).unwrap(),
serde_json::json!({
"level": "SECOND_LAYER",
"position": "B",
"packagingRatio": {
"$regularExpression": {
"pattern": "^[^::]*盒",
"options": "",
}
},
"type": { "$ne": "PACK_IN" },
})
);
}
#[test]
fn drops_js_only_regex_literal_flags() {
let command = parse(r#"db.items.find({ value: /abc/gi })"#).unwrap();
let MongoCommand::Find { filter, .. } = command else {
panic!("expected find command");
};
assert_eq!(
serde_json::from_str::<Value>(&filter).unwrap(),
serde_json::json!({
"value": {
"$regularExpression": {
"pattern": "abc",
"options": "i",
}
}
})
);
}
#[test]
fn ignores_delimiters_inside_mongo_shell_regex_literals() {
let command = parse(r#"db.items.updateMany({ value: /a\),b/ }, { $set: { matched: true } })"#).unwrap();
let MongoCommand::Update { filter, many, .. } = command else {
panic!("expected update command");
};
assert!(many);
assert_eq!(
serde_json::from_str::<Value>(&filter).unwrap(),
serde_json::json!({
"value": {
"$regularExpression": {
"pattern": r#"a\),b"#,
"options": "",
}
}
})
);
}
#[test]
fn normalizes_regex_literal_edges_without_touching_strings_or_comments() {
let normalized = normalized_json(
r#"{
pattern: /a\/b[/::]/mi,
constructorText: "ObjectId('literal')",
url: "https://example.com/a/b",
// slash comments stay comments
active: true,
}"#,
)
.unwrap();
assert_eq!(
serde_json::from_str::<Value>(&normalized).unwrap(),
serde_json::json!({
"pattern": {
"$regularExpression": {
"pattern": r#"a\/b[/::]"#,
"options": "im",
}
},
"constructorText": "ObjectId('literal')",
"url": "https://example.com/a/b",
"active": true,
})
);
for source in ["{pattern: /unterminated}", "{pattern: /value/ii}", "{pattern: /value/q}"] {
assert!(normalized_json(source).is_err(), "{source}");
}
}
#[test]
fn normalizes_regex_literals_after_comments() {
let normalized = normalized_json(
r#"{
block: /* explain the pattern */ /block/i,
line: // explain the next pattern
/line/m,
}"#,
)
.unwrap();
assert_eq!(
serde_json::from_str::<Value>(&normalized).unwrap(),
serde_json::json!({
"block": { "$regularExpression": { "pattern": "block", "options": "i" } },
"line": { "$regularExpression": { "pattern": "line", "options": "m" } },
})
);
}
#[test]
fn parses_legacy_update_with_single_and_multi_semantics() {
assert_eq!(
parse("db.projects.update({_id: 1}, {$set: {active: true}})").unwrap(),
MongoCommand::Update {
collection: "projects".to_string(),
filter: r#"{"_id":1}"#.to_string(),
update: r#"{"$set":{"active":true}}"#.to_string(),
options: None,
many: false,
}
);
let command =
parse(r#"db.getCollection("xxx").update({tenantId: 7}, {$set: {active: true}}, {upsert: true})"#).unwrap();
let MongoCommand::Update { collection, update, options, many, .. } = command else {
panic!("expected legacy update command");
};
assert_eq!(collection, "xxx");
assert!(!many);
assert_eq!(parse_json_value(&update).unwrap(), serde_json::json!({ "$set": { "active": true } }));
assert_eq!(parse_json_value(options.as_deref().unwrap()).unwrap(), serde_json::json!({ "upsert": true }));
let command = parse(
r#"db.projects.update({tenantId: 7}, [{$set: {active: true}}], {multi: true, arrayFilters: [{"item.id": 1}]})"#,
)
.unwrap();
let MongoCommand::Update { update, options, many, .. } = command else {
panic!("expected legacy multi update command");
};
assert!(many);
assert_eq!(parse_json_value(&update).unwrap(), serde_json::json!([{ "$set": { "active": true } }]));
assert_eq!(
parse_json_value(options.as_deref().unwrap()).unwrap(),
serde_json::json!({ "arrayFilters": [{ "item.id": 1 }] })
);
}
#[test]
fn rejects_invalid_legacy_update_arguments() {
assert!(parse("db.projects.update({_id: 1})").is_err());
assert!(parse("db.projects.update({_id: 1}, {$set: {active: true}}, true)").is_err());
assert!(parse("db.projects.update({_id: 1}, {$set: {active: true}}, {multi: 'yes'})").is_err());
assert!(parse("db.projects.update({_id: 1}, {$set: {active: true}}, {}, false)").is_err());
}
#[test]
fn accepts_legacy_insert_and_rejects_unsupported_options() {
assert_eq!(
parse(r#"db.getCollection("accounting_reconciliations").insert({accountId: 999, status: "done"})"#)
.unwrap(),
MongoCommand::Insert {
collection: "accounting_reconciliations".to_string(),
documents: r#"{"accountId":999,"status":"done"}"#.to_string(),
}
);
assert_eq!(
parse("db.products.insert([{name: 'first'}, {name: 'second'}])").unwrap(),
MongoCommand::Insert {
collection: "products".to_string(),
documents: r#"[{"name":"first"},{"name":"second"}]"#.to_string(),
}
);
assert!(parse("db.products.insert({name: 'demo'}, {writeConcern: {w: 1}})").is_err());
assert!(parse("db.products.insert()").is_err());
assert!(parse("db.products.insert('demo')").is_err());
}
#[test]
fn parses_desktop_find_one_find_and_modify_and_use_commands() {
assert_eq!(
parse("db.users.findOne({name: 'Ada'}, {_id: 0}, {maxTimeMS: 500})").unwrap(),
MongoCommand::FindOne {
collection: "users".to_string(),
filter: r#"{"name":"Ada"}"#.to_string(),
projection: Some(r#"{"_id":0}"#.to_string()),
options: Some(r#"{"maxTimeMS":500}"#.to_string()),
}
);
assert!(matches!(
parse("db.users.findOneAndUpdate({_id: 1}, {$set: {active: true}}, {returnDocument: 'after'})").unwrap(),
MongoCommand::FindOneAndUpdate { options: Some(_), .. }
));
assert!(matches!(
parse("db.users.findOneAndReplace({_id: 1}, {name: 'Grace'})").unwrap(),
MongoCommand::FindOneAndReplace { .. }
));
assert!(matches!(parse("db.users.findOneAndDelete({_id: 1})").unwrap(), MongoCommand::FindOneAndDelete { .. }));
assert_eq!(parse("use analytics-test").unwrap(), MongoCommand::Use { database: "analytics-test".to_string() });
}
#[test]
fn serializes_frontend_command_contract() {
let insert = serde_json::to_value(parse("db.items.insert({_id: 1})").unwrap()).unwrap();
assert_eq!(insert["kind"], "insert");
assert_eq!(insert["docsJson"], r#"{"_id":1}"#);
let count = serde_json::to_value(parse("db.items.count({})").unwrap()).unwrap();
assert_eq!(count["kind"], "countDocuments");
assert_eq!(count["accurate"], false);
let create_user =
serde_json::to_value(parse(r#"db.createUser({user: "app", pwd: "secret", roles: []})"#).unwrap()).unwrap();
assert_eq!(create_user["kind"], "createUser");
assert_eq!(create_user["userJson"], r#"{"user":"app","pwd":"secret","roles":[]}"#);
let run_command = serde_json::to_value(parse("db.runCommand({ping: 1})").unwrap()).unwrap();
assert_eq!(run_command["kind"], "runCommand");
assert_eq!(run_command["commandJson"], r#"{"ping":1}"#);
let show_databases = serde_json::to_value(parse("show dbs").unwrap()).unwrap();
assert_eq!(show_databases["kind"], "showDatabases");
}
#[test]
fn accepts_stats_and_rejects_negative_pagination() {
assert!(matches!(
parse("db.users.stats(1024)").unwrap(),
MongoCommand::CollectionStats { metric, scale: Some(_), .. } if metric == "stats"
));
assert!(parse("db.users.find({}).skip(-1)").is_err());
}
}