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

2781 lines
114 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 = "bulkWrite")]
BulkWrite { collection: String, operations: 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 = "renameCollection")]
RenameCollection {
collection: String,
#[serde(rename = "newName")]
new_name: 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> },
/// `db.getSiblingDB("name").<command>`: the wrapped command, run against `database`
/// instead of the session's current database, for this command only.
#[serde(rename = "inDatabase")]
InDatabase { database: String, command: Box<MongoCommand> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MongoSafetyError {
WritesDisabled,
EmptyFilter,
Dangerous,
ProductionWrite,
}
impl MongoCommand {
/// The database this command explicitly targets, when it does not use the current one.
pub fn target_database(&self) -> Option<&str> {
match self {
Self::InDatabase { database, .. } => Some(database),
_ => None,
}
}
pub fn is_mutating(&self) -> bool {
if let Self::InDatabase { command, .. } = self {
return command.is_mutating();
}
matches!(
self,
Self::RunCommand { .. }
| Self::CreateUser { .. }
| Self::Insert { .. }
| Self::Update { .. }
| Self::Replace { .. }
| Self::BulkWrite { .. }
| Self::Delete { .. }
| Self::CreateIndex { .. }
| Self::DropIndexes { .. }
| Self::DropCollection { .. }
| Self::RenameCollection { .. }
| Self::FindOneAndUpdate { .. }
| Self::FindOneAndReplace { .. }
| Self::FindOneAndDelete { .. }
) || matches!(self, Self::Aggregate { pipeline, .. } if aggregate_writes(pipeline))
}
pub fn is_dangerous(&self) -> bool {
if let Self::InDatabase { command, .. } = self {
return command.is_dangerous();
}
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::InDatabase { command, .. } => command.has_empty_filter(),
Self::Update { filter, .. }
| Self::Replace { filter, .. }
| Self::Delete { filter, .. }
| Self::FindOneAndUpdate { filter, .. }
| Self::FindOneAndReplace { filter, .. }
| Self::FindOneAndDelete { filter, .. } => is_empty_object(filter),
Self::BulkWrite { operations, .. } => {
bulk_write_filters(operations).iter().any(|filter| is_empty_object(filter))
}
_ => false,
}
}
pub fn has_effectively_unbounded_filter(&self) -> bool {
match self {
Self::InDatabase { command, .. } => command.has_effectively_unbounded_filter(),
Self::Update { filter, .. }
| Self::Replace { filter, .. }
| Self::Delete { filter, .. }
| Self::FindOneAndUpdate { filter, .. }
| Self::FindOneAndReplace { filter, .. }
| Self::FindOneAndDelete { filter, .. } => mongo_filter_is_effectively_unbounded(filter),
Self::BulkWrite { operations, .. } => {
bulk_write_filters(operations).iter().any(|filter| mongo_filter_is_effectively_unbounded(filter))
}
_ => false,
}
}
}
/// One entry of a `bulkWrite([...])` array, validated but not yet converted to BSON.
#[derive(Debug, Clone, PartialEq)]
pub enum BulkWriteOperation {
InsertOne { document: Value },
UpdateOne { filter: Value, update: Value, upsert: Option<bool>, array_filters: Option<Value> },
UpdateMany { filter: Value, update: Value, upsert: Option<bool>, array_filters: Option<Value> },
ReplaceOne { filter: Value, replacement: Value, upsert: Option<bool> },
DeleteOne { filter: Value },
DeleteMany { filter: Value },
}
impl BulkWriteOperation {
pub fn kind(&self) -> &'static str {
match self {
Self::InsertOne { .. } => "insertOne",
Self::UpdateOne { .. } => "updateOne",
Self::UpdateMany { .. } => "updateMany",
Self::ReplaceOne { .. } => "replaceOne",
Self::DeleteOne { .. } => "deleteOne",
Self::DeleteMany { .. } => "deleteMany",
}
}
fn filter(&self) -> Option<&Value> {
match self {
Self::InsertOne { .. } => None,
Self::UpdateOne { filter, .. }
| Self::UpdateMany { filter, .. }
| Self::ReplaceOne { filter, .. }
| Self::DeleteOne { filter }
| Self::DeleteMany { filter } => Some(filter),
}
}
}
/// Validate a `bulkWrite` operations array the way the shell does: each entry is one
/// `{ <op>: { ... } }` document with exactly the fields that operation takes.
pub fn parse_bulk_write_operations(operations_json: &str) -> Result<Vec<BulkWriteOperation>, String> {
let Some(Value::Array(entries)) = parse_json_value(operations_json) else {
return Err("MongoDB bulkWrite() requires an array of operations.".to_string());
};
if entries.is_empty() {
return Err("MongoDB bulkWrite() requires at least one operation.".to_string());
}
entries.iter().enumerate().map(|(index, entry)| parse_bulk_write_operation(index, entry)).collect()
}
fn parse_bulk_write_operation(index: usize, entry: &Value) -> Result<BulkWriteOperation, String> {
let position = index + 1;
let Value::Object(wrapper) = entry else {
return Err(format!("MongoDB bulkWrite() operation {position} must be a document such as {{ insertOne: {{ document: {{ ... }} }} }}."));
};
let (kind, spec) = match wrapper.iter().collect::<Vec<_>>().as_slice() {
[(kind, Value::Object(spec))] => (kind.as_str(), spec),
[(kind, _)] => return Err(format!("MongoDB bulkWrite() operation {position} ({kind}) must be a document.")),
_ => return Err(format!("MongoDB bulkWrite() operation {position} must have exactly one operation key.")),
};
let allowed: &[&str] = match kind {
"insertOne" => &["document"],
"updateOne" | "updateMany" => &["filter", "update", "upsert", "arrayFilters"],
"replaceOne" => &["filter", "replacement", "upsert"],
"deleteOne" | "deleteMany" => &["filter"],
other => {
return Err(format!(
"MongoDB bulkWrite() operation {position} uses unsupported operation {other}; supported: insertOne, updateOne, updateMany, replaceOne, deleteOne, deleteMany."
))
}
};
if let Some(unknown) = spec.keys().find(|key| !allowed.contains(&key.as_str())) {
return Err(format!("MongoDB bulkWrite() operation {position} ({kind}) has unsupported field {unknown}."));
}
let document = |field: &str| -> Result<Value, String> {
match spec.get(field) {
Some(value @ Value::Object(_)) => Ok(value.clone()),
Some(_) => {
Err(format!("MongoDB bulkWrite() operation {position} ({kind}) field {field} must be a document."))
}
None => Err(format!("MongoDB bulkWrite() operation {position} ({kind}) requires a {field} document.")),
}
};
let upsert = match spec.get("upsert") {
None => None,
Some(Value::Bool(value)) => Some(*value),
Some(_) => return Err(format!("MongoDB bulkWrite() operation {position} ({kind}) upsert must be a boolean.")),
};
match kind {
"insertOne" => Ok(BulkWriteOperation::InsertOne { document: document("document")? }),
"updateOne" | "updateMany" => {
let update = match spec.get("update") {
Some(value @ Value::Object(object)) => {
if object.is_empty() || !object.keys().all(|key| key.starts_with('$')) {
return Err(format!(
"MongoDB bulkWrite() operation {position} ({kind}) update must use operators such as $set; use replaceOne for a whole document."
));
}
value.clone()
}
Some(value @ Value::Array(_)) => value.clone(),
Some(_) => {
return Err(format!(
"MongoDB bulkWrite() operation {position} ({kind}) update must be a document or pipeline."
))
}
None => return Err(format!("MongoDB bulkWrite() operation {position} ({kind}) requires an update.")),
};
let array_filters = match spec.get("arrayFilters") {
None => None,
Some(value @ Value::Array(_)) => Some(value.clone()),
Some(_) => {
return Err(format!(
"MongoDB bulkWrite() operation {position} ({kind}) arrayFilters must be an array."
))
}
};
let filter = document("filter")?;
Ok(if kind == "updateOne" {
BulkWriteOperation::UpdateOne { filter, update, upsert, array_filters }
} else {
BulkWriteOperation::UpdateMany { filter, update, upsert, array_filters }
})
}
"replaceOne" => {
let replacement = document("replacement")?;
if let Some(operator) =
replacement.as_object().and_then(|object| object.keys().find(|key| key.starts_with('$')))
{
return Err(format!(
"MongoDB bulkWrite() operation {position} (replaceOne) replacement must not contain update operators such as {operator}; use updateOne to modify fields."
));
}
Ok(BulkWriteOperation::ReplaceOne { filter: document("filter")?, replacement, upsert })
}
"deleteOne" => Ok(BulkWriteOperation::DeleteOne { filter: document("filter")? }),
_ => Ok(BulkWriteOperation::DeleteMany { filter: document("filter")? }),
}
}
/// Filters of every non-insert operation, as JSON text, for the safety checks.
fn bulk_write_filters(operations_json: &str) -> Vec<String> {
parse_bulk_write_operations(operations_json)
.map(|operations| operations.iter().filter_map(|op| op.filter().map(Value::to_string)).collect())
.unwrap_or_default()
}
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 });
}
if source.starts_with("db")
&& source[2..].trim_start().starts_with('.')
&& source[2..].trim_start()[1..]
.trim_start()
.strip_prefix("getSiblingDB")
.is_some_and(|after| after.trim_start().starts_with('('))
&& split_sibling_db_prefix(source).is_none()
{
return Err("MongoDB getSiblingDB() requires a database name string followed by a command, for example db.getSiblingDB(\"app\").orders.find({}).".to_string());
}
if let Some((database, rest)) = split_sibling_db_prefix(source) {
let command = parse(&format!("db{rest}"))?;
return match command {
MongoCommand::InDatabase { .. } => Err("MongoDB getSiblingDB() cannot be chained.".to_string()),
MongoCommand::Use { .. } | MongoCommand::ShowDatabases => {
Err("MongoDB getSiblingDB() must be followed by a collection or database method.".to_string())
}
command => Ok(MongoCommand::InDatabase { database, command: Box::new(command) }),
};
}
// `db.stats()` and `db.serverStatus()` are the shell's shorthand for the
// matching runCommand, so they execute through the same supported path.
// `db.dropDatabase()` is the shell's shorthand for the dropDatabase command; running it
// through runCommand keeps it on the supported path and classed as dangerous.
if let Some((args, tail)) = database_method_call(source, "dropDatabase") {
if !tail.is_empty() || !args.iter().all(|arg| arg.trim().is_empty()) {
return Err("MongoDB db.dropDatabase() takes no arguments.".to_string());
}
return Ok(MongoCommand::RunCommand { command_json: r#"{"dropDatabase":1}"#.to_string() });
}
// `db.createCollection(name[, options])` is the create command; options such as
// capped/size/max/validator pass through for the server to validate.
if let Some((args, tail)) = database_method_call(source, "createCollection") {
if !tail.is_empty() || !(1..=2).contains(&args.len()) {
return Err("MongoDB createCollection() requires a collection name and optional options.".to_string());
}
let name = parse_string_arg(&args[0])?;
if name.trim().is_empty() || name.contains('$') {
return Err("MongoDB createCollection() requires a valid collection name.".to_string());
}
let mut command = serde_json::Map::new();
command.insert("create".to_string(), Value::String(name));
if let Some(options) = args.get(1).filter(|arg| !arg.trim().is_empty()) {
let Some(Value::Object(options)) = parse_json_value(&normalized_json(options)?) else {
return Err("MongoDB createCollection() options must be a document.".to_string());
};
if options.contains_key("create") {
return Err("MongoDB createCollection() options must not contain create.".to_string());
}
command.extend(options);
}
return Ok(MongoCommand::RunCommand { command_json: Value::Object(command).to_string() });
}
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, "bulkWrite") {
if !tail.is_empty() || !(1..=2).contains(&args.len()) {
return Err("MongoDB bulkWrite() requires an array of operations and optional options.".to_string());
}
let operations = normalized_json(&args[0])?;
parse_bulk_write_operations(&operations)?;
let options = args.get(1).filter(|arg| !arg.trim().is_empty()).map(|arg| normalized_json(arg)).transpose()?;
if let Some(options) = &options {
validate_bulk_write_options(options)?;
}
return Ok(MongoCommand::BulkWrite { collection, operations, options });
}
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, "renameCollection") {
if !tail.is_empty() || args.is_empty() {
return Err("MongoDB renameCollection() requires the new collection name.".to_string());
}
if args.len() > 1 {
// The second argument is dropTarget, which would delete an existing collection
// of the new name; reject it rather than silently ignore it.
return Err(
"MongoDB renameCollection() dropTarget is not supported; drop the target collection first.".to_string()
);
}
let new_name = parse_string_arg(&args[0])?;
if new_name.trim().is_empty() || new_name.contains('$') {
return Err("MongoDB renameCollection() requires a valid collection name.".to_string());
}
if new_name == collection {
return Err("MongoDB renameCollection() new name must differ from the current name.".to_string());
}
return Ok(MongoCommand::RenameCollection { collection, new_name });
}
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))
}
/// `db.getSiblingDB("name")` prefix: the database name and the text after the call,
/// which must continue with `.` so the rest reads as a normal `db.` command.
fn split_sibling_db_prefix(source: &str) -> Option<(String, &str)> {
let rest = source.strip_prefix("db")?.trim_start().strip_prefix('.')?.trim_start().strip_prefix("getSiblingDB")?;
let rest = rest.trim_start();
if !rest.starts_with('(') {
return None;
}
let close = matching_paren(rest, 0)?;
let args = split_top_level(&rest[1..close]);
if args.len() != 1 {
return None;
}
let database = parse_string_arg(&args[0]).ok()?;
if database.is_empty() {
return None;
}
let after = &rest[close + 1..];
if !after.trim_start().starts_with('.') {
return None;
}
Some((database, after))
}
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())
}
/// `bulkWrite` options: only `ordered` is honoured, so anything else is rejected rather than dropped.
fn validate_bulk_write_options(options: &str) -> Result<(), String> {
let Some(Value::Object(object)) = parse_json_value(options) else {
return Err("MongoDB bulkWrite() options must be a document.".to_string());
};
for (key, value) in &object {
match (key.as_str(), value) {
("ordered", Value::Bool(_)) => {}
("ordered", _) => return Err("MongoDB bulkWrite() ordered option must be a boolean.".to_string()),
(other, _) => return Err(format!("Unsupported MongoDB bulkWrite() option: {other}.")),
}
}
Ok(())
}
/// 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_bulk_write_with_every_operation_kind() {
let command = parse(
r#"db.orders.bulkWrite([
{ insertOne: { document: { sku: "A1", stock: 1 } } },
{ updateOne: { filter: { sku: "A1" }, update: { $inc: { stock: 1 } }, upsert: true } },
{ updateMany: { filter: { archived: true }, update: { $set: { stock: 0 } }, arrayFilters: [] } },
{ replaceOne: { filter: { sku: "B2" }, replacement: { sku: "B2", stock: 9 } } },
{ deleteOne: { filter: { sku: "C3" } } },
{ deleteMany: { filter: { stock: { $lt: 0 } } } }
], { ordered: false })"#,
)
.unwrap();
let MongoCommand::BulkWrite { collection, operations, options } = &command else {
panic!("expected a bulkWrite command");
};
assert_eq!(collection, "orders");
assert_eq!(options.as_deref(), Some(r#"{"ordered":false}"#));
let kinds: Vec<_> = parse_bulk_write_operations(operations).unwrap().iter().map(|op| op.kind()).collect();
assert_eq!(kinds, ["insertOne", "updateOne", "updateMany", "replaceOne", "deleteOne", "deleteMany"]);
assert!(command.is_mutating());
assert!(!command.is_dangerous());
assert!(!command.has_effectively_unbounded_filter());
assert_eq!(validate_safety(&command, true, false, false), Ok(()));
assert_eq!(validate_safety(&command, false, false, false), Err(MongoSafetyError::WritesDisabled));
// Only an insert: no filters at all, so never unbounded.
let inserts = parse(r#"db.orders.bulkWrite([{ insertOne: { document: { a: 1 } } }])"#).unwrap();
assert!(!inserts.has_empty_filter());
}
#[test]
fn bulk_write_with_any_unbounded_filter_is_guarded_like_an_update() {
let command = parse(
r#"db.orders.bulkWrite([
{ updateOne: { filter: { sku: "A1" }, update: { $set: { a: 1 } } } },
{ deleteMany: { filter: {} } }
])"#,
)
.unwrap();
assert!(command.has_empty_filter());
assert!(command.has_effectively_unbounded_filter());
assert_eq!(validate_safety(&command, true, false, false), Err(MongoSafetyError::EmptyFilter));
}
#[test]
fn rejects_malformed_bulk_write_operations() {
for (source, expected) in [
("db.orders.bulkWrite()", "array of operations"),
("db.orders.bulkWrite({})", "array of operations"),
("db.orders.bulkWrite([])", "at least one"),
("db.orders.bulkWrite([1])", "operation 1 must be a document"),
("db.orders.bulkWrite([{ insertOne: {}, deleteOne: {} }])", "exactly one operation key"),
("db.orders.bulkWrite([{ upsertOne: { document: {} } }])", "unsupported operation upsertOne"),
("db.orders.bulkWrite([{ insertOne: { doc: {} } }])", "unsupported field doc"),
("db.orders.bulkWrite([{ insertOne: {} }])", "requires a document document"),
(
"db.orders.bulkWrite([{ updateOne: { filter: {}, update: { a: 1 } } }])",
"must use operators such as $set",
),
(
"db.orders.bulkWrite([{ updateOne: { filter: {}, update: { $set: { a: 1 } }, upsert: 1 } }])",
"upsert must be a boolean",
),
(
"db.orders.bulkWrite([{ updateOne: { filter: {}, update: { $set: { a: 1 } }, collation: {} } }])",
"unsupported field collation",
),
(
"db.orders.bulkWrite([{ replaceOne: { filter: {}, replacement: { $set: { a: 1 } } } }])",
"must not contain update operators such as $set",
),
("db.orders.bulkWrite([{ deleteOne: { filter: [] } }])", "field filter must be a document"),
(
"db.orders.bulkWrite([{ deleteOne: { filter: {} } }], { ordered: 1 })",
"ordered option must be a boolean",
),
(
"db.orders.bulkWrite([{ deleteOne: { filter: {} } }], { writeConcern: {} })",
"Unsupported MongoDB bulkWrite() option: writeConcern",
),
(
"db.orders.bulkWrite([{ deleteOne: { filter: {} } }]).limit(1)",
"array of operations and optional options",
),
] {
let error = parse(source).unwrap_err();
assert!(error.contains(expected), "{source}\n expected: {expected}\n got: {error}");
}
}
#[test]
fn parses_rename_collection() {
let command = parse(r#"db.orders.renameCollection("orders_2024");"#).unwrap();
assert_eq!(
command,
MongoCommand::RenameCollection { collection: "orders".to_string(), new_name: "orders_2024".to_string() }
);
assert!(command.is_mutating());
assert!(!command.is_dangerous());
assert_eq!(serde_json::to_value(&command).unwrap()["newName"], "orders_2024");
assert!(matches!(parse(r#"db["a-b"].renameCollection('c')"#).unwrap(), MongoCommand::RenameCollection { .. }));
for (source, expected) in [
("db.orders.renameCollection()", "requires the new collection name"),
("db.orders.renameCollection(1)", "must be a string"),
(r#"db.orders.renameCollection("")"#, "valid collection name"),
(r#"db.orders.renameCollection("a$b")"#, "valid collection name"),
(r#"db.orders.renameCollection("orders")"#, "must differ"),
(r#"db.orders.renameCollection("x", true)"#, "dropTarget is not supported"),
(r#"db.orders.renameCollection("x").y()"#, "requires the new collection name"),
] {
let error = parse(source).unwrap_err();
assert!(error.contains(expected), "{source}\n expected: {expected}\n got: {error}");
}
}
#[test]
fn parses_drop_database_and_create_collection_as_run_commands() {
let drop = parse("db . dropDatabase ( ) ;").unwrap();
assert_eq!(drop, MongoCommand::RunCommand { command_json: r#"{"dropDatabase":1}"#.to_string() });
assert!(drop.is_mutating() && drop.is_dangerous());
assert_eq!(
parse(r#"db.createCollection("events")"#).unwrap(),
MongoCommand::RunCommand { command_json: r#"{"create":"events"}"#.to_string() }
);
let MongoCommand::RunCommand { command_json } =
parse("db.createCollection('logs', {capped: true, size: 1048576, max: 1000})").unwrap()
else {
panic!("expected a run command");
};
assert_eq!(
parse_json_value(&command_json).unwrap(),
serde_json::json!({ "create": "logs", "capped": true, "size": 1048576, "max": 1000 })
);
for (source, expected) in [
("db.dropDatabase(1)", "takes no arguments"),
("db.createCollection()", "requires a collection name"),
("db.createCollection(1)", "must be a string"),
(r#"db.createCollection("")"#, "valid collection name"),
(r#"db.createCollection("a$b")"#, "valid collection name"),
(r#"db.createCollection("a", [])"#, "options must be a document"),
(r#"db.createCollection("a", {create: "b"})"#, "must not contain create"),
(r#"db.createCollection("a").x()"#, "collection name and optional options"),
] {
let error = parse(source).unwrap_err();
assert!(error.contains(expected), "{source}\n expected: {expected}\n got: {error}");
}
}
#[test]
fn parses_get_sibling_db_as_a_database_override() {
// The exact command from #3936.
let command = parse(
r#"db.getSiblingDB("iam_account").getCollection("user").find({_id: NumberLong('144115205316939462')}).sort({phone: 1}).limit(21);"#,
)
.unwrap();
let MongoCommand::InDatabase { database, command: inner } = &command else {
panic!("expected an InDatabase wrapper");
};
assert_eq!(database, "iam_account");
assert!(matches!(**inner, MongoCommand::Find { limit: 21, .. }));
assert_eq!(command.target_database(), Some("iam_account"));
assert!(!command.is_mutating());
// Database-level commands and odd spacing work too; safety delegates to the wrapped command.
assert!(matches!(parse(r#"db . getSiblingDB( "x" ) . stats()"#).unwrap(), MongoCommand::InDatabase { .. }));
let delete = parse(r#"db.getSiblingDB('x').c.deleteMany({})"#).unwrap();
assert!(delete.is_mutating());
assert!(delete.has_empty_filter() && delete.has_effectively_unbounded_filter());
assert_eq!(validate_safety(&delete, true, false, false), Err(MongoSafetyError::EmptyFilter));
let serialized = serde_json::to_value(parse(r#"db.getSiblingDB("x").c.find({})"#).unwrap()).unwrap();
assert_eq!(serialized["kind"], "inDatabase");
assert_eq!(serialized["database"], "x");
assert_eq!(serialized["command"]["kind"], "find");
}
#[test]
fn rejects_malformed_get_sibling_db() {
for (source, expected) in [
(r#"db.getSiblingDB("x")"#, "followed by a command"),
(r#"db.getSiblingDB("x").getSiblingDB("y").c.find({})"#, "cannot be chained"),
(r#"db.getSiblingDB("").c.find({})"#, "requires a database name string"),
(r#"db.getSiblingDB(1).c.find({})"#, "requires a database name string"),
(r#"db.getSiblingDB("x", "y").c.find({})"#, "requires a database name string"),
(r#"db.getSiblingDB("x").use y"#, "collection method is required"),
] {
let error = parse(source).unwrap_err();
assert!(error.contains(expected), "{source}\n expected: {expected}\n got: {error}");
}
}
#[test]
fn does_not_misreport_get_sibling_db_prefixed_methods() {
// A method whose name merely starts with `getSiblingDB` must fall through
// to the generic unsupported-method error, not the wrapper-specific one.
let error = parse(r#"db.getSiblingDBX("x").c.find({})"#).unwrap_err();
assert!(!error.contains("getSiblingDB()"), "got: {error}");
}
#[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());
}
}