2781 lines
114 KiB
Rust
2781 lines
114 KiB
Rust
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());
|
||
}
|
||
}
|