Files
dbx/crates/dbx-drivers/src/db/mongo_driver.rs
T

5211 lines
211 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use mongodb::{
bson::{doc, oid::ObjectId, Bson, DateTime, Document},
options::{
ClientOptions, Collation, GridFsBucketOptions, IndexOptions, ReadPreference, SelectionCriteria,
UpdateModifications,
},
Client, Cursor, Database, IndexModel,
};
use serde::{Deserialize, Serialize};
use super::with_connection_timeout;
use crate::models::connection::DatabaseConnectionInfo;
use crate::types::{IndexInfo, ObjectStatistics};
use dbx_types::document::{MongoGridFsBucketInfo, MongoGridFsFileInfo};
use futures::{io::AsyncReadExt, io::AsyncWriteExt, stream, StreamExt, TryStreamExt};
use percent_encoding::percent_decode_str;
use std::{collections::HashSet, time::Duration};
use crate::mongo_oidc::MongoOidcBrowserOpener;
pub use super::document_result::DocumentQueryResult;
/// Backward-compatible name for callers of Mongo-specific APIs.
pub type MongoDocumentResult = DocumentQueryResult;
const MONGO_COLLATION_FIELDS: &[&str] = &[
"locale",
"strength",
"caseLevel",
"caseFirst",
"numericOrdering",
"alternate",
"maxVariable",
"normalization",
"backwards",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MongoDropIndexesResult {
pub dropped_names: Vec<String>,
pub affected_rows: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub failures: Vec<MongoDropIndexFailure>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MongoDropIndexFailure {
pub name: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MongoCollectionStatsResult {
pub count: serde_json::Value,
pub size: serde_json::Value,
#[serde(rename = "avgObjSize")]
pub avg_obj_size: serde_json::Value,
#[serde(rename = "storageSize")]
pub storage_size: serde_json::Value,
#[serde(rename = "totalIndexSize")]
pub total_index_size: serde_json::Value,
pub nindexes: serde_json::Value,
}
/// Result counts returned after cloning a regular MongoDB collection.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MongoCloneCollectionResult {
pub documents_copied: u64,
pub indexes_copied: u64,
}
pub async fn connect(url: &str, timeout: Duration, idle_timeout: Duration) -> Result<Client, String> {
connect_with_oidc(url, timeout, idle_timeout, None).await
}
pub async fn connect_with_oidc(
url: &str,
timeout: Duration,
idle_timeout: Duration,
oidc_browser_opener: Option<MongoOidcBrowserOpener>,
) -> Result<Client, String> {
let url = normalize_mongo_uri_direct_connection(url);
let is_multi_host = is_multi_host_mongo_uri(&url);
let parse_timeout = if is_multi_host { std::cmp::max(timeout * 2, Duration::from_secs(10)) } else { timeout };
with_connection_timeout("MongoDB", parse_timeout, async {
let mut options = ClientOptions::parse(&url).await.map_err(|e| format!("MongoDB connection failed: {e}"))?;
if let Some(credential) = options.credential.as_mut().filter(|credential| {
credential
.mechanism
.as_ref()
.is_some_and(|mechanism| matches!(mechanism, mongodb::options::AuthMechanism::MongoDbOidc))
}) {
// ENVIRONMENT selects the driver's built-in machine flow. Only
// install DBX's browser callback for interactive human OIDC.
if !mongo_oidc_uses_machine_environment(credential.mechanism_properties.as_ref()) {
let opener = oidc_browser_opener.ok_or_else(|| {
"MongoDB OIDC browser authentication is only available in the DBX desktop app".to_string()
})?;
credential.oidc_callback = crate::mongo_oidc::human_callback(opener);
}
}
options.connect_timeout = Some(timeout);
options.server_selection_timeout =
if is_multi_host { Some(std::cmp::max(timeout * 2, Duration::from_secs(10))) } else { Some(timeout) };
// Close idle connections before the server-side timeout drops them,
// preventing "Broken pipe" (os error 32) or "unexpected end of file".
// 0 means no idle timeout (keep connections alive indefinitely).
if idle_timeout.as_secs() > 0 {
options.max_idle_time = Some(idle_timeout);
}
// For single-host connections, force direct connection to avoid replica
// set discovery. This is essential when connecting through a TCP proxy
// or NAT where the driver would otherwise receive internal IPs from
// the replica set handshake and fail to connect.
if !is_multi_host && options.load_balanced != Some(true) && options.direct_connection.is_none() {
options.direct_connection = Some(true);
}
Client::with_options(options).map_err(|e| format!("MongoDB connection failed: {e}"))
})
.await
}
fn normalize_mongo_uri_direct_connection(uri: &str) -> String {
if !is_multi_host_mongo_uri(uri) || !mongo_uri_has_direct_connection_true(uri) {
return uri.to_string();
}
let (before_fragment, fragment) =
uri.split_once('#').map(|(base, fragment)| (base, Some(fragment))).unwrap_or((uri, None));
let Some((base, query)) = before_fragment.split_once('?') else {
return uri.to_string();
};
let params =
query.split('&').filter(|part| !mongo_url_param_is_direct_connection_true(part)).collect::<Vec<_>>().join("&");
let mut normalized = if params.is_empty() { base.to_string() } else { format!("{base}?{params}") };
if let Some(fragment) = fragment {
normalized.push('#');
normalized.push_str(fragment);
}
normalized
}
fn is_multi_host_mongo_uri(url: &str) -> bool {
if url.get(..14).is_some_and(|scheme| scheme.eq_ignore_ascii_case("mongodb+srv://")) {
// SRV URLs expand to a DNS seed list during driver parsing, so forcing
// directConnection would be rejected even though the URI shows one host.
return true;
}
let rest = match url.strip_prefix("mongodb://").or_else(|| url.strip_prefix("mongodb+srv://")) {
Some(r) => r,
None => return false,
};
let authority = match rest.split('/').next() {
Some(a) => a,
None => return false,
};
let host_section = match authority.rfind('@') {
Some(idx) => &authority[idx + 1..],
None => authority,
};
host_section.contains(',')
}
fn mongo_uri_has_direct_connection_true(uri: &str) -> bool {
uri.split_once('?')
.map(|(_, query)| {
query.split('#').next().unwrap_or("").split('&').any(mongo_url_param_is_direct_connection_true)
})
.unwrap_or(false)
}
fn mongo_url_param_is_direct_connection_true(part: &str) -> bool {
let Some((key, value)) = part.split_once('=') else {
return false;
};
percent_decode_str(key).decode_utf8_lossy().eq_ignore_ascii_case("directConnection")
&& percent_decode_str(value).decode_utf8_lossy().eq_ignore_ascii_case("true")
}
pub async fn test_connection(client: &Client, timeout: Duration, database: Option<&str>) -> Result<(), String> {
test_connection_with_timeout(client, timeout, database).await
}
pub async fn test_connection_for_url(
client: &Client,
url: &str,
timeout: Duration,
database: Option<&str>,
) -> Result<(), String> {
let timeout = if mongo_uri_uses_oidc(url) {
timeout.saturating_add(crate::mongo_oidc::OIDC_BROWSER_AUTH_TIMEOUT)
} else {
timeout
};
test_connection_with_timeout(client, timeout, database).await
}
async fn test_connection_with_timeout(
client: &Client,
timeout: Duration,
database: Option<&str>,
) -> Result<(), String> {
let database = database.map(str::trim).filter(|value| !value.is_empty()).unwrap_or("admin");
let client = client.clone();
let database = database.to_string();
with_connection_timeout("MongoDB", timeout, async move {
client
.database(&database)
.run_command(doc! { "ping": 1 })
.await
.map(|_| ())
.map_err(|e| format!("MongoDB connection failed: {e}"))
})
.await
}
pub fn mongo_uri_uses_oidc(uri: &str) -> bool {
uri.split_once('?')
.map(|(_, query)| {
query.split('#').next().unwrap_or("").split('&').any(|part| {
let Some((key, value)) = part.split_once('=') else {
return false;
};
percent_decode_str(key).decode_utf8_lossy().eq_ignore_ascii_case("authMechanism")
&& percent_decode_str(value).decode_utf8_lossy().eq_ignore_ascii_case("MONGODB-OIDC")
})
})
.unwrap_or(false)
}
fn mongo_oidc_uses_machine_environment(properties: Option<&mongodb::bson::Document>) -> bool {
properties.is_some_and(|properties| properties.contains_key("ENVIRONMENT"))
}
pub async fn server_version(client: &Client, database: &str) -> Result<String, String> {
let database = database.trim();
let database = if database.is_empty() { "admin" } else { database };
let result = client.database(database).run_command(doc! { "buildInfo": 1 }).await.map_err(|e| e.to_string())?;
server_version_from_build_info(&result)
}
pub async fn database_connection_info(
client: &Client,
database: Option<&str>,
) -> Result<DatabaseConnectionInfo, String> {
let version = server_version(client, database.unwrap_or("admin")).await?;
Ok(DatabaseConnectionInfo {
product_name: Some("MongoDB".to_string()),
product_version: Some(version),
current_database: database.map(str::to_string),
driver_name: Some("MongoDB Rust driver".to_string()),
..Default::default()
})
}
pub async fn run_command(client: &Client, database: &str, command_json: &str) -> Result<MongoDocumentResult, String> {
let value: serde_json::Value =
serde_json::from_str(command_json).map_err(|error| format!("Invalid MongoDB command JSON: {error}"))?;
let command = json_object_to_document_extended_json(&value)
.map_err(|error| format!("Invalid MongoDB command document: {error}"))?;
if command.is_empty() {
return Err("MongoDB runCommand requires a non-empty command document".to_string());
}
let result = client.database(database).run_command(command).await.map_err(|error| error.to_string())?;
let (document, extended_document) = document_json_views(result);
Ok(MongoDocumentResult {
documents: vec![document],
raw_documents: None,
extended_documents: Some(vec![extended_document]),
total: 1,
total_is_exact: true,
next_cursor: None,
})
}
/// Bounded `collStats` round trips for the database view's per-collection stats.
const MONGO_OBJECT_STATISTICS_CONCURRENCY: usize = 8;
/// Per-collection document counts and on-disk sizes for the database view's
/// "rows"/"size" columns.
///
/// `listCollections` carries no size metadata, so every collection needs its own
/// `collStats`. Views are skipped (they own no storage and `collStats` rejects
/// them) and the round trips run with bounded concurrency so a database with
/// hundreds of collections does not serialize into one long wait. A collection
/// whose `collStats` fails is left out; the view then keeps that row blank.
pub async fn list_object_statistics(client: &Client, database: &str) -> Result<Vec<ObjectStatistics>, String> {
let database = database.trim();
if database.is_empty() {
return Err("Database name is required".to_string());
}
let collections = list_collection_specs(client, database).await?;
let handle = client.database(database);
let stats =
stream::iter(collections.into_iter().filter(|spec| spec.kind != MongoCollectionKind::View).map(|spec| {
let handle = handle.clone();
async move {
let result = handle.run_command(collection_stats_command(&spec.name, None).ok()?).await.ok()?;
Some(object_statistics_from_collection_stats(&spec.name, database, &result))
}
}))
.buffer_unordered(MONGO_OBJECT_STATISTICS_CONCURRENCY)
.collect::<Vec<_>>()
.await;
Ok(stats.into_iter().flatten().collect())
}
/// Map `collStats` onto the shared statistics shape: the document count, and the
/// on-disk footprint (data + indexes) the SQL engines report as
/// `DATA_LENGTH + INDEX_LENGTH`.
pub fn object_statistics_from_collection_stats(name: &str, database: &str, result: &Document) -> ObjectStatistics {
ObjectStatistics {
name: name.to_string(),
schema: Some(database.to_string()),
estimated_rows: collection_stats_int(result, "count"),
total_bytes: match (collection_stats_int(result, "storageSize"), collection_stats_int(result, "totalIndexSize"))
{
(None, None) => None,
(data, index) => Some(data.unwrap_or(0).saturating_add(index.unwrap_or(0))),
},
..Default::default()
}
}
fn collection_stats_int(result: &Document, key: &str) -> Option<i64> {
match result.get(key) {
Some(Bson::Int32(value)) => Some(i64::from(*value)),
Some(Bson::Int64(value)) => Some(*value),
Some(Bson::Double(value)) if value.is_finite() => Some(*value as i64),
_ => None,
}
}
fn server_version_from_build_info(result: &Document) -> Result<String, String> {
result.get_str("version").map(str::to_string).map_err(|e| format!("MongoDB server version not found: {e}"))
}
pub async fn collection_stats(
client: &Client,
database: &str,
collection: &str,
scale: Option<serde_json::Number>,
) -> Result<MongoCollectionStatsResult, String> {
let database = database.trim();
let collection = collection.trim();
if database.is_empty() {
return Err("Database name is required".to_string());
}
if collection.is_empty() {
return Err("Collection name is required".to_string());
}
let result = client
.database(database)
.run_command(collection_stats_command(collection, scale.as_ref())?)
.await
.map_err(|e| e.to_string())?;
Ok(collection_stats_result_from_document(&result))
}
/// The `collStats` command behind [`collection_stats`], for drivers that only expose `runCommand`.
pub fn collection_stats_command(collection: &str, scale: Option<&serde_json::Number>) -> Result<Document, String> {
let collection = collection.trim();
if collection.is_empty() {
return Err("Collection name is required".to_string());
}
let mut command = doc! { "collStats": collection };
if let Some(scale) = scale {
command.insert("scale", json_value_to_bson(&serde_json::Value::Number(scale.clone())));
}
Ok(command)
}
pub fn collection_stats_result_from_document(result: &Document) -> MongoCollectionStatsResult {
MongoCollectionStatsResult {
count: collection_stats_field(result, "count"),
size: collection_stats_field(result, "size"),
avg_obj_size: collection_stats_field(result, "avgObjSize"),
storage_size: collection_stats_field(result, "storageSize"),
total_index_size: collection_stats_field(result, "totalIndexSize"),
nindexes: collection_stats_field(result, "nindexes"),
}
}
fn collection_stats_field(result: &Document, key: &str) -> serde_json::Value {
result.get(key).map(bson_to_json).unwrap_or(serde_json::Value::Null)
}
pub async fn list_databases(client: &Client) -> Result<Vec<String>, String> {
match client.list_database_names().await {
Ok(databases) => Ok(databases),
Err(error) if list_databases_requires_secondary_fallback(&error.to_string()) => {
let result = client
.database("admin")
.run_command(doc! { "listDatabases": 1, "nameOnly": true })
.selection_criteria(list_databases_secondary_selection())
.await
.map_err(|fallback| fallback.to_string())?;
let databases = result
.get_array("databases")
.map_err(|error| format!("MongoDB listDatabases response is invalid: {error}"))?
.iter()
.filter_map(|database| database.as_document()?.get_str("name").ok().map(str::to_string))
.collect();
Ok(databases)
}
Err(error) => Err(error.to_string()),
}
}
fn list_databases_secondary_selection() -> SelectionCriteria {
SelectionCriteria::ReadPreference(ReadPreference::SecondaryPreferred { options: None })
}
fn list_databases_requires_secondary_fallback(error: &str) -> bool {
let error = error.to_ascii_lowercase();
error.contains("not master")
|| error.contains("not primary")
|| error.contains("notmaster")
|| error.contains("notwritableprimary")
|| error.contains("slaveok=false")
}
/// MongoDB collection kind from `listCollections` (not GridFS buckets).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MongoCollectionKind {
Collection,
View,
Timeseries,
}
impl MongoCollectionKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Collection => "collection",
Self::View => "view",
Self::Timeseries => "timeseries",
}
}
pub fn from_driver_type(collection_type: &mongodb::results::CollectionType) -> Self {
match collection_type {
mongodb::results::CollectionType::View => Self::View,
mongodb::results::CollectionType::Timeseries => Self::Timeseries,
// Collection and any future non_exhaustive variants default to a renamable collection.
_ => Self::Collection,
}
}
/// Convert the Legacy Agent's optional listCollections metadata. Unknown
/// values stay compatible with the former name-only response.
pub fn from_metadata_kind(kind: Option<&str>) -> Self {
match kind.map(str::trim) {
Some(kind) if kind.eq_ignore_ascii_case("view") => Self::View,
Some(kind) if kind.eq_ignore_ascii_case("timeseries") => Self::Timeseries,
_ => Self::Collection,
}
}
}
/// Name + kind for a MongoDB collection/view/timeseries returned by full `listCollections`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MongoCollectionSpec {
pub name: String,
pub kind: MongoCollectionKind,
}
/// Full `listCollections` with type metadata (for sidebar rename gating, etc.).
pub async fn list_collection_specs(client: &Client, database: &str) -> Result<Vec<MongoCollectionSpec>, String> {
let mut cursor = client.database(database).list_collections().await.map_err(|e| e.to_string())?;
let mut specs = Vec::new();
while let Some(spec) = cursor.try_next().await.map_err(|e| e.to_string())? {
specs.push(MongoCollectionSpec {
name: spec.name,
kind: MongoCollectionKind::from_driver_type(&spec.collection_type),
});
}
Ok(specs)
}
/// Name-only listing (schema, GridFS helpers, and other callers that do not need type).
pub async fn list_collections(client: &Client, database: &str) -> Result<Vec<String>, String> {
client.database(database).list_collection_names().await.map_err(|e| e.to_string())
}
pub async fn list_gridfs_files(
client: &Client,
database: &str,
bucket: &str,
filter: Option<&str>,
sort: Option<&str>,
) -> Result<Vec<MongoGridFsFileInfo>, String> {
let bucket = normalized_gridfs_bucket_name(bucket)?;
let collection_name = format!("{bucket}.files");
let collection = client.database(database).collection::<Document>(&collection_name);
let filter_doc = gridfs_file_filter_document(filter)?;
let sort_doc = gridfs_file_sort_document(sort)?;
let mut cursor = collection.find(filter_doc).sort(sort_doc).await.map_err(|e| e.to_string())?;
let mut files = Vec::new();
while cursor.advance().await.map_err(|e| e.to_string())? {
let doc = cursor.deserialize_current().map_err(|e| e.to_string())?;
files.push(gridfs_file_info_from_document(&doc));
}
Ok(files)
}
fn gridfs_file_filter_document(filter: Option<&str>) -> Result<Document, String> {
match filter {
Some(raw) if !raw.trim().is_empty() => {
let json: serde_json::Value = serde_json::from_str(raw).map_err(|e| format!("Invalid filter JSON: {e}"))?;
json_filter_to_document(&json)
}
_ => Ok(doc! {}),
}
}
fn gridfs_file_sort_document(sort: Option<&str>) -> Result<Document, String> {
match sort {
Some(raw) if !raw.trim().is_empty() => {
let json: serde_json::Value = serde_json::from_str(raw).map_err(|e| format!("Invalid sort JSON: {e}"))?;
json_object_to_document(&json).map_err(|e| format!("Invalid sort: {e}"))
}
_ => Ok(doc! { "uploadDate": -1_i32, "_id": -1_i32 }),
}
}
pub async fn gridfs_bucket_summary(
client: &Client,
database: &str,
bucket: &str,
) -> Result<MongoGridFsBucketInfo, String> {
let bucket = normalized_gridfs_bucket_name(bucket)?;
let collection = client.database(database).collection::<Document>(&format!("{bucket}.files"));
let mut cursor = collection
.aggregate(vec![doc! {
"$group": {
"_id": Bson::Null,
"fileCount": { "$sum": 1_i32 },
"totalBytes": { "$sum": "$length" },
}
}])
.await
.map_err(|e| e.to_string())?;
let mut file_count = 0_u64;
let mut total_bytes = 0_i64;
if cursor.advance().await.map_err(|e| e.to_string())? {
let doc = cursor.deserialize_current().map_err(|e| e.to_string())?;
file_count =
doc.get_i64("fileCount").or_else(|_| doc.get_i32("fileCount").map(i64::from)).unwrap_or(0).max(0) as u64;
total_bytes = doc.get_i64("totalBytes").or_else(|_| doc.get_i32("totalBytes").map(i64::from)).unwrap_or(0);
}
Ok(MongoGridFsBucketInfo { name: bucket, file_count, total_bytes })
}
pub async fn create_gridfs_bucket(client: &Client, database: &str, bucket: &str) -> Result<(), String> {
let bucket = normalized_gridfs_bucket_name(bucket)?;
let database = client.database(database);
let files_name = format!("{bucket}.files");
let chunks_name = format!("{bucket}.chunks");
let existing: HashSet<String> =
database.list_collection_names().await.map_err(|e| e.to_string())?.into_iter().collect();
if !existing.contains(&files_name) {
database.create_collection(&files_name).await.map_err(|e| e.to_string())?;
}
if !existing.contains(&chunks_name) {
database.create_collection(&chunks_name).await.map_err(|e| e.to_string())?;
}
database
.collection::<Document>(&files_name)
.create_index(
IndexModel::builder()
.keys(doc! { "filename": 1_i32, "uploadDate": 1_i32 })
.options(IndexOptions::builder().name(Some("filename_1_uploadDate_1".to_string())).build())
.build(),
)
.await
.map_err(|e| e.to_string())?;
database
.collection::<Document>(&chunks_name)
.create_index(
IndexModel::builder()
.keys(doc! { "files_id": 1_i32, "n": 1_i32 })
.options(IndexOptions::builder().name(Some("files_id_1_n_1".to_string())).unique(Some(true)).build())
.build(),
)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
pub async fn delete_gridfs_bucket(client: &Client, database: &str, bucket: &str) -> Result<(), String> {
let bucket = normalized_gridfs_bucket_name(bucket)?;
let database = client.database(database);
drop_collection_if_exists(&database, &format!("{bucket}.files")).await?;
drop_collection_if_exists(&database, &format!("{bucket}.chunks")).await?;
Ok(())
}
pub async fn download_gridfs_file(
client: &Client,
database: &str,
bucket: &str,
file_id: &str,
) -> Result<Vec<u8>, String> {
let bucket = normalized_gridfs_bucket_name(bucket)?;
let trimmed = file_id.trim();
if trimmed.is_empty() {
return Err("GridFS file id is required".to_string());
}
let bson_id = parse_gridfs_file_id(trimmed)?;
let files_collection = client.database(database).collection::<Document>(&format!("{bucket}.files"));
let file_doc = files_collection
.find_one(doc! { "_id": bson_id.clone() })
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| "GridFS file not found".to_string())?;
let bucket =
client.database(database).gridfs_bucket(GridFsBucketOptions::builder().bucket_name(bucket.to_string()).build());
let mut stream = bucket
.open_download_stream(file_doc.get("_id").cloned().unwrap_or(bson_id))
.await
.map_err(|e| e.to_string())?;
let mut bytes = Vec::new();
stream.read_to_end(&mut bytes).await.map_err(|e| e.to_string())?;
Ok(bytes)
}
pub async fn upload_gridfs_file(
client: &Client,
database: &str,
bucket: &str,
file_name: &str,
data: &[u8],
content_type: Option<&str>,
) -> Result<String, String> {
let bucket = normalized_gridfs_bucket_name(bucket)?;
let file_name = file_name.trim();
if file_name.is_empty() {
return Err("GridFS file name is required".to_string());
}
create_gridfs_bucket(client, database, &bucket).await?;
let database_ref = client.database(database);
let gridfs_bucket = database_ref.gridfs_bucket(GridFsBucketOptions::builder().bucket_name(bucket.clone()).build());
let mut upload_action = gridfs_bucket.open_upload_stream(file_name);
if let Some(content_type) = content_type.map(str::trim).filter(|value| !value.is_empty()) {
upload_action = upload_action.metadata(doc! { "contentType": content_type });
}
let mut stream = upload_action.await.map_err(|e| e.to_string())?;
let file_id = stream.id().clone();
stream.write_all(data).await.map_err(|e| e.to_string())?;
stream.close().await.map_err(|e| e.to_string())?;
if let Some(content_type) = content_type.map(str::trim).filter(|value| !value.is_empty()) {
database_ref
.collection::<Document>(&format!("{bucket}.files"))
.update_one(doc! { "_id": file_id.clone() }, doc! { "$set": { "contentType": content_type } })
.await
.map_err(|e| e.to_string())?;
}
Ok(gridfs_file_id_to_string(&file_id))
}
pub async fn delete_gridfs_file(client: &Client, database: &str, bucket: &str, file_id: &str) -> Result<(), String> {
let bucket = normalized_gridfs_bucket_name(bucket)?;
let bson_id = parse_gridfs_file_id(file_id.trim())?;
client
.database(database)
.gridfs_bucket(GridFsBucketOptions::builder().bucket_name(bucket).build())
.delete(bson_id)
.await
.map_err(|e| e.to_string())
}
fn gridfs_file_id_to_string(id: &Bson) -> String {
match id {
Bson::ObjectId(value) => value.to_hex(),
Bson::String(value) => value.clone(),
_ => id.clone().into_relaxed_extjson().to_string(),
}
}
fn gridfs_upload_date_to_string(value: &DateTime) -> String {
value.try_to_rfc3339_string().unwrap_or_else(|_| value.timestamp_millis().to_string())
}
fn gridfs_file_info_from_document(doc: &Document) -> MongoGridFsFileInfo {
let id = gridfs_file_id_to_string(doc.get("_id").unwrap_or(&Bson::Null));
let filename = doc.get_str("filename").ok().map(str::to_string);
let length = doc.get_i64("length").or_else(|_| doc.get_i32("length").map(i64::from)).unwrap_or(0);
let chunk_size =
doc.get_i32("chunkSize").or_else(|_| doc.get_i64("chunkSize").map(|value| value as i32)).unwrap_or(0);
let upload_date = doc.get_datetime("uploadDate").ok().map(gridfs_upload_date_to_string);
let metadata = doc.get_document("metadata").ok().map(|value| Bson::Document(value.clone()).into_relaxed_extjson());
let md5 = doc.get_str("md5").ok().map(str::to_string);
let content_type = doc.get_str("contentType").ok().map(str::to_string).or_else(|| {
doc.get_document("metadata").ok().and_then(|meta| meta.get_str("contentType").ok().map(str::to_string))
});
let aliases = doc.get_array("aliases").ok().and_then(|values| {
let aliases: Vec<String> = values.iter().filter_map(|value| value.as_str().map(str::to_string)).collect();
if aliases.is_empty() {
None
} else {
Some(aliases)
}
});
MongoGridFsFileInfo { id, filename, length, chunk_size, upload_date, metadata, md5, content_type, aliases }
}
fn parse_gridfs_file_id(file_id: &str) -> Result<Bson, String> {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(file_id) {
return Bson::try_from(value).map_err(|e| format!("Invalid GridFS file id: {e}"));
}
if let Ok(object_id) = ObjectId::parse_str(file_id) {
return Ok(Bson::ObjectId(object_id));
}
Ok(Bson::String(file_id.to_string()))
}
fn normalized_gridfs_bucket_name(bucket: &str) -> Result<String, String> {
let bucket = bucket.trim();
if bucket.is_empty() {
return Err("GridFS bucket name is required".to_string());
}
if bucket.ends_with(".files") || bucket.ends_with(".chunks") {
return Err("Use the GridFS bucket name without the .files or .chunks suffix".to_string());
}
Ok(bucket.to_string())
}
async fn drop_collection_if_exists(database: &Database, collection_name: &str) -> Result<(), String> {
match database.collection::<Document>(collection_name).drop().await {
Ok(()) => Ok(()),
Err(error) if mongo_namespace_missing(&error.to_string()) => Ok(()),
Err(error) => Err(error.to_string()),
}
}
fn mongo_namespace_missing(error: &str) -> bool {
let lower = error.to_lowercase();
lower.contains("ns not found") || lower.contains("namespace not found")
}
/// MongoDB has no "create database"; a database exists once it holds a collection, so both
/// drivers create this placeholder.
pub const CREATE_DATABASE_PLACEHOLDER_COLLECTION: &str = "dbx_init";
pub async fn create_database(client: &Client, database: &str) -> Result<(), String> {
let database = validate_create_database_name(database)?;
client.database(database).create_collection(CREATE_DATABASE_PLACEHOLDER_COLLECTION).await.map_err(|e| e.to_string())
}
pub fn validate_create_database_name(database: &str) -> Result<&str, String> {
let database = database.trim();
if database.is_empty() {
return Err("Database name is required".to_string());
}
Ok(database)
}
pub async fn drop_database(client: &Client, database: &str) -> Result<(), String> {
let database = validate_mongo_namespace_name(database, "Database")?;
client.database(database).drop().await.map_err(|e| e.to_string())
}
pub async fn drop_collection(client: &Client, database: &str, collection: &str) -> Result<(), String> {
let database = validate_mongo_namespace_name(database, "Database")?;
let collection = validate_mongo_namespace_name(collection, "Collection")?;
client.database(database).collection::<Document>(collection).drop().await.map_err(|e| e.to_string())
}
/// MongoDB namespace identifiers must reach the server unchanged. In
/// particular, a collection name may legitimately contain leading or trailing
/// whitespace, so validation only rejects the empty identifier.
pub fn validate_mongo_namespace_name<'a>(name: &'a str, kind: &str) -> Result<&'a str, String> {
if name.is_empty() {
return Err(format!("{kind} name is required"));
}
Ok(name)
}
/// Build the admin `renameCollection` command document for a same-database rename.
///
/// Collection (and database) names are identifiers and must be passed exactly as provided —
/// do not trim or otherwise normalize them before building the namespace.
pub fn rename_collection_command_document(database: &str, old_name: &str, new_name: &str) -> Result<Document, String> {
if database.is_empty() {
return Err("Database name is required".to_string());
}
if old_name.is_empty() {
return Err("Collection name is required".to_string());
}
if new_name.is_empty() {
return Err("New collection name is required".to_string());
}
if old_name == new_name {
return Err("New collection name must differ from the current name".to_string());
}
// MongoDB reserves system.* namespaces; reject them before exposing a rename that the server cannot perform.
if old_name.starts_with("system.") || new_name.starts_with("system.") {
return Err("System collections cannot be renamed".to_string());
}
Ok(doc! {
"renameCollection": format!("{database}.{old_name}"),
"to": format!("{database}.{new_name}"),
})
}
pub async fn rename_collection(client: &Client, database: &str, old_name: &str, new_name: &str) -> Result<(), String> {
let command = rename_collection_command_document(database, old_name, new_name)?;
client.database("admin").run_command(command).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Clone a regular collection within one database without relying on MongoDB's
/// deprecated clone commands. The individual commands used here have been
/// available across the MongoDB versions supported by the native driver.
pub async fn clone_collection(
client: &Client,
database: &str,
source_name: &str,
target_name: &str,
) -> Result<MongoCloneCollectionResult, String> {
validate_clone_collection_names(database, source_name, target_name)?;
let database = client.database(database);
let source_spec = find_collection_specification(&database, source_name).await?;
if !matches!(source_spec.collection_type, mongodb::results::CollectionType::Collection) {
return Err(
"Only regular MongoDB collections can be cloned; views and time-series collections are not supported"
.to_string(),
);
}
// Create explicitly before copying data so an existing target fails rather
// than being silently merged with or overwritten by the source documents.
database
.create_collection(target_name)
.with_options(source_spec.options.clone())
.await
.map_err(|error| error.to_string())?;
let source = database.collection::<Document>(source_name);
let target = database.collection::<Document>(target_name);
let needs_validation_bypass = source_spec.options.validator.is_some()
|| source_spec.options.validation_level.is_some()
|| source_spec.options.validation_action.is_some();
let mut cursor = source.find(doc! {}).await.map_err(|error| error.to_string())?;
let mut batch = Vec::with_capacity(1_000);
let mut documents_copied = 0_u64;
while let Some(document) = cursor.try_next().await.map_err(|error| error.to_string())? {
batch.push(document);
if batch.len() == 1_000 {
documents_copied += insert_clone_batch(&target, &mut batch, needs_validation_bypass).await?;
}
}
if !batch.is_empty() {
documents_copied += insert_clone_batch(&target, &mut batch, needs_validation_bypass).await?;
}
// The target gets its _id index during createCollection. Recreating every
// other source index after the data copy avoids needless index maintenance.
let mut index_cursor = source.list_indexes().await.map_err(|error| error.to_string())?;
let mut indexes_copied = 0_u64;
while let Some(index) = index_cursor.try_next().await.map_err(|error| error.to_string())? {
if is_automatic_id_index(&index) {
continue;
}
target.create_index(index).await.map_err(|error| error.to_string())?;
indexes_copied += 1;
}
Ok(MongoCloneCollectionResult { documents_copied, indexes_copied })
}
async fn find_collection_specification(
database: &Database,
source_name: &str,
) -> Result<mongodb::results::CollectionSpecification, String> {
let mut cursor = database.list_collections().await.map_err(|error| error.to_string())?;
while let Some(specification) = cursor.try_next().await.map_err(|error| error.to_string())? {
if specification.name == source_name {
return Ok(specification);
}
}
Err(format!("MongoDB collection '{source_name}' was not found"))
}
async fn insert_clone_batch(
target: &mongodb::Collection<Document>,
batch: &mut Vec<Document>,
bypass_document_validation: bool,
) -> Result<u64, String> {
let documents = std::mem::take(batch);
let result = if bypass_document_validation {
target.insert_many(documents).bypass_document_validation(true).await
} else {
target.insert_many(documents).await
}
.map_err(|error| error.to_string())?;
Ok(result.inserted_ids.len() as u64)
}
/// `createCollection` always creates the `_id` index, but it need not be named
/// `_id_` (for example, on a clustered collection). Compare the key instead
/// of the name so cloning never attempts to create it a second time.
fn is_automatic_id_index(index: &IndexModel) -> bool {
index.keys.len() == 1
&& (matches!(index.keys.get("_id"), Some(Bson::Int32(1) | Bson::Int64(1)))
|| matches!(index.keys.get("_id"), Some(Bson::Double(value)) if *value == 1.0))
}
pub fn validate_clone_collection_names(database: &str, source_name: &str, target_name: &str) -> Result<(), String> {
validate_mongo_namespace_name(database, "Database")?;
validate_mongo_namespace_name(source_name, "Source collection")?;
validate_mongo_namespace_name(target_name, "Target collection")?;
if source_name == target_name {
return Err("Target collection name must differ from the source collection name".to_string());
}
if source_name.starts_with("system.") || target_name.starts_with("system.") {
return Err("System collections cannot be cloned".to_string());
}
Ok(())
}
pub async fn list_indexes(client: &Client, database: &str, collection: &str) -> Result<Vec<IndexInfo>, String> {
let col = client.database(database).collection::<Document>(collection);
let mut cursor = col.list_indexes().await.map_err(|e| e.to_string())?;
let mut indexes = Vec::new();
while let Some(model) = cursor.try_next().await.map_err(|e| e.to_string())? {
indexes.push(index_info_from_model(model));
}
Ok(indexes)
}
fn index_info_from_model(model: IndexModel) -> IndexInfo {
let name = model.options.as_ref().and_then(|options| options.name.clone()).unwrap_or_else(|| {
model.keys.iter().map(|(field, value)| format!("{field}_{value}")).collect::<Vec<_>>().join("_")
});
let columns = model.keys.keys().cloned().collect::<Vec<_>>();
let index_type = if model.keys.is_empty() {
None
} else {
Some(model.keys.iter().map(|(field, value)| format!("{field}: {value}")).collect::<Vec<_>>().join(", "))
};
let filter = model
.options
.as_ref()
.and_then(|options| options.partial_filter_expression.as_ref())
.map(|filter| bson_to_json(&Bson::Document(filter.clone())).to_string());
IndexInfo {
is_unique: model.options.as_ref().and_then(|options| options.unique).unwrap_or(false),
is_primary: name == "_id_",
name,
columns,
filter,
index_type,
included_columns: None,
comment: None,
key_is_expression: Vec::new(),
column_opclasses: vec![],
key_options: Vec::new(),
constraint_backed: false,
}
}
/// One key of a MongoDB index, with the direction/type kept as the server reports it
/// (`1`, `-1`, `text`, `2dsphere`, `hashed`, …).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MongoIndexKey {
pub field: String,
pub direction: String,
}
/// Full MongoDB index specification straight from `listIndexes`.
///
/// The shared [`IndexInfo`] cannot carry `sparse`, `expireAfterSeconds`, `background`
/// or `bucketSize`, so index management reads this MongoDB-specific shape instead.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MongoIndexSpec {
pub name: String,
pub keys: Vec<MongoIndexKey>,
pub is_unique: bool,
pub is_primary: bool,
pub is_sparse: bool,
/// TTL in seconds; `None` when the index does not expire.
pub expire_after_seconds: Option<i64>,
/// Partial index condition, serialized as JSON.
pub partial_filter_expression: Option<String>,
/// Ignored by MongoDB 4.2+, still reported by older servers.
pub background: bool,
/// Only meaningful for geoHaystack indexes, removed in MongoDB 4.4+.
pub bucket_size: Option<i64>,
pub hidden: bool,
/// `false` when the properties above could not be read (Legacy Agent fallback),
/// so callers can avoid presenting defaults as if the server had reported them.
pub properties_complete: bool,
/// Options this build does not model, serialized as JSON for display only.
pub extra_options: Option<String>,
}
/// Index-spec fields this build maps explicitly; everything else lands in `extra_options`.
/// `v` and `ns` are server bookkeeping, listed here only to keep them out of that bucket.
const MODELED_INDEX_FIELDS: &[&str] = &[
"name",
"key",
"v",
"ns",
"unique",
"sparse",
"expireAfterSeconds",
"partialFilterExpression",
"background",
"bucketSize",
"hidden",
];
/// Read every index of a collection with all of its options preserved.
pub async fn list_index_specs(
client: &Client,
database: &str,
collection: &str,
) -> Result<Vec<MongoIndexSpec>, String> {
let database = validate_mongo_namespace_name(database, "Database")?;
let collection = validate_mongo_namespace_name(collection, "Collection")?;
// Raw command rather than `Collection::list_indexes`, whose IndexModel drops
// sparse/TTL/background. The driver cursor owns getMore and killCursors.
let mut cursor = client
.database(database)
.run_cursor_command(doc! { "listIndexes": collection })
.await
.map_err(|e| e.to_string())?;
let mut specs = Vec::new();
while let Some(document) = cursor.try_next().await.map_err(|e| e.to_string())? {
specs.push(index_spec_from_document(&document));
}
Ok(specs)
}
/// Canonicalize an index key direction so `1.0` and `1` read the same downstream.
fn index_key_direction(value: &Bson) -> String {
match value {
Bson::String(value) => value.clone(),
Bson::Int32(value) => value.to_string(),
Bson::Int64(value) => value.to_string(),
Bson::Double(value) if value.fract() == 0.0 && value.is_finite() => (*value as i64).to_string(),
value => value.to_string(),
}
}
/// MongoDB accepts booleans and truthy numbers for index flags.
fn index_flag(document: &Document, field: &str) -> bool {
match document.get(field) {
Some(Bson::Boolean(value)) => *value,
Some(Bson::Int32(value)) => *value != 0,
Some(Bson::Int64(value)) => *value != 0,
Some(Bson::Double(value)) => *value != 0.0,
_ => false,
}
}
/// TTL and bucket size arrive as any BSON number depending on server version.
fn index_number(document: &Document, field: &str) -> Option<i64> {
match document.get(field) {
Some(Bson::Int32(value)) => Some(i64::from(*value)),
Some(Bson::Int64(value)) => Some(*value),
Some(Bson::Double(value)) if value.is_finite() => Some(*value as i64),
_ => None,
}
}
/// Map one `listIndexes` document onto [`MongoIndexSpec`].
pub fn index_spec_from_document(document: &Document) -> MongoIndexSpec {
let keys = match document.get("key") {
Some(Bson::Document(keys)) => keys
.iter()
.map(|(field, value)| MongoIndexKey { field: field.clone(), direction: index_key_direction(value) })
.collect::<Vec<_>>(),
_ => Vec::new(),
};
let name =
document.get_str("name").ok().map(str::to_string).filter(|name| !name.trim().is_empty()).unwrap_or_else(|| {
keys.iter().map(|key| format!("{}_{}", key.field, key.direction)).collect::<Vec<_>>().join("_")
});
let partial_filter_expression = match document.get("partialFilterExpression") {
Some(Bson::Document(filter)) => Some(bson_to_json(&Bson::Document(filter.clone())).to_string()),
_ => None,
};
let extra: Document = document
.iter()
.filter(|(field, _)| !MODELED_INDEX_FIELDS.contains(&field.as_str()))
.map(|(field, value)| (field.clone(), value.clone()))
.collect();
let extra_options = (!extra.is_empty()).then(|| bson_to_json(&Bson::Document(extra)).to_string());
let is_primary = name == "_id_";
MongoIndexSpec {
is_primary,
name,
keys,
is_unique: index_flag(document, "unique") || is_primary,
is_sparse: index_flag(document, "sparse"),
expire_after_seconds: index_number(document, "expireAfterSeconds"),
partial_filter_expression,
background: index_flag(document, "background"),
bucket_size: index_number(document, "bucketSize"),
hidden: index_flag(document, "hidden"),
properties_complete: true,
extra_options,
}
}
/// Degrade a shared [`IndexInfo`] into a spec for drivers that cannot report the
/// full option set. `properties_complete` stays `false` so nothing is presented as
/// server truth that was never read.
pub fn index_spec_from_index_info(info: &IndexInfo) -> MongoIndexSpec {
let keys = match info.index_type.as_deref().map(str::trim).filter(|spec| !spec.is_empty()) {
Some(spec) => spec
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(|part| match part.rsplit_once(':') {
Some((field, direction)) if !field.trim().is_empty() => {
MongoIndexKey { field: field.trim().to_string(), direction: direction.trim().to_string() }
}
_ => MongoIndexKey { field: part.to_string(), direction: String::new() },
})
.collect::<Vec<_>>(),
None => {
info.columns.iter().map(|field| MongoIndexKey { field: field.clone(), direction: String::new() }).collect()
}
};
MongoIndexSpec {
name: info.name.clone(),
keys,
is_unique: info.is_unique,
is_primary: info.is_primary,
is_sparse: false,
expire_after_seconds: None,
partial_filter_expression: info.filter.clone(),
background: false,
bucket_size: None,
hidden: false,
properties_complete: false,
extra_options: None,
}
}
fn parse_find_collation(value: Option<&str>) -> Result<Option<Collation>, String> {
let Some(raw) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
let json: serde_json::Value =
serde_json::from_str(raw).map_err(|error| format!("Invalid collation JSON: {error}"))?;
let object = json.as_object().ok_or_else(|| "Invalid collation: expected an options object".to_string())?;
if let Some(field) = object.keys().find(|field| !MONGO_COLLATION_FIELDS.contains(&field.as_str())) {
return Err(format!("Unsupported collation option: {field}"));
}
let collation: Collation = serde_json::from_value(json).map_err(|error| format!("Invalid collation: {error}"))?;
if collation.locale.trim().is_empty() {
return Err("Invalid collation: locale must not be empty".to_string());
}
Ok(Some(collation))
}
pub async fn find_documents(
client: &Client,
database: &str,
collection: &str,
skip: u64,
limit: i64,
filter: Option<&str>,
projection: Option<&str>,
sort: Option<&str>,
collation: Option<&str>,
) -> Result<MongoDocumentResult, String> {
find_documents_with_total(client, database, collection, skip, limit, filter, projection, sort, collation, true)
.await
}
/// Execute a find without the document browser's separate total-count query.
/// Agent callers only consume returned rows, so counting the full result set adds latency
/// without providing any useful output.
pub async fn find_documents_without_total(
client: &Client,
database: &str,
collection: &str,
skip: u64,
limit: i64,
filter: Option<&str>,
projection: Option<&str>,
sort: Option<&str>,
collation: Option<&str>,
) -> Result<MongoDocumentResult, String> {
find_documents_with_total(client, database, collection, skip, limit, filter, projection, sort, collation, false)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn explain_find(
client: &Client,
database: &str,
collection: &str,
skip: u64,
limit: i64,
filter: Option<&str>,
projection: Option<&str>,
sort: Option<&str>,
collation: Option<&str>,
verbosity: &str,
) -> Result<serde_json::Value, String> {
let command = build_find_explain_command(collection, skip, limit, filter, projection, sort, collation, verbosity)?;
let result = client.database(database).run_command(command).await.map_err(|error| error.to_string())?;
Ok(bson_to_json(&Bson::Document(result)))
}
#[allow(clippy::too_many_arguments)]
fn build_find_explain_command(
collection: &str,
skip: u64,
limit: i64,
filter: Option<&str>,
projection: Option<&str>,
sort: Option<&str>,
collation: Option<&str>,
verbosity: &str,
) -> Result<Document, String> {
let mut find = doc! {
"find": collection,
"filter": parse_optional_filter_document(filter)?.unwrap_or_default(),
};
if let Some(projection) = parse_optional_json_document(projection, "projection")? {
find.insert("projection", projection);
}
if let Some(sort) = parse_optional_json_document(sort, "sort")? {
find.insert("sort", sort);
}
if let Some(collation) = parse_find_collation(collation)? {
find.insert(
"collation",
mongodb::bson::to_document(&collation).map_err(|error| format!("Invalid collation: {error}"))?,
);
}
if skip > 0 {
find.insert("skip", i64::try_from(skip).map_err(|_| "MongoDB skip exceeds the supported range")?);
}
if limit > 0 {
find.insert("limit", limit);
}
Ok(doc! {
"explain": find,
"verbosity": validate_find_explain_verbosity(verbosity)?,
})
}
fn validate_find_explain_verbosity(verbosity: &str) -> Result<&str, String> {
match verbosity {
"queryPlanner" | "executionStats" | "allPlansExecution" => Ok(verbosity),
_ => Err("MongoDB explain verbosity must be queryPlanner, executionStats, or allPlansExecution.".to_string()),
}
}
#[allow(clippy::too_many_arguments)]
async fn find_documents_with_total(
client: &Client,
database: &str,
collection: &str,
skip: u64,
limit: i64,
filter: Option<&str>,
projection: Option<&str>,
sort: Option<&str>,
collation: Option<&str>,
include_total: bool,
) -> Result<MongoDocumentResult, String> {
let col = client.database(database).collection::<Document>(collection);
let filter_doc: Document = match filter {
Some(f) if !f.trim().is_empty() => {
let json: serde_json::Value = serde_json::from_str(f).map_err(|e| format!("Invalid filter JSON: {e}"))?;
json_filter_to_document(&json)?
}
_ => doc! {},
};
let collation = parse_find_collation(collation)?;
let count_is_exact = !filter_doc.is_empty();
let total_result = if !include_total {
None
} else if count_is_exact {
let mut count = col.count_documents(filter_doc.clone());
if let Some(collation) = collation.clone() {
count = count.collation(collation);
}
Some(count.await.map_err(|e| e.to_string()))
} else {
Some(estimated_document_count(client, database, collection).await)
};
let mut find = col.find(filter_doc).skip(skip).limit(limit);
if let Some(p) = projection {
if !p.trim().is_empty() {
let json: serde_json::Value =
serde_json::from_str(p).map_err(|e| format!("Invalid projection JSON: {e}"))?;
let projection_doc = json_object_to_document(&json).map_err(|e| format!("Invalid projection: {e}"))?;
find = find.projection(projection_doc);
}
}
if let Some(s) = sort {
if !s.trim().is_empty() {
let json: serde_json::Value = serde_json::from_str(s).map_err(|e| format!("Invalid sort JSON: {e}"))?;
let sort_doc = json_object_to_document(&json).map_err(|e| format!("Invalid sort: {e}"))?;
find = find.sort(sort_doc);
}
}
if let Some(collation) = collation {
find = find.collation(collation);
}
let mut cursor = find.await.map_err(|e| e.to_string())?;
let mut documents = Vec::new();
let mut extended_documents = Vec::new();
while cursor.advance().await.map_err(|e| e.to_string())? {
let doc = cursor.deserialize_current().map_err(|e| e.to_string())?;
documents.push(bson_to_json(&Bson::Document(doc.clone())));
extended_documents.push(Bson::Document(doc).into_canonical_extjson());
}
let (total, total_is_exact) = match total_result {
Some(total_result) => resolve_mongo_find_total(total_result, count_is_exact, skip, documents.len()),
None => (documents.len() as u64, false),
};
Ok(MongoDocumentResult {
documents,
raw_documents: None,
extended_documents: Some(extended_documents),
total,
total_is_exact,
next_cursor: None,
})
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct MongoFindOneOptions {
sort: Option<serde_json::Value>,
}
pub async fn find_one(
client: &Client,
database: &str,
collection: &str,
filter: Option<&str>,
projection: Option<&str>,
options: Option<&str>,
) -> Result<MongoDocumentResult, String> {
let filter_doc = parse_optional_filter_document(filter)?.unwrap_or_default();
let projection_doc = parse_optional_json_document(projection, "projection")?;
let options = parse_find_one_options(options)?;
let sort_doc = parse_optional_document(options.sort.as_ref(), "sort")?;
let col = client.database(database).collection::<Document>(collection);
let mut action = col.find_one(filter_doc);
if let Some(projection) = projection_doc {
action = action.projection(projection);
}
if let Some(sort) = sort_doc {
action = action.sort(sort);
}
let result = action.await.map_err(|e| e.to_string())?;
Ok(single_document_result(result))
}
fn parse_optional_filter_document(value: Option<&str>) -> Result<Option<Document>, String> {
let Some(raw) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
let json: serde_json::Value = serde_json::from_str(raw).map_err(|e| format!("Invalid filter JSON: {e}"))?;
json_filter_to_document(&json).map(Some).map_err(|e| format!("Invalid filter: {e}"))
}
fn parse_find_one_options(options: Option<&str>) -> Result<MongoFindOneOptions, String> {
match options.map(str::trim).filter(|value| !value.is_empty()) {
Some(raw) => serde_json::from_str(raw).map_err(|e| format!("Invalid findOne options: {e}")),
None => Ok(MongoFindOneOptions::default()),
}
}
fn parse_optional_json_document(value: Option<&str>, label: &str) -> Result<Option<Document>, String> {
let Some(raw) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
let json: serde_json::Value = serde_json::from_str(raw).map_err(|e| format!("Invalid {label} JSON: {e}"))?;
json_object_to_document(&json).map(Some).map_err(|e| format!("Invalid {label}: {e}"))
}
pub async fn count_documents(
client: &Client,
database: &str,
collection: &str,
filter: Option<&str>,
accurate: bool,
) -> Result<u64, String> {
let col = client.database(database).collection::<Document>(collection);
let filter_doc: Document = match filter {
Some(f) if !f.trim().is_empty() => {
let json: serde_json::Value = serde_json::from_str(f).map_err(|e| format!("Invalid filter JSON: {e}"))?;
json_filter_to_document(&json)?
}
_ => doc! {},
};
if !accurate && filter_doc.is_empty() {
// Legacy count() permits the metadata-backed fast path; countDocuments() must scan accurately.
estimated_document_count(client, database, collection).await
} else {
col.count_documents(filter_doc).await.map_err(|e| e.to_string())
}
}
async fn estimated_document_count(client: &Client, database: &str, collection: &str) -> Result<u64, String> {
let result = client.database(database).run_command(doc! { "count": collection }).await;
match result {
Ok(result) => parse_count_command_result(&result),
Err(error) => parse_count_command_error(&error.kind).ok_or_else(|| error.to_string()),
}
}
/// Resolves a failed `count` command the way the driver's `estimated_document_count` does:
/// NamespaceNotFound (code 26) means the collection is missing, i.e. an empty count, while any
/// other error must propagate. Mirrors mongodb's internal `Error::is_ns_not_found`.
fn parse_count_command_error(kind: &mongodb::error::ErrorKind) -> Option<u64> {
match kind {
mongodb::error::ErrorKind::Command(error) if error.code == 26 => Some(0),
_ => None,
}
}
fn parse_count_command_result(result: &Document) -> Result<u64, String> {
let value = result.get("n").ok_or_else(|| "MongoDB count command response is missing the 'n' field".to_string())?;
match value {
Bson::Int32(value) => u64::try_from(*value),
Bson::Int64(value) => u64::try_from(*value),
Bson::Double(value)
if value.is_finite()
&& *value >= 0.0
&& value.fract() == 0.0
&& *value <= super::JS_MAX_SAFE_INTEGER as f64 =>
{
Ok(*value as u64)
}
_ => return Err(format!("MongoDB count command returned an invalid 'n' value: {value:?}")),
}
.map_err(|_| format!("MongoDB count command returned a negative 'n' value: {value:?}"))
}
/// Find MongoDB documents in a browser-friendly representation.
#[allow(clippy::too_many_arguments)]
pub async fn find_documents_extended_json(
client: &Client,
database: &str,
collection: &str,
skip: u64,
limit: i64,
filter: Option<&str>,
projection: Option<&str>,
sort: Option<&str>,
collation: Option<&str>,
) -> Result<MongoDocumentResult, String> {
let col = client.database(database).collection::<Document>(collection);
let filter_doc: Document = match filter {
Some(f) if !f.trim().is_empty() => {
let json: serde_json::Value = serde_json::from_str(f).map_err(|e| format!("Invalid filter JSON: {e}"))?;
json_filter_to_document(&json)?
}
_ => doc! {},
};
let collation = parse_find_collation(collation)?;
let count_is_exact = !filter_doc.is_empty();
let total_result = if count_is_exact {
let mut count = col.count_documents(filter_doc.clone());
if let Some(collation) = collation.clone() {
count = count.collation(collation);
}
count.await.map_err(|e| e.to_string())
} else {
estimated_document_count(client, database, collection).await
};
let mut find = col.find(filter_doc).skip(skip).limit(limit);
if let Some(p) = projection {
if !p.trim().is_empty() {
let json: serde_json::Value =
serde_json::from_str(p).map_err(|e| format!("Invalid projection JSON: {e}"))?;
let projection_doc = json_object_to_document(&json).map_err(|e| format!("Invalid projection: {e}"))?;
find = find.projection(projection_doc);
}
}
if let Some(s) = sort {
if !s.trim().is_empty() {
let json: serde_json::Value = serde_json::from_str(s).map_err(|e| format!("Invalid sort JSON: {e}"))?;
let sort_doc = json_object_to_document(&json).map_err(|e| format!("Invalid sort: {e}"))?;
find = find.sort(sort_doc);
}
}
if let Some(collation) = collation {
find = find.collation(collation);
}
let mut cursor = find.await.map_err(|e| e.to_string())?;
let mut documents = Vec::new();
let mut extended_documents = Vec::new();
while cursor.advance().await.map_err(|e| e.to_string())? {
let doc = cursor.deserialize_current().map_err(|e| e.to_string())?;
let (document, extended_document) = document_json_views(doc);
documents.push(document);
extended_documents.push(extended_document);
}
let (total, total_is_exact) = resolve_mongo_find_total(total_result, count_is_exact, skip, documents.len());
Ok(MongoDocumentResult {
extended_documents: Some(extended_documents),
documents,
raw_documents: None,
total,
total_is_exact,
next_cursor: None,
})
}
fn resolve_mongo_find_total(
total_result: Result<u64, String>,
count_is_exact: bool,
skip: u64,
document_count: usize,
) -> (u64, bool) {
match total_result {
Ok(total) => (total, count_is_exact),
Err(error) => {
log::debug!(
"[mongo][find:count-fallback] count_mode={} skip={} documents={} error={}",
if count_is_exact { "exact" } else { "estimated" },
skip,
document_count,
error
);
(skip.saturating_add(u64::try_from(document_count).unwrap_or(u64::MAX)), false)
}
}
}
/// Run `db.collection.aggregate(pipeline, options)`.
///
/// One execution model for every non-explain aggregate (empty or free-form options):
/// build the server [`aggregate`](https://www.mongodb.com/docs/manual/reference/command/aggregate/)
/// command and open it with [`Database::run_cursor_command`] so session-scoped getMore/killCursors
/// stay with the driver cursor. Options are forwarded as-is (`allowDiskUse`, `cursor.batchSize`,
/// `maxTimeMS`, collation, hint, comment, let, …).
///
/// `explain: true` is the only special case: the server returns a plan document (no cursor), so
/// that path uses plain `run_command`.
///
/// Note: this intentionally uses the command/cursor path rather than `Collection::aggregate`, so
/// default read/write concern inheritance matches other free-form shell options (explicit options
/// on the command document only).
pub async fn aggregate_documents(
client: &Client,
database: &str,
collection: &str,
pipeline_json: &str,
max_rows: Option<usize>,
options_json: Option<&str>,
) -> Result<MongoDocumentResult, String> {
let json: serde_json::Value =
serde_json::from_str(pipeline_json).map_err(|e| format!("Invalid pipeline JSON: {e}"))?;
let pipeline_values = json.as_array().ok_or_else(|| "Aggregate pipeline must be a JSON array".to_string())?;
let pipeline_docs = pipeline_values
.iter()
.map(|value| json_object_to_document(value).map_err(|e| format!("Invalid pipeline stage: {e}")))
.collect::<Result<Vec<Document>, String>>()?;
let options = parse_aggregate_options_document(options_json)?;
let (command, explain) = build_aggregate_command(collection, pipeline_docs, options)?;
let db = client.database(database);
if explain {
let result = db.run_command(command).await.map_err(|e| e.to_string())?;
let document = bson_to_json(&Bson::Document(result.clone()));
let extended = Bson::Document(result).into_relaxed_extjson();
return Ok(MongoDocumentResult {
documents: vec![document],
raw_documents: None,
extended_documents: Some(vec![extended]),
total: 1,
total_is_exact: true,
next_cursor: None,
});
}
// Driver cursor owns the implicit session across aggregate + getMore + killCursors.
let mut cursor = db.run_cursor_command(command).await.map_err(|e| e.to_string())?;
drain_document_cursor(&mut cursor, max_rows).await
}
/// Build the server aggregate command document, preserving free-form options.
/// Returns `(command, explain)` so callers branch only on the explain flag.
fn build_aggregate_command(
collection: &str,
pipeline: Vec<Document>,
options: Document,
) -> Result<(Document, bool), String> {
let explain = aggregate_options_explain(&options)?;
let pipeline_bson: Vec<Bson> = pipeline.into_iter().map(Bson::Document).collect();
let mut command = doc! {
"aggregate": collection,
"pipeline": pipeline_bson,
};
for (key, value) in options {
command.insert(key, value);
}
// Server requires `cursor` unless `explain` is true.
if !explain && !command.contains_key("cursor") {
command.insert("cursor", doc! {});
}
Ok((command, explain))
}
/// Drain a driver cursor up to `max_rows`, peeking one extra document so callers can detect
/// truncation: when more rows exist, `total == max_rows + 1` while `documents.len() == max_rows`.
async fn drain_document_cursor(
cursor: &mut Cursor<Document>,
max_rows: Option<usize>,
) -> Result<MongoDocumentResult, String> {
let max_rows = max_rows.unwrap_or(100);
let fetch_limit = max_rows.saturating_add(1);
let mut documents = Vec::new();
let mut extended_documents = Vec::new();
while documents.len() < fetch_limit && cursor.advance().await.map_err(|e| e.to_string())? {
let doc = cursor.deserialize_current().map_err(|e| e.to_string())?;
documents.push(bson_to_json(&Bson::Document(doc.clone())));
extended_documents.push(Bson::Document(doc).into_relaxed_extjson());
}
let total = documents.len() as u64;
if documents.len() > max_rows {
documents.truncate(max_rows);
extended_documents.truncate(max_rows);
}
Ok(MongoDocumentResult {
documents,
raw_documents: None,
extended_documents: Some(extended_documents),
total,
total_is_exact: true,
next_cursor: None,
})
}
fn parse_aggregate_options_document(options_json: Option<&str>) -> Result<Document, String> {
let Some(raw) = options_json.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(Document::new());
};
let value: serde_json::Value =
serde_json::from_str(raw).map_err(|e| format!("Invalid aggregate options JSON: {e}"))?;
if !value.is_object() {
return Err("Aggregate options must be a JSON object".to_string());
}
// Free-form object conversion keeps every official aggregate option (nested
// docs, hint strings, maxTimeMS numbers, comments, let vars, …) intact.
json_object_to_document(&value).map_err(|e| format!("Invalid aggregate options: {e}"))
}
fn aggregate_options_explain(options: &Document) -> Result<bool, String> {
match options.get("explain") {
None => Ok(false),
Some(Bson::Boolean(flag)) => Ok(*flag),
Some(_) => Err("aggregate options.explain must be a boolean (for example { explain: true })".to_string()),
}
}
/// Distinct values of a field, matching the mongo shell's `db.coll.distinct(field, filter)`:
/// array fields contribute their elements rather than the whole array, and the server may
/// answer from an index with a DISTINCT_SCAN. Values are returned in the `documents` slot as
/// bare scalars, which `mongoDocumentsToQueryResult` already renders as a single column.
/// Validates the field name and parses the filter shared by the native `distinct` helper and the
/// command builder, so both drivers reject the same inputs with the same message.
fn distinct_inputs(field: &str, filter: Option<&str>) -> Result<Document, String> {
if field.trim().is_empty() {
return Err("Distinct field name is required".to_string());
}
match filter {
Some(f) if !f.trim().is_empty() => {
let json: serde_json::Value = serde_json::from_str(f).map_err(|e| format!("Invalid filter JSON: {e}"))?;
json_filter_to_document(&json)
}
_ => Ok(doc! {}),
}
}
fn distinct_values_result(values: Vec<Bson>) -> MongoDocumentResult {
let documents = values.iter().map(bson_to_json).collect::<Vec<_>>();
let extended_documents = values.into_iter().map(|value| value.into_relaxed_extjson()).collect::<Vec<_>>();
let total = documents.len() as u64;
MongoDocumentResult {
documents,
raw_documents: None,
extended_documents: Some(extended_documents),
total,
total_is_exact: true,
next_cursor: None,
}
}
/// The `distinct` server command behind `distinct()`, for drivers that only expose `runCommand`
/// (the legacy agent). Same field and filter validation as [`distinct`].
pub fn distinct_command(collection: &str, field: &str, filter: Option<&str>) -> Result<Document, String> {
let query = distinct_inputs(field, filter)?;
Ok(doc! { "distinct": collection, "key": field, "query": query })
}
/// Turns a `distinct` response (`{ values: [...] }`) into the same result shape as [`distinct`].
pub fn distinct_response_result(mut response: Document) -> Result<MongoDocumentResult, String> {
match response.remove("values") {
Some(Bson::Array(values)) => Ok(distinct_values_result(values)),
other => Err(format!("Unexpected distinct values: {other:?}")),
}
}
pub async fn distinct(
client: &Client,
database: &str,
collection: &str,
field: &str,
filter: Option<&str>,
) -> Result<MongoDocumentResult, String> {
let filter_doc = distinct_inputs(field, filter)?;
let col = client.database(database).collection::<Document>(collection);
let values = col.distinct(field, filter_doc).await.map_err(|e| e.to_string())?;
Ok(distinct_values_result(values))
}
pub async fn create_index(
client: &Client,
database: &str,
collection: &str,
keys_json: &str,
options_json: Option<&str>,
) -> Result<String, String> {
let database = validate_mongo_namespace_name(database, "Database")?;
let collection = validate_mongo_namespace_name(collection, "Collection")?;
let (command, name) = create_indexes_command(collection, keys_json, options_json)?;
client.database(database).run_command(command).await.map_err(|e| e.kind.to_string())?;
Ok(name)
}
pub async fn create_user(
client: &Client,
database: &str,
user_json: &str,
write_concern_json: Option<&str>,
) -> Result<(), String> {
let database = validate_mongo_namespace_name(database, "Database")?;
let command = create_user_command(user_json, write_concern_json)?;
client.database(database).run_command(command).await.map_err(|error| error.kind.to_string())?;
Ok(())
}
pub fn validate_create_user_request(user_json: &str, write_concern_json: Option<&str>) -> Result<(), String> {
create_user_command(user_json, write_concern_json).map(|_| ())
}
fn create_user_command(user_json: &str, write_concern_json: Option<&str>) -> Result<Document, String> {
let user_value: serde_json::Value =
serde_json::from_str(user_json).map_err(|error| format!("Invalid MongoDB user JSON: {error}"))?;
let mut user = json_object_to_document_extended_json(&user_value)
.map_err(|error| format!("Invalid MongoDB user document: {error}"))?;
let username = user
.remove("user")
.and_then(|value| value.as_str().map(str::to_string))
.filter(|value| !value.trim().is_empty())
.ok_or("MongoDB createUser requires a non-empty user name")?;
if user.contains_key("createUser") || user.contains_key("writeConcern") {
return Err("MongoDB createUser user document contains reserved command fields".to_string());
}
let mut command = doc! { "createUser": username };
command.extend(user);
if let Some(write_concern_json) = write_concern_json.filter(|value| !value.trim().is_empty()) {
let write_concern_value: serde_json::Value = serde_json::from_str(write_concern_json)
.map_err(|error| format!("Invalid MongoDB write concern JSON: {error}"))?;
let write_concern = json_object_to_document_extended_json(&write_concern_value)
.map_err(|error| format!("Invalid MongoDB write concern: {error}"))?;
command.insert("writeConcern", write_concern);
}
Ok(command)
}
/// Validate an index request before it reaches either the native driver or the
/// Legacy Agent. In particular, `key` belongs to the createIndexes command
/// itself and must not be smuggled through the options document.
pub fn validate_create_index_request(keys_json: &str, options_json: Option<&str>) -> Result<(), String> {
parse_create_index_spec(keys_json, options_json).map(|_| ())
}
fn create_indexes_command(
collection: &str,
keys_json: &str,
options_json: Option<&str>,
) -> Result<(Document, String), String> {
let (keys, options, name) = parse_create_index_spec(keys_json, options_json)?;
let mut index = doc! { "key": keys };
for (option, value) in options {
index.insert(option, value);
}
Ok((doc! { "createIndexes": collection, "indexes": [index] }, name))
}
fn parse_create_index_spec(
keys_json: &str,
options_json: Option<&str>,
) -> Result<(Document, Document, String), String> {
let keys_value: serde_json::Value =
serde_json::from_str(keys_json).map_err(|e| format!("Invalid index keys JSON: {e}"))?;
// The internal transport uses JSON rather than a Mongo shell expression.
// Keep ordinary strings literal while supporting official Extended JSON
// wrappers, matching the Legacy Agent's Document.parse behavior.
let keys = json_object_to_document_extended_json(&keys_value).map_err(|e| format!("Invalid index keys: {e}"))?;
if keys.is_empty() {
return Err("Index keys are required".to_string());
}
let options = match options_json.map(str::trim).filter(|json| !json.is_empty()) {
Some(json) => {
let value: serde_json::Value =
serde_json::from_str(json).map_err(|e| format!("Invalid index options JSON: {e}"))?;
json_object_to_document_extended_json(&value).map_err(|e| format!("Invalid index options: {e}"))?
}
None => Document::new(),
};
if options.contains_key("key") {
return Err("Index options cannot contain \"key\"; specify index fields in keys JSON".to_string());
}
let name = match options.get("name") {
Some(Bson::String(name)) if !name.trim().is_empty() => name.clone(),
Some(_) => return Err("Index option \"name\" must be a non-empty string".to_string()),
None => default_index_name(&keys),
};
// Use the raw server command instead of deserializing into the driver's
// fixed IndexOptions struct. This keeps native and Legacy Agent requests
// equivalent and lets the connected MongoDB version validate new options.
let mut options = options;
if !options.contains_key("name") {
options.insert("name", name.clone());
}
Ok((keys, options, name))
}
fn default_index_name(keys: &Document) -> String {
keys.iter()
.map(|(field, value)| {
let value = match value {
Bson::String(value) => value.clone(),
value => value.to_string(),
};
format!("{field}_{value}")
})
.collect::<Vec<_>>()
.join("_")
}
pub async fn drop_indexes(
client: &Client,
database: &str,
collection: &str,
indexes_json: Option<&str>,
single: bool,
) -> Result<MongoDropIndexesResult, String> {
let database = validate_mongo_namespace_name(database, "Database")?;
let collection = validate_mongo_namespace_name(collection, "Collection")?;
let index = parse_drop_indexes_value(indexes_json, single)?;
let before = list_indexes(client, database, collection).await?;
client
.database(database)
.run_command(doc! { "dropIndexes": collection, "index": index })
.await
.map_err(|e| e.to_string())?;
let after = list_indexes(client, database, collection).await?;
let dropped_names = diff_dropped_index_names(&before, &after);
Ok(MongoDropIndexesResult { affected_rows: dropped_names.len() as u64, dropped_names, failures: Vec::new() })
}
/// Validate a drop-index request before it is sent to either MongoDB driver.
/// The Legacy Agent receives the original JSON, so keeping this validation
/// public gives both paths the same protection for MongoDB's default index.
pub fn validate_drop_indexes_request(indexes_json: Option<&str>, single: bool) -> Result<(), String> {
parse_drop_indexes_value(indexes_json, single).map(|_| ())
}
/// MongoDB added array-form `dropIndexes.index` in 4.2. Unknown version
/// strings preserve the modern single-command semantics instead of risking a
/// partially applied serial fallback.
pub fn mongo_server_requires_serial_drop_indexes(version: &str) -> bool {
let Some(start) = version.find(|character: char| character.is_ascii_digit()) else {
return false;
};
let mut components = version[start..].split('.');
let Some(major) = components.next().and_then(parse_version_component) else {
return false;
};
let Some(minor) = components.next().and_then(parse_version_component) else {
return false;
};
(major, minor) < (4, 2)
}
fn parse_version_component(component: &str) -> Option<u32> {
let digits = component.chars().take_while(|character| character.is_ascii_digit()).collect::<String>();
(!digits.is_empty()).then(|| digits.parse().ok()).flatten()
}
/// Return names that must be issued as individual dropIndex commands for
/// MongoDB 3.4 compatibility. The original request is fully parsed first, so
/// an invalid name (including `_id_`) cannot cause a partial batch mutation.
pub fn serial_drop_index_names(indexes_json: Option<&str>, single: bool) -> Result<Option<Vec<String>>, String> {
let Bson::Array(indexes) = parse_drop_indexes_value(indexes_json, single)? else {
return Ok(None);
};
let mut seen = HashSet::new();
let mut names = Vec::new();
for index in indexes {
let Bson::String(name) = index else {
return Err("dropIndexes only accepts arrays of string index names".to_string());
};
if seen.insert(name.clone()) {
names.push(name);
}
}
Ok(Some(names))
}
fn diff_dropped_index_names(before: &[IndexInfo], after: &[IndexInfo]) -> Vec<String> {
let remaining = after.iter().map(|index| index.name.as_str()).collect::<HashSet<_>>();
before.iter().filter(|index| !remaining.contains(index.name.as_str())).map(|index| index.name.clone()).collect()
}
fn parse_drop_indexes_value(indexes_json: Option<&str>, single: bool) -> Result<Bson, String> {
match indexes_json.map(str::trim).filter(|value| !value.is_empty()) {
Some(json) => parse_drop_indexes_json(json, single),
None if single => Err("dropIndex requires a string index name or JSON document".to_string()),
None => Ok(Bson::String("*".to_string())),
}
}
fn parse_drop_indexes_json(json: &str, single: bool) -> Result<Bson, String> {
let value: serde_json::Value = serde_json::from_str(json).map_err(|e| format!("Invalid index JSON: {e}"))?;
let bson = json_value_to_bson(&value);
if single {
validate_single_drop_index_value(&bson)?;
} else {
validate_multi_drop_indexes_value(&bson)?;
}
Ok(bson)
}
fn validate_single_drop_index_value(value: &Bson) -> Result<(), String> {
match value {
Bson::String(name) => {
if name.trim().is_empty() {
Err("Index name is required".to_string())
} else if name == "*" {
Err(r#"dropIndex does not accept "*"; use dropIndexes() or dropIndexes("*") instead"#.to_string())
} else if name == "_id_" {
Err("The default MongoDB _id_ index cannot be dropped".to_string())
} else {
Ok(())
}
}
Bson::Document(doc) if doc.is_empty() => Err("Index specification is required".to_string()),
Bson::Document(doc) if is_default_id_index_specification(doc) => {
Err("The default MongoDB _id_ index cannot be dropped".to_string())
}
Bson::Document(_) => Ok(()),
Bson::Array(_) => {
Err("dropIndex only accepts a string index name or JSON document; arrays are not supported".to_string())
}
_ => Err("dropIndex only accepts a string index name or JSON document".to_string()),
}
}
fn validate_multi_drop_indexes_value(value: &Bson) -> Result<(), String> {
match value {
Bson::String(name) => {
if name.trim().is_empty() {
Err("Index name is required".to_string())
} else if name == "_id_" {
Err("The default MongoDB _id_ index cannot be dropped".to_string())
} else {
Ok(())
}
}
Bson::Document(doc) if doc.is_empty() => Err("Index specification is required".to_string()),
Bson::Document(doc) if is_default_id_index_specification(doc) => {
Err("The default MongoDB _id_ index cannot be dropped".to_string())
}
Bson::Document(_) => Ok(()),
Bson::Array(items) if items.is_empty() => Err("dropIndexes only accepts non-empty string arrays".to_string()),
Bson::Array(items) => {
if items.iter().all(|item| matches!(item, Bson::String(name) if !name.trim().is_empty())) {
if items.iter().any(|item| matches!(item, Bson::String(name) if name == "_id_")) {
Err("The default MongoDB _id_ index cannot be dropped".to_string())
} else {
Ok(())
}
} else {
Err("dropIndexes only accepts arrays of string index names".to_string())
}
}
_ => Err("dropIndexes only accepts a string index name, JSON document, or string array".to_string()),
}
}
fn is_default_id_index_specification(specification: &Document) -> bool {
specification.len() == 1 && specification.get("_id").is_some_and(is_bson_numeric_one)
}
fn is_bson_numeric_one(value: &Bson) -> bool {
match value {
Bson::Int32(value) => *value == 1,
Bson::Int64(value) => *value == 1,
Bson::Double(value) => *value == 1.0,
Bson::Decimal128(value) => value.to_string().parse::<f64>().is_ok_and(|value| value == 1.0),
_ => false,
}
}
pub async fn insert_document(
client: &Client,
database: &str,
collection: &str,
doc_json: &str,
) -> Result<String, String> {
let value: serde_json::Value = serde_json::from_str(doc_json).map_err(|e| format!("Invalid JSON: {e}"))?;
let doc = json_object_to_document(&value).map_err(|e| format!("Invalid document: {e}"))?;
let col = client.database(database).collection::<Document>(collection);
let result = col.insert_one(doc).await.map_err(|e| e.to_string())?;
Ok(format!("{}", result.inserted_id))
}
/// Inserts a document from canonical Extended JSON without interpreting
/// Mongo shell-like strings such as `ISODate(...)`.
pub async fn insert_document_extended_json(
client: &Client,
database: &str,
collection: &str,
doc_json: &str,
) -> Result<String, String> {
let value: serde_json::Value = serde_json::from_str(doc_json).map_err(|e| format!("Invalid JSON: {e}"))?;
let doc = json_object_to_document_extended_json(&value).map_err(|e| format!("Invalid document: {e}"))?;
let col = client.database(database).collection::<Document>(collection);
let result = col.insert_one(doc).await.map_err(|e| e.to_string())?;
Ok(format!("{}", result.inserted_id))
}
pub async fn insert_documents(
client: &Client,
database: &str,
collection: &str,
docs_json: &str,
) -> Result<u64, String> {
let json: serde_json::Value = serde_json::from_str(docs_json).map_err(|e| format!("Invalid JSON: {e}"))?;
let docs = match json {
serde_json::Value::Array(values) => values
.into_iter()
.map(|value| json_object_to_document(&value).map_err(|e| format!("Invalid document: {e}")))
.collect::<Result<Vec<Document>, String>>()?,
value => vec![json_object_to_document(&value).map_err(|e| format!("Invalid document: {e}"))?],
};
if docs.is_empty() {
return Ok(0);
}
let col = client.database(database).collection::<Document>(collection);
let result = col.insert_many(docs).await.map_err(|e| e.to_string())?;
Ok(result.inserted_ids.len() as u64)
}
pub async fn insert_documents_extended_json(
client: &Client,
database: &str,
collection: &str,
docs_json: &str,
) -> Result<u64, String> {
let json: serde_json::Value = serde_json::from_str(docs_json).map_err(|e| format!("Invalid JSON: {e}"))?;
let docs = match json {
serde_json::Value::Array(values) => values
.into_iter()
.map(|value| json_object_to_document_extended_json(&value).map_err(|e| format!("Invalid document: {e}")))
.collect::<Result<Vec<Document>, String>>()?,
value => vec![json_object_to_document_extended_json(&value).map_err(|e| format!("Invalid document: {e}"))?],
};
if docs.is_empty() {
return Ok(0);
}
let col = client.database(database).collection::<Document>(collection);
let result = col.insert_many(docs).await.map_err(|e| e.to_string())?;
Ok(result.inserted_ids.len() as u64)
}
pub async fn update_document(
client: &Client,
database: &str,
collection: &str,
id: &str,
doc_json: &str,
) -> Result<u64, String> {
let value: serde_json::Value = serde_json::from_str(doc_json).map_err(|e| format!("Invalid JSON: {e}"))?;
let col = client.database(database).collection::<Document>(collection);
let update_doc = json_object_to_document_for_update(&value, None).map_err(|e| format!("Invalid document: {e}"))?;
if is_update_operator_document(&update_doc) {
for filter in document_id_filters(id) {
let result = col.update_one(filter, update_doc.clone()).await.map_err(|e| e.to_string())?;
if result.matched_count > 0 {
return Ok(result.modified_count);
}
}
return Err(no_matching_document_error(id));
}
for filter in document_id_filters(id) {
let current = col.find_one(filter.clone()).await.map_err(|e| e.to_string())?;
let mut new_doc = json_object_to_document_for_update(&value, current.as_ref())
.map_err(|e| format!("Invalid document: {e}"))?;
new_doc.remove("_id");
let result = col.replace_one(filter, new_doc.clone()).await.map_err(|e| e.to_string())?;
if result.matched_count > 0 {
return Ok(result.modified_count);
}
}
Err(no_matching_document_error(id))
}
fn no_matching_document_error(id: &str) -> String {
let display = decode_string_document_id(id).unwrap_or_else(|| id.to_string());
format!("No document matched _id {display}. It may have been deleted or its _id changed since the query ran.")
}
fn is_update_operator_document(doc: &Document) -> bool {
!doc.is_empty() && doc.keys().all(|key| key.starts_with('$'))
}
pub async fn update_documents(
client: &Client,
database: &str,
collection: &str,
filter_json: &str,
update_json: &str,
many: bool,
options_json: Option<&str>,
) -> Result<u64, String> {
let filter_value: serde_json::Value =
serde_json::from_str(filter_json).map_err(|e| format!("Invalid filter JSON: {e}"))?;
let update_value: serde_json::Value =
serde_json::from_str(update_json).map_err(|e| format!("Invalid update JSON: {e}"))?;
let filter = json_filter_to_document(&filter_value).map_err(|e| format!("Invalid filter: {e}"))?;
let update = json_update_to_modifications(&update_value).map_err(|e| format!("Invalid update: {e}"))?;
let ParsedMongoUpdateOptions { upsert, array_filters } = parse_update_options(options_json)?;
let col = client.database(database).collection::<Document>(collection);
let result = if many {
let mut action = col.update_many(filter, update);
if let Some(upsert) = upsert {
action = action.upsert(upsert);
}
if let Some(filters) = array_filters {
action = action.array_filters(filters);
}
action.await.map_err(|e| e.to_string())?
} else {
let mut action = col.update_one(filter, update);
if let Some(upsert) = upsert {
action = action.upsert(upsert);
}
if let Some(filters) = array_filters {
action = action.array_filters(filters);
}
action.await.map_err(|e| e.to_string())?
};
Ok(result.modified_count)
}
/// Counts reported by `bulkWrite`, mirroring the shell's `BulkWriteResult`.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MongoBulkWriteResult {
pub inserted_count: u64,
pub matched_count: u64,
pub modified_count: u64,
pub deleted_count: u64,
pub upserted_count: u64,
}
#[derive(Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct MongoBulkWriteOptions {
ordered: Option<bool>,
}
/// bulkWrite(operations[, { ordered }]).
///
/// The Rust driver's `bulk_write` is client-level and needs MongoDB 8.0, so the
/// operations run here one at a time in order, which is what the shell's
/// collection-level bulkWrite guarantees anyway (it is not transactional). With
/// `ordered: true` (the default) the first failure stops the batch; with
/// `ordered: false` every operation is attempted and the failures are reported together.
pub async fn bulk_write(
client: &Client,
database: &str,
collection: &str,
operations_json: &str,
options_json: Option<&str>,
) -> Result<MongoBulkWriteResult, String> {
use crate::mongo_shell::BulkWriteOperation;
let operations = crate::mongo_shell::parse_bulk_write_operations(operations_json)?;
let ordered = match options_json.filter(|value| !value.trim().is_empty()) {
Some(raw) => {
let options: MongoBulkWriteOptions =
serde_json::from_str(raw).map_err(|e| format!("Invalid bulkWrite options: {e}"))?;
options.ordered.unwrap_or(true)
}
None => true,
};
let col = client.database(database).collection::<Document>(collection);
let mut result = MongoBulkWriteResult::default();
let mut failures = Vec::new();
for (index, operation) in operations.iter().enumerate() {
let outcome: Result<(), String> = async {
match operation {
BulkWriteOperation::InsertOne { document } => {
let document = json_object_to_document(document).map_err(|e| format!("Invalid document: {e}"))?;
col.insert_one(document).await.map_err(|e| e.to_string())?;
result.inserted_count += 1;
}
BulkWriteOperation::UpdateOne { filter, update, upsert, array_filters }
| BulkWriteOperation::UpdateMany { filter, update, upsert, array_filters } => {
let many = matches!(operation, BulkWriteOperation::UpdateMany { .. });
let filter = json_filter_to_document(filter).map_err(|e| format!("Invalid filter: {e}"))?;
let update = json_update_to_modifications(update).map_err(|e| format!("Invalid update: {e}"))?;
let array_filters = array_filters
.as_ref()
.and_then(serde_json::Value::as_array)
.map(|filters| filters.iter().map(json_filter_to_document).collect::<Result<Vec<_>, _>>())
.transpose()
.map_err(|e| format!("Invalid arrayFilters: {e}"))?;
let update_result = if many {
let mut action = col.update_many(filter, update);
if let Some(upsert) = upsert {
action = action.upsert(*upsert);
}
if let Some(filters) = array_filters {
action = action.array_filters(filters);
}
action.await.map_err(|e| e.to_string())?
} else {
let mut action = col.update_one(filter, update);
if let Some(upsert) = upsert {
action = action.upsert(*upsert);
}
if let Some(filters) = array_filters {
action = action.array_filters(filters);
}
action.await.map_err(|e| e.to_string())?
};
result.matched_count += update_result.matched_count;
result.modified_count += update_result.modified_count;
result.upserted_count += u64::from(update_result.upserted_id.is_some());
}
BulkWriteOperation::ReplaceOne { filter, replacement, upsert } => {
let filter = json_filter_to_document(filter).map_err(|e| format!("Invalid filter: {e}"))?;
let replacement =
json_object_to_document(replacement).map_err(|e| format!("Invalid replacement: {e}"))?;
let mut action = col.replace_one(filter, replacement);
if let Some(upsert) = upsert {
action = action.upsert(*upsert);
}
let replace_result = action.await.map_err(|e| e.to_string())?;
result.matched_count += replace_result.matched_count;
result.modified_count += replace_result.modified_count;
result.upserted_count += u64::from(replace_result.upserted_id.is_some());
}
BulkWriteOperation::DeleteOne { filter } | BulkWriteOperation::DeleteMany { filter } => {
let many = matches!(operation, BulkWriteOperation::DeleteMany { .. });
let filter = json_filter_to_document(filter).map_err(|e| format!("Invalid filter: {e}"))?;
let delete_result = if many {
col.delete_many(filter).await.map_err(|e| e.to_string())?
} else {
col.delete_one(filter).await.map_err(|e| e.to_string())?
};
result.deleted_count += delete_result.deleted_count;
}
}
Ok(())
}
.await;
if let Err(error) = outcome {
let message = format!("operation {} ({}): {error}", index + 1, operation.kind());
if ordered {
return Err(format!(
"bulkWrite stopped at {message}. Completed before it: {}",
describe_bulk_counts(&result)
));
}
failures.push(message);
}
}
if failures.is_empty() {
Ok(result)
} else {
Err(format!(
"bulkWrite finished with {} failed operation(s): {}. Completed: {}",
failures.len(),
failures.join("; "),
describe_bulk_counts(&result)
))
}
}
fn describe_bulk_counts(result: &MongoBulkWriteResult) -> String {
format!(
"inserted {}, matched {}, modified {}, deleted {}, upserted {}",
result.inserted_count, result.matched_count, result.modified_count, result.deleted_count, result.upserted_count
)
}
/// replaceOne(filter, replacement[, { upsert }]): the whole document is swapped, so
/// unlike update there are no array filters to apply.
pub async fn replace_document(
client: &Client,
database: &str,
collection: &str,
filter_json: &str,
replacement_json: &str,
options_json: Option<&str>,
) -> Result<u64, String> {
let filter_value: serde_json::Value =
serde_json::from_str(filter_json).map_err(|e| format!("Invalid filter JSON: {e}"))?;
let replacement_value: serde_json::Value =
serde_json::from_str(replacement_json).map_err(|e| format!("Invalid replacement JSON: {e}"))?;
let filter = json_filter_to_document(&filter_value).map_err(|e| format!("Invalid filter: {e}"))?;
let replacement = json_object_to_document(&replacement_value).map_err(|e| format!("Invalid replacement: {e}"))?;
if let Some(operator) = replacement.keys().find(|key| key.starts_with('$')) {
return Err(format!("Replacement document must not contain update operators such as {operator}"));
}
let upsert = parse_replace_options(options_json)?;
let col = client.database(database).collection::<Document>(collection);
let mut action = col.replace_one(filter, replacement);
if let Some(upsert) = upsert {
action = action.upsert(upsert);
}
let result = action.await.map_err(|e| e.to_string())?;
Ok(result.modified_count)
}
#[derive(Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct MongoReplaceOptions {
upsert: Option<bool>,
}
fn parse_replace_options(options_json: Option<&str>) -> Result<Option<bool>, String> {
let Some(raw) = options_json.filter(|value| !value.trim().is_empty()) else {
return Ok(None);
};
let options: MongoReplaceOptions =
serde_json::from_str(raw).map_err(|e| format!("Invalid replace options: {e}"))?;
Ok(options.upsert)
}
/// Unknown options are rejected rather than dropped, so a `collation` or `hint` the
/// driver does not apply fails loudly instead of silently changing nothing. This
/// matches `MongoReplaceOptions` and the legacy agent, which already reject them.
#[derive(Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct MongoUpdateOptions {
upsert: Option<bool>,
array_filters: Option<Vec<serde_json::Value>>,
}
#[derive(Debug, Default)]
struct ParsedMongoUpdateOptions {
upsert: Option<bool>,
array_filters: Option<Vec<Document>>,
}
fn parse_update_options(options_json: Option<&str>) -> Result<ParsedMongoUpdateOptions, String> {
let Some(raw) = options_json.filter(|value| !value.trim().is_empty()) else {
return Ok(ParsedMongoUpdateOptions::default());
};
let options: MongoUpdateOptions = serde_json::from_str(raw).map_err(|e| format!("Invalid update options: {e}"))?;
let array_filters = options
.array_filters
.map(|filters| {
filters
.iter()
.map(json_filter_to_document)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Invalid arrayFilters: {e}"))
})
.transpose()?;
Ok(ParsedMongoUpdateOptions { upsert: options.upsert, array_filters })
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct MongoFindOneAndUpdateOptions {
return_document: Option<String>,
return_new_document: Option<bool>,
new: Option<bool>,
upsert: Option<bool>,
projection: Option<serde_json::Value>,
sort: Option<serde_json::Value>,
array_filters: Option<Vec<serde_json::Value>>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct MongoFindOneAndReplaceOptions {
return_document: Option<String>,
return_new_document: Option<bool>,
new: Option<bool>,
upsert: Option<bool>,
projection: Option<serde_json::Value>,
sort: Option<serde_json::Value>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct MongoFindOneAndDeleteOptions {
projection: Option<serde_json::Value>,
sort: Option<serde_json::Value>,
}
fn parse_find_and_modify_options<T>(options_json: Option<&str>, command: &str) -> Result<T, String>
where
T: Default + for<'de> Deserialize<'de>,
{
match options_json.map(str::trim).filter(|value| !value.is_empty()) {
Some(raw) => serde_json::from_str(raw).map_err(|e| format!("Invalid {command} options: {e}")),
None => Ok(T::default()),
}
}
fn find_and_modify_returns_after(
return_document: Option<&str>,
return_new_document: Option<bool>,
new: Option<bool>,
) -> Result<bool, String> {
if let Some(return_document) = return_document {
if return_document.eq_ignore_ascii_case("after") {
return Ok(true);
}
if return_document.eq_ignore_ascii_case("before") {
return Ok(false);
}
return Err("returnDocument must be either 'before' or 'after'".to_string());
}
Ok(return_new_document.or(new).unwrap_or(false))
}
fn parse_optional_document(field: Option<&serde_json::Value>, label: &str) -> Result<Option<Document>, String> {
field.map(|value| json_object_to_document(value).map_err(|e| format!("Invalid {label}: {e}"))).transpose()
}
fn find_and_modify_array_filters(
array_filters: Option<&Vec<serde_json::Value>>,
) -> Result<Option<Vec<Document>>, String> {
array_filters
.map(|filters| {
filters
.iter()
.map(json_filter_to_document)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Invalid arrayFilters: {e}"))
})
.transpose()
}
fn single_document_result(document: Option<Document>) -> MongoDocumentResult {
match document {
Some(document) => MongoDocumentResult {
documents: vec![bson_to_json(&Bson::Document(document.clone()))],
raw_documents: None,
extended_documents: Some(vec![Bson::Document(document).into_canonical_extjson()]),
total: 1,
total_is_exact: true,
next_cursor: None,
},
None => MongoDocumentResult {
documents: Vec::new(),
raw_documents: None,
extended_documents: Some(Vec::new()),
total: 0,
total_is_exact: true,
next_cursor: None,
},
}
}
/// The `findAndModify` server command behind `findOneAndUpdate()`, for drivers that only expose
/// `runCommand` (the legacy agent). Same inputs and option validation as [`find_one_and_update`].
pub fn find_one_and_update_command(
collection: &str,
filter_json: &str,
update_json: &str,
options_json: Option<&str>,
) -> Result<Document, String> {
let filter_value: serde_json::Value =
serde_json::from_str(filter_json).map_err(|e| format!("Invalid filter JSON: {e}"))?;
let update_value: serde_json::Value =
serde_json::from_str(update_json).map_err(|e| format!("Invalid update JSON: {e}"))?;
let filter = json_filter_to_document(&filter_value).map_err(|e| format!("Invalid filter: {e}"))?;
let update = match json_update_to_modifications(&update_value).map_err(|e| format!("Invalid update: {e}"))? {
UpdateModifications::Document(document) => Bson::Document(document),
UpdateModifications::Pipeline(stages) => Bson::Array(stages.into_iter().map(Bson::Document).collect()),
_ => return Err("Unsupported update modification".to_string()),
};
let options: MongoFindOneAndUpdateOptions = parse_find_and_modify_options(options_json, "findOneAndUpdate")?;
let mut command = doc! { "findAndModify": collection, "query": filter, "update": update };
if find_and_modify_returns_after(options.return_document.as_deref(), options.return_new_document, options.new)? {
command.insert("new", true);
}
if let Some(upsert) = options.upsert {
command.insert("upsert", upsert);
}
if let Some(projection) = parse_optional_document(options.projection.as_ref(), "projection")? {
command.insert("fields", projection);
}
if let Some(sort) = parse_optional_document(options.sort.as_ref(), "sort")? {
command.insert("sort", sort);
}
if let Some(array_filters) = find_and_modify_array_filters(options.array_filters.as_ref())? {
command.insert("arrayFilters", array_filters);
}
Ok(command)
}
/// A replacement document whose keys start with `$` would be silently executed as
/// an update by the server, so both the native and legacy-agent `findOneAndReplace`
/// paths refuse it up front with the same message.
fn reject_update_operator_replacement(replacement: &Document) -> Result<(), String> {
if let Some(operator) = replacement.keys().find(|key| key.starts_with('$')) {
return Err(format!("Replacement document must not contain update operators such as {operator}"));
}
Ok(())
}
/// The `findAndModify` server command behind `findOneAndReplace()`. A replacement with update
/// operators is refused here because the server would silently treat it as an update instead.
pub fn find_one_and_replace_command(
collection: &str,
filter_json: &str,
replacement_json: &str,
options_json: Option<&str>,
) -> Result<Document, String> {
let filter_value: serde_json::Value =
serde_json::from_str(filter_json).map_err(|e| format!("Invalid filter JSON: {e}"))?;
let replacement_value: serde_json::Value =
serde_json::from_str(replacement_json).map_err(|e| format!("Invalid replacement JSON: {e}"))?;
let filter = json_filter_to_document(&filter_value).map_err(|e| format!("Invalid filter: {e}"))?;
let replacement = json_object_to_document(&replacement_value).map_err(|e| format!("Invalid replacement: {e}"))?;
reject_update_operator_replacement(&replacement)?;
let options: MongoFindOneAndReplaceOptions = parse_find_and_modify_options(options_json, "findOneAndReplace")?;
let mut command = doc! { "findAndModify": collection, "query": filter, "update": replacement };
if find_and_modify_returns_after(options.return_document.as_deref(), options.return_new_document, options.new)? {
command.insert("new", true);
}
if let Some(upsert) = options.upsert {
command.insert("upsert", upsert);
}
if let Some(projection) = parse_optional_document(options.projection.as_ref(), "projection")? {
command.insert("fields", projection);
}
if let Some(sort) = parse_optional_document(options.sort.as_ref(), "sort")? {
command.insert("sort", sort);
}
Ok(command)
}
/// The `findAndModify` server command behind `findOneAndDelete()`.
pub fn find_one_and_delete_command(
collection: &str,
filter_json: &str,
options_json: Option<&str>,
) -> Result<Document, String> {
let filter_value: serde_json::Value =
serde_json::from_str(filter_json).map_err(|e| format!("Invalid filter JSON: {e}"))?;
let filter = json_filter_to_document(&filter_value).map_err(|e| format!("Invalid filter: {e}"))?;
let options: MongoFindOneAndDeleteOptions = parse_find_and_modify_options(options_json, "findOneAndDelete")?;
let mut command = doc! { "findAndModify": collection, "query": filter, "remove": true };
if let Some(projection) = parse_optional_document(options.projection.as_ref(), "projection")? {
command.insert("fields", projection);
}
if let Some(sort) = parse_optional_document(options.sort.as_ref(), "sort")? {
command.insert("sort", sort);
}
Ok(command)
}
/// Turns a `findAndModify` response into the same shape the native helpers return: the matched
/// document (before or after the change, as requested) or nothing.
pub fn find_and_modify_response_result(response: &Document) -> Result<MongoDocumentResult, String> {
match response.get("value") {
Some(Bson::Document(document)) => Ok(single_document_result(Some(document.clone()))),
Some(Bson::Null) | None => Ok(single_document_result(None)),
Some(other) => Err(format!("Unexpected findAndModify value: {other:?}")),
}
}
pub async fn find_one_and_update(
client: &Client,
database: &str,
collection: &str,
filter_json: &str,
update_json: &str,
options_json: Option<&str>,
) -> Result<MongoDocumentResult, String> {
let filter_value: serde_json::Value =
serde_json::from_str(filter_json).map_err(|e| format!("Invalid filter JSON: {e}"))?;
let update_value: serde_json::Value =
serde_json::from_str(update_json).map_err(|e| format!("Invalid update JSON: {e}"))?;
let filter = json_filter_to_document(&filter_value).map_err(|e| format!("Invalid filter: {e}"))?;
let update = json_update_to_modifications(&update_value).map_err(|e| format!("Invalid update: {e}"))?;
let options: MongoFindOneAndUpdateOptions = parse_find_and_modify_options(options_json, "findOneAndUpdate")?;
let col = client.database(database).collection::<Document>(collection);
let mut action = col.find_one_and_update(filter, update);
if find_and_modify_returns_after(options.return_document.as_deref(), options.return_new_document, options.new)? {
action = action.return_document(mongodb::options::ReturnDocument::After);
}
if let Some(upsert) = options.upsert {
action = action.upsert(upsert);
}
if let Some(projection) = parse_optional_document(options.projection.as_ref(), "projection")? {
action = action.projection(projection);
}
if let Some(sort) = parse_optional_document(options.sort.as_ref(), "sort")? {
action = action.sort(sort);
}
if let Some(array_filters) = find_and_modify_array_filters(options.array_filters.as_ref())? {
action = action.array_filters(array_filters);
}
let result = action.await.map_err(|e| e.to_string())?;
Ok(single_document_result(result))
}
pub async fn find_one_and_replace(
client: &Client,
database: &str,
collection: &str,
filter_json: &str,
replacement_json: &str,
options_json: Option<&str>,
) -> Result<MongoDocumentResult, String> {
let filter_value: serde_json::Value =
serde_json::from_str(filter_json).map_err(|e| format!("Invalid filter JSON: {e}"))?;
let replacement_value: serde_json::Value =
serde_json::from_str(replacement_json).map_err(|e| format!("Invalid replacement JSON: {e}"))?;
let filter = json_filter_to_document(&filter_value).map_err(|e| format!("Invalid filter: {e}"))?;
let replacement = json_object_to_document(&replacement_value).map_err(|e| format!("Invalid replacement: {e}"))?;
reject_update_operator_replacement(&replacement)?;
let options: MongoFindOneAndReplaceOptions = parse_find_and_modify_options(options_json, "findOneAndReplace")?;
let col = client.database(database).collection::<Document>(collection);
let mut action = col.find_one_and_replace(filter, replacement);
if find_and_modify_returns_after(options.return_document.as_deref(), options.return_new_document, options.new)? {
action = action.return_document(mongodb::options::ReturnDocument::After);
}
if let Some(upsert) = options.upsert {
action = action.upsert(upsert);
}
if let Some(projection) = parse_optional_document(options.projection.as_ref(), "projection")? {
action = action.projection(projection);
}
if let Some(sort) = parse_optional_document(options.sort.as_ref(), "sort")? {
action = action.sort(sort);
}
let result = action.await.map_err(|e| e.to_string())?;
Ok(single_document_result(result))
}
pub async fn find_one_and_delete(
client: &Client,
database: &str,
collection: &str,
filter_json: &str,
options_json: Option<&str>,
) -> Result<MongoDocumentResult, String> {
let filter_value: serde_json::Value =
serde_json::from_str(filter_json).map_err(|e| format!("Invalid filter JSON: {e}"))?;
let filter = json_filter_to_document(&filter_value).map_err(|e| format!("Invalid filter: {e}"))?;
let options: MongoFindOneAndDeleteOptions = parse_find_and_modify_options(options_json, "findOneAndDelete")?;
let col = client.database(database).collection::<Document>(collection);
let mut action = col.find_one_and_delete(filter);
if let Some(projection) = parse_optional_document(options.projection.as_ref(), "projection")? {
action = action.projection(projection);
}
if let Some(sort) = parse_optional_document(options.sort.as_ref(), "sort")? {
action = action.sort(sort);
}
let result = action.await.map_err(|e| e.to_string())?;
Ok(single_document_result(result))
}
pub async fn delete_document(client: &Client, database: &str, collection: &str, id: &str) -> Result<u64, String> {
let col = client.database(database).collection::<Document>(collection);
for filter in document_id_filters(id) {
let result = col.delete_one(filter).await.map_err(|e| e.to_string())?;
if result.deleted_count > 0 {
return Ok(result.deleted_count);
}
}
Ok(0)
}
fn document_id_filters(id: &str) -> Vec<Document> {
if let Some(string_id) = decode_string_document_id(id) {
// The marker is emitted for an explicitly typed BSON string; do not reinterpret it as ObjectId.
return vec![doc! { "_id": Bson::String(string_id) }];
}
if let Some(filter) = extended_json_document_id_filter(id) {
return vec![filter];
}
if let Some(numeric) = numeric_document_id(id) {
return vec![doc! { "_id": numeric }, doc! { "_id": Bson::String(id.to_string()) }];
}
object_id_then_string_filters(id)
}
fn object_id_then_string_filters(id: &str) -> Vec<Document> {
let string_filter = doc! { "_id": Bson::String(id.to_string()) };
match ObjectId::parse_str(id) {
Ok(oid) => vec![doc! { "_id": Bson::ObjectId(oid) }, string_filter],
Err(_) => vec![string_filter],
}
}
fn numeric_document_id(id: &str) -> Option<Bson> {
if id.trim() != id || id.is_empty() {
return None;
}
if let Ok(value) = id.parse::<i64>() {
return Some(Bson::Int64(value));
}
match id.parse::<f64>() {
Ok(value) if value.is_finite() => Some(Bson::Double(value)),
_ => None,
}
}
fn decode_string_document_id(id: &str) -> Option<String> {
id.strip_prefix("__dbx_mongo_string_id__").and_then(|payload| serde_json::from_str::<String>(payload).ok())
}
fn extended_json_document_id_filter(id: &str) -> Option<Document> {
let trimmed = id.trim();
if !trimmed.starts_with('{') {
return None;
}
let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
let bson = json_value_to_bson(&value);
if matches!(bson, Bson::Document(_)) {
return None;
}
Some(doc! { "_id": bson })
}
pub async fn delete_documents(
client: &Client,
database: &str,
collection: &str,
filter_json: &str,
many: bool,
) -> Result<u64, String> {
let filter_value: serde_json::Value =
serde_json::from_str(filter_json).map_err(|e| format!("Invalid filter JSON: {e}"))?;
let filter = json_filter_to_document(&filter_value).map_err(|e| format!("Invalid filter: {e}"))?;
let col = client.database(database).collection::<Document>(collection);
let result = if many {
col.delete_many(filter).await.map_err(|e| e.to_string())?
} else {
col.delete_one(filter).await.map_err(|e| e.to_string())?
};
Ok(result.deleted_count)
}
fn bson_to_json(bson: &Bson) -> serde_json::Value {
match bson {
Bson::Double(v) => serde_json::json!(v),
Bson::String(v) => serde_json::Value::String(v.clone()),
Bson::Boolean(v) => serde_json::Value::Bool(*v),
Bson::Null => serde_json::Value::Null,
Bson::Int32(v) => serde_json::json!(v),
Bson::Int64(v) => super::safe_i64_to_json(*v),
Bson::ObjectId(oid) => serde_json::Value::String(oid.to_hex()),
Bson::DateTime(dt) => serde_json::Value::String(format!(
"ISODate(\"{}\")",
dt.try_to_rfc3339_string().unwrap_or_else(|_| dt.to_string())
)),
Bson::Array(arr) => serde_json::Value::Array(arr.iter().map(bson_to_json).collect()),
Bson::Document(doc) => {
let mut map = serde_json::Map::new();
for (k, v) in doc {
map.insert(k.clone(), bson_document_field_to_json(k, v));
}
serde_json::Value::Object(map)
}
_ => serde_json::Value::String(format!("{bson}")),
}
}
fn bson_document_field_to_json(key: &str, bson: &Bson) -> serde_json::Value {
if key == "_id" {
match bson {
Bson::Int64(value) => return serde_json::json!({ "$numberLong": value.to_string() }),
Bson::ObjectId(value) => return serde_json::json!({ "$oid": value.to_hex() }),
_ => {}
}
}
bson_to_json(bson)
}
fn bson_to_browser_json(bson: &Bson) -> serde_json::Value {
match bson {
Bson::Int64(value) if !(-9_007_199_254_740_991..=9_007_199_254_740_991).contains(value) => {
serde_json::json!({ "$numberLong": value.to_string() })
}
Bson::ObjectId(oid) => serde_json::json!({ "$oid": oid.to_hex() }),
Bson::Array(values) => serde_json::Value::Array(values.iter().map(bson_to_browser_json).collect()),
Bson::Document(document) => serde_json::Value::Object(
document.iter().map(|(key, value)| (key.clone(), bson_to_browser_json(value))).collect(),
),
_ => bson_to_json(bson),
}
}
fn document_json_views(document: Document) -> (serde_json::Value, serde_json::Value) {
let bson = Bson::Document(document);
let browser = bson_to_browser_json(&bson);
// Derive copy JSON from the original BSON so every BSON type keeps its canonical wrapper.
let extended = bson.into_canonical_extjson();
(browser, extended)
}
/// Convert a `serde_json::Value` (JSON object) to a BSON `Document`,
/// handling MongoDB extended JSON conventions such as `{"$oid":"..."}`.
pub fn json_object_to_document(value: &serde_json::Value) -> Result<Document, String> {
match json_value_to_bson(value) {
Bson::Document(doc) => Ok(doc),
other => Err(format!("Expected a JSON object, got {other:?}")),
}
}
fn json_update_to_modifications(value: &serde_json::Value) -> Result<UpdateModifications, String> {
match json_value_to_bson(value) {
Bson::Document(document) => Ok(UpdateModifications::Document(document)),
Bson::Array(stages) => stages
.into_iter()
.enumerate()
.map(|(index, stage)| match stage {
Bson::Document(document) => Ok(document),
other => Err(format!("Update pipeline stage {} must be a JSON object, got {other:?}", index + 1)),
})
.collect::<Result<Vec<_>, _>>()
.map(UpdateModifications::Pipeline),
other => Err(format!("Expected a JSON object or pipeline array, got {other:?}")),
}
}
pub fn json_object_to_document_extended_json(value: &serde_json::Value) -> Result<Document, String> {
match Bson::try_from(value.clone()).map_err(|e| e.to_string())? {
Bson::Document(doc) => Ok(doc),
other => Err(format!("Expected a JSON object, got {other:?}")),
}
}
pub fn document_to_canonical_extended_json(document: &Document) -> serde_json::Value {
Bson::Document(document.clone()).into_canonical_extjson()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MongoBulkWriteError {
pub message: String,
pub index: Option<usize>,
pub code: Option<i32>,
pub retryable: bool,
}
/// What actually happened to a submitted batch. A batch can partly succeed, so the count of
/// inserted documents and the per-document rejections are reported together.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MongoInsertOutcome {
pub inserted: u64,
/// One entry per document the server rejected, `index` pointing into the submitted batch.
pub errors: Vec<MongoBulkWriteError>,
}
pub async fn insert_bson_documents<T: serde::Serialize + Send + Sync>(
client: &Client,
database: &str,
collection: &str,
documents: Vec<T>,
) -> Result<MongoInsertOutcome, MongoBulkWriteError> {
if documents.is_empty() {
return Ok(MongoInsertOutcome::default());
}
let total = documents.len() as u64;
let col = client.database(database).collection::<T>(collection);
// Unordered: one rejected document must not abandon the rest of the batch, and the server
// then reports every rejection instead of stopping at the first.
match col.insert_many(documents).ordered(false).await {
Ok(result) => Ok(MongoInsertOutcome { inserted: result.inserted_ids.len() as u64, errors: Vec::new() }),
Err(error) => {
let errors = insert_write_errors(&error);
if errors.is_empty() {
// No per-document detail means the whole batch failed (network, auth, …).
return Err(map_insert_many_error(error));
}
Ok(MongoInsertOutcome { inserted: total.saturating_sub(errors.len() as u64), errors })
}
}
}
/// Per-document rejections, sorted by batch index. Empty when the failure was not per-document.
fn insert_write_errors(error: &mongodb::error::Error) -> Vec<MongoBulkWriteError> {
use mongodb::error::{ErrorKind, WriteFailure};
let mut errors = match error.kind.as_ref() {
ErrorKind::Write(WriteFailure::WriteError(write_error)) => {
vec![write_error_entry(None, write_error.code, &write_error.message)]
}
ErrorKind::InsertMany(failure) => failure
.write_errors
.iter()
.flatten()
.map(|error| write_error_entry(Some(error.index), error.code, &error.message))
.collect(),
ErrorKind::BulkWrite(failure) => failure
.write_errors
.iter()
.map(|(index, error)| write_error_entry(Some(*index), error.code, &error.message))
.collect(),
_ => Vec::new(),
};
errors.sort_by_key(|error| error.index.unwrap_or(0));
errors
}
fn write_error_entry(index: Option<usize>, code: i32, message: &str) -> MongoBulkWriteError {
MongoBulkWriteError {
message: message.to_string(),
index,
code: Some(code),
retryable: is_retryable_mongo_write_code(code),
}
}
fn map_insert_many_error(error: mongodb::error::Error) -> MongoBulkWriteError {
use mongodb::error::ErrorKind;
match error.kind.as_ref() {
ErrorKind::Io(_) | ErrorKind::ConnectionPoolCleared { .. } | ErrorKind::ServerSelection { .. } => {
MongoBulkWriteError { message: error.to_string(), index: None, code: None, retryable: true }
}
_ => MongoBulkWriteError { message: error.to_string(), index: None, code: None, retryable: false },
}
}
fn is_retryable_mongo_write_code(code: i32) -> bool {
!matches!(code, 11000 | 11001 | 12582)
}
/// Reads the `insert_documents` result of a MongoDB legacy agent. The agent reports a partly
/// applied batch as a success carrying one entry per rejected document, so callers must inspect
/// [`MongoInsertOutcome::errors`] instead of trusting a bare affected-row count.
pub fn agent_insert_outcome(result: &serde_json::Value) -> Result<MongoInsertOutcome, String> {
let inserted = result
.get("affected_rows")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| "MongoDB Legacy Agent returned an invalid insertMany result".to_string())?;
let errors = result
.get("errors")
.and_then(serde_json::Value::as_array)
.map(|errors| errors.iter().map(agent_insert_error).collect())
.unwrap_or_default();
Ok(MongoInsertOutcome { inserted, errors })
}
/// One rejected document, or a batch-wide rejection the agent could not attribute to a document
/// (a write concern failure) — those carry no index and keep [`MongoBulkWriteError::index`] `None`.
fn agent_insert_error(value: &serde_json::Value) -> MongoBulkWriteError {
// A rejection is never dropped for lack of a message: dropping it would make a document the
// server refused look like a successful insert.
let message = value
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("MongoDB Legacy Agent rejected a document without a message")
.to_string();
let code = value.get("code").and_then(serde_json::Value::as_i64).map(|code| code as i32);
MongoBulkWriteError {
message,
index: value.get("index").and_then(serde_json::Value::as_u64).map(|index| index as usize),
code,
retryable: code.is_some_and(is_retryable_mongo_write_code),
}
}
#[allow(clippy::too_many_arguments)]
pub async fn for_each_find_document(
client: &Client,
database: &str,
collection: &str,
filter: Option<&str>,
projection: Option<&str>,
sort: Option<&str>,
collation: Option<&str>,
batch_size: u32,
mut on_document: impl FnMut(Document) -> Result<(), String>,
) -> Result<(), String> {
let col = client.database(database).collection::<Document>(collection);
let filter_doc = parse_optional_filter_document(filter)?.unwrap_or_default();
let mut find = col.find(filter_doc).batch_size(batch_size);
if let Some(projection) = parse_optional_json_document(projection, "projection")? {
find = find.projection(projection);
}
if let Some(sort) = parse_optional_json_document(sort, "sort")? {
find = find.sort(sort);
}
if let Some(collation) = parse_find_collation(collation)? {
find = find.collation(collation);
}
let mut cursor = find.await.map_err(|error| error.to_string())?;
while cursor.advance().await.map_err(|error| error.to_string())? {
let document = cursor.deserialize_current().map_err(|error| error.to_string())?;
on_document(document)?;
}
Ok(())
}
fn json_object_to_document_preserving_existing(
value: &serde_json::Value,
existing: Option<&Document>,
) -> Result<Document, String> {
match value {
serde_json::Value::Object(obj) => obj
.iter()
.map(|(key, value)| {
json_value_to_bson_preserving_existing(value, existing.and_then(|document| document.get(key)))
.map(|bson| (key.clone(), bson))
})
.collect(),
_ => json_object_to_document(value),
}
}
/// Parse document-editor input value by value so mixed browser representations
/// retain their BSON types.
fn json_object_to_document_for_update(
value: &serde_json::Value,
existing: Option<&Document>,
) -> Result<Document, String> {
json_object_to_document_preserving_existing(value, existing)
}
pub fn json_filter_to_document(value: &serde_json::Value) -> Result<Document, String> {
match json_filter_value_to_bson(value, None) {
Bson::Document(doc) => Ok(doc),
other => Err(format!("Expected a JSON object, got {other:?}")),
}
}
fn json_value_to_bson_preserving_existing(value: &serde_json::Value, existing: Option<&Bson>) -> Result<Bson, String> {
if let Some(existing) = existing {
if &bson_to_json(existing) == value {
return Ok(existing.clone());
}
}
match value {
serde_json::Value::Array(values) => {
let existing_values = match existing {
Some(Bson::Array(values)) => Some(values),
_ => None,
};
values
.iter()
.enumerate()
.map(|(index, item)| {
json_value_to_bson_preserving_existing(item, existing_values.and_then(|values| values.get(index)))
})
.collect::<Result<Vec<_>, _>>()
.map(Bson::Array)
}
serde_json::Value::Object(obj) => {
if let Some(value) = parse_extended_json_value(obj)? {
return Ok(value);
}
let existing_document = match existing {
Some(Bson::Document(document)) => Some(document),
_ => None,
};
obj.iter()
.map(|(key, item)| {
json_value_to_bson_preserving_existing(
item,
existing_document.and_then(|document| document.get(key)),
)
.map(|bson| (key.clone(), bson))
})
.collect::<Result<Document, _>>()
.map(Bson::Document)
}
_ => Ok(json_value_to_bson(value)),
}
}
fn json_value_to_bson(value: &serde_json::Value) -> Bson {
match value {
serde_json::Value::Null => Bson::Null,
serde_json::Value::Bool(b) => Bson::Boolean(*b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Bson::Int64(i)
} else if let Some(f) = n.as_f64() {
Bson::Double(f)
} else {
Bson::Null
}
}
serde_json::Value::String(s) => {
parse_mongo_shell_date(s).map(Bson::DateTime).unwrap_or_else(|| Bson::String(s.clone()))
}
serde_json::Value::Array(arr) => Bson::Array(arr.iter().map(json_value_to_bson).collect()),
serde_json::Value::Object(obj) => {
if let Ok(Some(value)) = parse_extended_json_value(obj) {
return value;
}
let doc: Document = obj.iter().map(|(k, v)| (k.clone(), json_value_to_bson(v))).collect();
Bson::Document(doc)
}
}
}
fn parse_extended_json_value(obj: &serde_json::Map<String, serde_json::Value>) -> Result<Option<Bson>, String> {
let is_wrapper = match obj.len() {
1 => obj.keys().next().is_some_and(|key| {
matches!(
key.as_str(),
"$oid"
| "$date"
| "$numberInt"
| "$numberLong"
| "$numberDouble"
| "$numberDecimal"
| "$binary"
| "$regularExpression"
| "$timestamp"
| "$minKey"
| "$maxKey"
| "$undefined"
| "$symbol"
| "$code"
| "$dbPointer"
| "$uuid"
)
}),
2 => obj.contains_key("$code") && obj.contains_key("$scope"),
_ => false,
};
if !is_wrapper {
return Ok(None);
}
Bson::try_from(serde_json::Value::Object(obj.clone())).map(Some).map_err(|error| error.to_string())
}
fn parse_mongo_shell_date(value: &str) -> Option<DateTime> {
let trimmed = value.trim();
let inner = trimmed.strip_prefix("ISODate(").or_else(|| trimmed.strip_prefix("new Date("))?;
let inner = inner.strip_suffix(')')?.trim();
let quoted = inner
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
.or_else(|| inner.strip_prefix('\'').and_then(|value| value.strip_suffix('\'')))?;
DateTime::parse_rfc3339_str(quoted).ok()
}
fn parse_extended_json_date(obj: &serde_json::Map<String, serde_json::Value>) -> Option<DateTime> {
match obj.get("$date")? {
serde_json::Value::String(value) => DateTime::parse_rfc3339_str(value).ok(),
serde_json::Value::Number(value) => value.as_i64().map(DateTime::from_millis),
serde_json::Value::Object(inner) if inner.len() == 1 => match inner.get("$numberLong") {
Some(serde_json::Value::String(value)) => value.parse::<i64>().ok().map(DateTime::from_millis),
Some(serde_json::Value::Number(value)) => value.as_i64().map(DateTime::from_millis),
_ => None,
},
_ => None,
}
}
fn parse_extended_json_int64(obj: &serde_json::Map<String, serde_json::Value>) -> Option<i64> {
match obj.get("$numberLong")? {
serde_json::Value::String(value) => value.parse().ok(),
serde_json::Value::Number(value) => value.as_i64(),
_ => None,
}
}
fn json_filter_value_to_bson(value: &serde_json::Value, field_name: Option<&str>) -> Bson {
if field_name == Some("_id") {
if let Some(id) = value.as_str() {
return id_equality_bson(id);
}
}
match value {
serde_json::Value::Array(arr) => {
Bson::Array(arr.iter().map(|item| json_filter_value_to_bson(item, None)).collect())
}
serde_json::Value::Object(obj) => {
if obj.len() == 1 {
// Shell constructor wrappers (UUID/BinData/Timestamp/MinKey/MaxKey),
// $regularExpression, and the numeric type wrappers decode through
// the shared extended JSON parser, following the same precedent as
// $date below, so typed number literals compare correctly in filters.
if obj.keys().next().is_some_and(|key| {
matches!(
key.as_str(),
"$regularExpression"
| "$uuid"
| "$binary"
| "$timestamp"
| "$minKey"
| "$maxKey"
| "$numberInt"
| "$numberDouble"
| "$numberDecimal"
)
}) {
if let Ok(Some(value)) = parse_extended_json_value(obj) {
return value;
}
}
if let Some(serde_json::Value::String(hex)) = obj.get("$oid") {
if let Ok(oid) = ObjectId::parse_str(hex) {
return Bson::ObjectId(oid);
}
}
if let Some(value) = parse_extended_json_int64(obj) {
return Bson::Int64(value);
}
// Extended JSON dates must be decoded in filters too, otherwise
// {"$date": ...} reaches the server as a raw document: a bare
// { field: {"$date": ...} } fails with "unknown operator: $date"
// and { field: {"$gte": {"$date": ...}} } silently matches nothing.
if let Some(date) = parse_extended_json_date(obj) {
return Bson::DateTime(date);
}
}
if field_name == Some("_id") && obj.keys().all(|key| key.starts_with('$')) {
let mut doc = Document::new();
for (key, item) in obj {
match key.as_str() {
"$eq" => {
if let Some(id) = item.as_str() {
doc.insert("$in", object_id_string_variants(id));
} else {
doc.insert(key, json_filter_value_to_bson(item, None));
}
}
"$ne" => {
if let Some(id) = item.as_str() {
doc.insert("$nin", object_id_string_variants(id));
} else {
doc.insert(key, json_filter_value_to_bson(item, None));
}
}
"$in" | "$nin" => {
if let Some(items) = item.as_array() {
doc.insert(key, expand_object_id_string_array(items));
} else {
doc.insert(key, json_filter_value_to_bson(item, None));
}
}
_ => {
doc.insert(key, json_filter_value_to_bson(item, None));
}
}
}
return Bson::Document(doc);
}
let doc: Document = obj.iter().map(|(k, v)| (k.clone(), json_filter_value_to_bson(v, Some(k)))).collect();
Bson::Document(doc)
}
_ => json_value_to_bson(value),
}
}
fn id_equality_bson(id: &str) -> Bson {
let variants = object_id_string_variants(id);
if variants.len() == 1 {
variants.into_iter().next().unwrap_or(Bson::String(id.to_string()))
} else {
Bson::Document(doc! { "$in": variants })
}
}
fn object_id_string_variants(id: &str) -> Vec<Bson> {
match ObjectId::parse_str(id) {
Ok(oid) => vec![Bson::ObjectId(oid), Bson::String(id.to_string())],
Err(_) => vec![Bson::String(id.to_string())],
}
}
fn expand_object_id_string_array(items: &[serde_json::Value]) -> Bson {
let mut values = Vec::new();
for item in items {
if let Some(id) = item.as_str() {
values.extend(object_id_string_variants(id));
} else {
values.push(json_filter_value_to_bson(item, None));
}
}
Bson::Array(values)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_oidc_auth_mechanism_in_mongo_uri() {
assert!(mongo_uri_uses_oidc("mongodb://localhost/test?authSource=%24external&authMechanism=MONGODB-OIDC"));
assert!(mongo_uri_uses_oidc(
"mongodb://localhost/test?authMechanism%3Dignored=x&authMechanism=mongodb-oidc#fragment"
));
assert!(!mongo_uri_uses_oidc("mongodb://localhost/test?authSource=admin&authMechanism=SCRAM-SHA-256"));
}
#[test]
fn preserves_mongodb_driver_machine_oidc_environment() {
assert!(mongo_oidc_uses_machine_environment(Some(&mongodb::bson::doc! { "ENVIRONMENT": "k8s" })));
assert!(!mongo_oidc_uses_machine_environment(Some(&mongodb::bson::doc! { "TOKEN_RESOURCE": "resource" })));
assert!(!mongo_oidc_uses_machine_environment(None));
}
#[test]
fn parses_find_collation_options() {
let collation = parse_find_collation(Some(
r#"{"locale":"en","strength":1,"caseLevel":true,"caseFirst":"upper","numericOrdering":true,"alternate":"shifted","maxVariable":"space","normalization":true,"backwards":false}"#,
))
.unwrap()
.unwrap();
assert_eq!(collation.locale, "en");
assert!(matches!(collation.strength, Some(mongodb::options::CollationStrength::Primary)));
assert_eq!(collation.case_level, Some(true));
assert!(matches!(collation.case_first, Some(mongodb::options::CollationCaseFirst::Upper)));
assert_eq!(collation.numeric_ordering, Some(true));
assert!(matches!(collation.alternate, Some(mongodb::options::CollationAlternate::Shifted)));
assert!(matches!(collation.max_variable, Some(mongodb::options::CollationMaxVariable::Space)));
assert_eq!(collation.normalization, Some(true));
assert_eq!(collation.backwards, Some(false));
}
#[test]
fn rejects_invalid_find_collation_options() {
assert!(parse_find_collation(Some(r#"{"strength":1}"#)).unwrap_err().contains("locale"));
assert!(parse_find_collation(Some(r#"{"locale":""}"#)).unwrap_err().contains("locale"));
assert!(parse_find_collation(Some(r#"{"locale":"en","unknown":true}"#))
.unwrap_err()
.contains("Unsupported collation option"));
assert!(parse_find_collation(Some(r#"{"locale":"en","strength":"primary"}"#))
.unwrap_err()
.contains("Invalid collation"));
}
#[test]
fn builds_find_explain_command_with_all_find_options() {
let command = build_find_explain_command(
"im_msg",
2,
5,
Some(r#"{"active":true}"#),
Some(r#"{"email":1}"#),
Some(r#"{"email":1}"#),
Some(r#"{"locale":"en","strength":1}"#),
"executionStats",
)
.unwrap();
let find = command.get_document("explain").unwrap();
assert_eq!(find.get_str("find").unwrap(), "im_msg");
assert!(find.get_document("filter").unwrap().get_bool("active").unwrap());
assert_eq!(find.get_document("projection").unwrap().get_i64("email").unwrap(), 1);
assert_eq!(find.get_document("sort").unwrap().get_i64("email").unwrap(), 1);
assert_eq!(find.get_i64("skip").unwrap(), 2);
assert_eq!(find.get_i64("limit").unwrap(), 5);
assert_eq!(find.get_document("collation").unwrap().get_str("locale").unwrap(), "en");
assert_eq!(command.get_str("verbosity").unwrap(), "executionStats");
}
#[test]
fn rejects_invalid_find_explain_verbosity() {
let error = build_find_explain_command("items", 0, 0, None, None, None, None, "invalid").unwrap_err();
assert!(error.contains("queryPlanner, executionStats, or allPlansExecution"));
}
#[test]
fn mongo_find_count_failure_returns_loaded_lower_bound() {
let error = "invalid type: floating point `2053278871.0`, expected u64".to_string();
assert_eq!(resolve_mongo_find_total(Err(error), false, 100, 25), (125, false));
}
#[test]
fn mongo_find_count_success_preserves_count_semantics() {
assert_eq!(resolve_mongo_find_total(Ok(250), true, 100, 25), (250, true));
assert_eq!(resolve_mongo_find_total(Ok(250), false, 100, 25), (250, false));
}
#[test]
fn parses_sharded_mongo_count_returned_as_integral_double() {
let response = doc! {
"shards": { "shard01": 887_286_174.0, "shard02": 885_925_656.0 },
"n": 1_773_211_830.0,
"ok": 1.0,
};
assert_eq!(parse_count_command_result(&response), Ok(1_773_211_830));
}
#[test]
fn parses_integer_mongo_count_results() {
assert_eq!(parse_count_command_result(&doc! { "n": 42_i32 }), Ok(42));
assert_eq!(parse_count_command_result(&doc! { "n": 4_294_967_296_i64 }), Ok(4_294_967_296));
}
#[test]
fn rejects_invalid_mongo_count_results() {
for response in [
doc! {},
doc! { "n": -1_i32 },
doc! { "n": 1.5 },
doc! { "n": f64::NAN },
doc! { "n": (super::super::JS_MAX_SAFE_INTEGER as f64) + 1.0 },
doc! { "n": "10" },
] {
assert!(parse_count_command_result(&response).is_err(), "response should be rejected: {response:?}");
}
}
/// `CommandError` is `#[non_exhaustive]` with a private field, so tests build it through the
/// driver's derived `Deserialize`.
fn count_command_error(code: i32, code_name: &str) -> mongodb::error::CommandError {
serde_json::from_str(&format!(r#"{{"code": {code}, "codeName": "{code_name}", "errmsg": "count failed"}}"#))
.unwrap()
}
#[test]
fn maps_mongo_namespace_not_found_count_errors_to_zero() {
let kind = mongodb::error::ErrorKind::Command(count_command_error(26, "NamespaceNotFound"));
assert_eq!(parse_count_command_error(&kind), Some(0));
}
#[test]
fn propagates_mongo_count_errors_other_than_namespace_not_found() {
for (code, code_name) in [(13, "Unauthorized"), (17405, "CommandNotFound")] {
let kind = mongodb::error::ErrorKind::Command(count_command_error(code, code_name));
assert_eq!(parse_count_command_error(&kind), None, "code {code} should propagate");
}
assert_eq!(parse_count_command_error(&mongodb::error::ErrorKind::Shutdown), None);
}
#[test]
fn detects_mongo_secondary_only_list_databases_errors() {
assert!(list_databases_requires_secondary_fallback("NotWritablePrimary: not master"));
assert!(list_databases_requires_secondary_fallback("not master and slaveOk=false"));
assert!(!list_databases_requires_secondary_fallback("Unauthorized: listDatabases"));
}
#[test]
fn list_databases_fallback_selects_secondary_preferred() {
assert!(matches!(
list_databases_secondary_selection(),
SelectionCriteria::ReadPreference(ReadPreference::SecondaryPreferred { .. })
));
}
#[test]
fn parse_aggregate_options_document_keeps_official_fields() {
let doc = parse_aggregate_options_document(Some(
r#"{
"explain": true,
"allowDiskUse": true,
"cursor": { "batchSize": 25 },
"maxTimeMS": 1000,
"bypassDocumentValidation": true,
"collation": { "locale": "en" },
"hint": "status_1",
"comment": "agg-test",
"let": { "year": 2024 },
"readConcern": { "level": "local" }
}"#,
))
.unwrap();
assert_eq!(doc.get_bool("explain").ok(), Some(true));
assert_eq!(doc.get_bool("allowDiskUse").ok(), Some(true));
assert_eq!(doc.get_i64("maxTimeMS").ok(), Some(1000));
assert_eq!(doc.get_str("hint").ok(), Some("status_1"));
assert_eq!(doc.get_str("comment").ok(), Some("agg-test"));
assert_eq!(doc.get_document("cursor").ok().and_then(|c| c.get_i64("batchSize").ok()), Some(25));
assert_eq!(doc.get_document("collation").ok().and_then(|c| c.get_str("locale").ok()), Some("en"));
assert_eq!(doc.get_document("let").ok().and_then(|c| c.get_i64("year").ok()), Some(2024));
assert!(aggregate_options_explain(&doc).unwrap());
let no_explain = parse_aggregate_options_document(Some(r#"{"allowDiskUse":false}"#)).unwrap();
assert!(!aggregate_options_explain(&no_explain).unwrap());
let err = aggregate_options_explain(
&parse_aggregate_options_document(Some(r#"{"explain":"executionStats"}"#)).unwrap(),
)
.unwrap_err();
assert!(err.contains("boolean"), "{err}");
}
#[test]
fn build_aggregate_command_adds_cursor_unless_explain() {
let pipeline = vec![doc! { "$match": { "active": true } }];
let (with_cursor, explain_flag) = build_aggregate_command(
"products",
pipeline.clone(),
parse_aggregate_options_document(Some(r#"{"allowDiskUse":true,"cursor":{"batchSize":1}}"#)).unwrap(),
)
.unwrap();
assert!(!explain_flag);
assert_eq!(with_cursor.get_str("aggregate").ok(), Some("products"));
assert_eq!(with_cursor.get_bool("allowDiskUse").ok(), Some(true));
assert_eq!(with_cursor.get_document("cursor").ok().and_then(|c| c.get_i64("batchSize").ok()), Some(1));
let (default_cursor, _) = build_aggregate_command(
"products",
pipeline.clone(),
parse_aggregate_options_document(Some(r#"{"comment":"agg"}"#)).unwrap(),
)
.unwrap();
assert!(default_cursor.get_document("cursor").is_ok(), "non-explain aggregate requires cursor");
assert_eq!(default_cursor.get_str("comment").ok(), Some("agg"));
// Empty options still produce a cursor command (single path with run_cursor_command).
let (empty_options, empty_explain) =
build_aggregate_command("products", pipeline.clone(), Document::new()).unwrap();
assert!(!empty_explain);
assert!(empty_options.get_document("cursor").is_ok());
let (explain, is_explain) = build_aggregate_command(
"products",
pipeline,
parse_aggregate_options_document(Some(r#"{"explain":true,"allowDiskUse":true}"#)).unwrap(),
)
.unwrap();
assert!(is_explain);
assert!(explain.get("cursor").is_none(), "explain path must not inject cursor");
assert_eq!(explain.get_bool("explain").ok(), Some(true));
}
#[test]
fn update_modifications_accept_document_and_pipeline() {
let document = json_update_to_modifications(&serde_json::json!({ "$set": { "status": "done" } })).unwrap();
match document {
mongodb::options::UpdateModifications::Document(document) => {
assert_eq!(document, doc! { "$set": { "status": "done" } });
}
other => panic!("expected document update, got {other:?}"),
}
let owner_id = ObjectId::parse_str("507f1f77bcf86cd799439011").unwrap();
let pipeline = json_update_to_modifications(&serde_json::json!([
{ "$set": { "update_date": { "$add": ["$update_date", 1000] } } },
{ "$set": { "owner_id": { "$oid": owner_id.to_hex() } } }
]))
.unwrap();
match pipeline {
mongodb::options::UpdateModifications::Pipeline(stages) => {
assert_eq!(
stages,
vec![
doc! { "$set": { "update_date": { "$add": ["$update_date", 1000_i64] } } },
doc! { "$set": { "owner_id": owner_id } },
]
);
}
other => panic!("expected pipeline update, got {other:?}"),
}
}
#[test]
fn update_modifications_reject_invalid_shapes() {
let stage_error =
json_update_to_modifications(&serde_json::json!([{ "$set": { "status": "done" } }, "invalid"]))
.unwrap_err();
assert!(stage_error.contains("stage 2"));
let value_error = json_update_to_modifications(&serde_json::json!("invalid")).unwrap_err();
assert!(value_error.contains("object or pipeline array"));
}
#[test]
fn update_options_reject_unknown_fields_instead_of_dropping_them() {
let error = parse_update_options(Some(r#"{"upsert":true,"collation":{"locale":"en"}}"#)).unwrap_err();
assert!(error.contains("collation"), "{error}");
// Same rule as replace: an option the driver would not apply must not be silently ignored.
assert!(parse_replace_options(Some(r#"{"upsert":true,"collation":{"locale":"en"}}"#)).is_err());
assert!(parse_update_options(Some("{}")).is_ok());
assert!(parse_update_options(None).is_ok());
}
#[test]
fn update_options_parse_upsert_and_array_filters() {
let options =
parse_update_options(Some(r#"{"upsert":true,"arrayFilters":[{"item.id":322678},{"item.active":true}]}"#))
.unwrap();
assert_eq!(options.upsert, Some(true));
assert_eq!(options.array_filters.unwrap(), vec![doc! { "item.id": 322678_i64 }, doc! { "item.active": true }]);
}
#[test]
fn rename_collection_command_uses_fully_qualified_names() {
let command = rename_collection_command_document("app", "users", "accounts").unwrap();
assert_eq!(command.get_str("renameCollection").unwrap(), "app.users");
assert_eq!(command.get_str("to").unwrap(), "app.accounts");
}
#[test]
fn rename_collection_command_rejects_invalid_names() {
assert!(rename_collection_command_document("", "users", "accounts").unwrap_err().contains("Database"));
assert!(rename_collection_command_document("app", "", "accounts").unwrap_err().contains("Collection"));
assert!(rename_collection_command_document("app", "users", "").unwrap_err().contains("New collection"));
assert!(rename_collection_command_document("app", "users", "users").unwrap_err().contains("differ"));
assert!(rename_collection_command_document("app", "system.views", "views_backup")
.unwrap_err()
.contains("System collections"));
assert!(rename_collection_command_document("app", "users", "system.users")
.unwrap_err()
.contains("System collections"));
}
#[test]
fn rename_collection_command_preserves_identifier_whitespace() {
let command = rename_collection_command_document("app", " users ", " renamed ").unwrap();
assert_eq!(command.get_str("renameCollection").unwrap(), "app. users ");
assert_eq!(command.get_str("to").unwrap(), "app. renamed ");
}
#[test]
fn rename_collection_command_does_not_drop_existing_target() {
// Existing target names must fail at the server instead of being overwritten.
let command = rename_collection_command_document("app", "users", "accounts").unwrap();
assert!(!command.contains_key("dropTarget"));
}
#[test]
fn clone_collection_validation_preserves_identifiers_and_rejects_unsafe_targets() {
validate_clone_collection_names("app", " users ", " users_backup ").unwrap();
assert!(validate_clone_collection_names("app", "users", "users").unwrap_err().contains("differ"));
assert!(validate_clone_collection_names("app", "system.users", "users_backup")
.unwrap_err()
.contains("System collections"));
assert!(validate_clone_collection_names("app", "users", "system.users_backup")
.unwrap_err()
.contains("System collections"));
}
#[test]
fn clone_collection_skips_the_automatic_id_index_by_key_not_name() {
let automatic_id = IndexModel::builder()
.keys(doc! { "_id": 1 })
.options(IndexOptions::builder().name("custom_id_name".to_string()).build())
.build();
let ordinary_index = IndexModel::builder()
.keys(doc! { "external_id": 1 })
.options(IndexOptions::builder().name("external_id_1".to_string()).build())
.build();
assert!(is_automatic_id_index(&automatic_id));
assert!(!is_automatic_id_index(&ordinary_index));
}
#[test]
fn mongo_collection_kind_as_str_is_stable_wire_value() {
assert_eq!(MongoCollectionKind::Collection.as_str(), "collection");
assert_eq!(MongoCollectionKind::View.as_str(), "view");
assert_eq!(MongoCollectionKind::Timeseries.as_str(), "timeseries");
}
#[test]
fn multi_seed_uri_removes_direct_connection_true_before_driver_parse() {
let uri =
"mongodb://read:pass@host1:27017,host2:27017/admin?directConnection=true&replicaSet=rs0&authSource=admin";
let normalized = normalize_mongo_uri_direct_connection(uri);
assert_eq!(normalized, "mongodb://read:pass@host1:27017,host2:27017/admin?replicaSet=rs0&authSource=admin");
}
#[test]
fn multi_seed_uri_removes_encoded_direct_connection_true_and_keeps_fragment() {
let uri = "mongodb://host1:27017,host2:27017/admin?authSource=admin&direct%43onnection=TRUE#read";
let normalized = normalize_mongo_uri_direct_connection(uri);
assert_eq!(normalized, "mongodb://host1:27017,host2:27017/admin?authSource=admin#read");
}
#[test]
fn srv_uri_removes_direct_connection_true_before_driver_parse() {
let uri = "mongodb+srv://read:pass@cluster.example.net/admin?tls=true&directConnection=true&replicaSet=rs0";
let normalized = normalize_mongo_uri_direct_connection(uri);
assert_eq!(normalized, "mongodb+srv://read:pass@cluster.example.net/admin?tls=true&replicaSet=rs0");
assert!(is_multi_host_mongo_uri(&normalized));
}
#[test]
fn single_seed_uri_keeps_direct_connection_true() {
let uri = "mongodb://host1:27017/admin?directConnection=true&authSource=admin";
let normalized = normalize_mongo_uri_direct_connection(uri);
assert_eq!(normalized, uri);
}
#[test]
fn multi_seed_uri_keeps_direct_connection_false() {
let uri = "mongodb://host1:27017,host2:27017/admin?directConnection=false&replicaSet=rs0";
let normalized = normalize_mongo_uri_direct_connection(uri);
assert_eq!(normalized, uri);
}
#[test]
fn document_id_filters_try_object_id_then_string_for_hex_ids() {
let id = "507f1f77bcf86cd799439011";
let filters = document_id_filters(id);
assert_eq!(filters.len(), 2);
assert!(matches!(filters[0].get("_id"), Some(Bson::ObjectId(_))));
assert!(matches!(filters[1].get("_id"), Some(Bson::String(value)) if value == id));
}
#[test]
fn document_id_filters_use_string_only_for_non_hex_ids() {
let id = "customer-42";
let filters = document_id_filters(id);
assert_eq!(filters.len(), 1);
assert!(matches!(filters[0].get("_id"), Some(Bson::String(value)) if value == id));
}
#[test]
fn document_id_filters_preserve_extended_json_int64_ids() {
let filters = document_id_filters(r#"{"$numberLong":"2048938405781032962"}"#);
assert_eq!(filters.len(), 1);
assert!(matches!(filters[0].get("_id"), Some(Bson::Int64(2_048_938_405_781_032_962))));
}
#[test]
fn document_id_filters_decode_explicit_string_ids_before_extended_json() {
let original = r#"{"$numberLong":"2048938405781032962"}"#;
let id = format!("__dbx_mongo_string_id__{}", serde_json::to_string(original).unwrap());
let filters = document_id_filters(&id);
assert_eq!(filters.len(), 1);
assert!(
matches!(filters[0].get("_id"), Some(Bson::String(value)) if value == r####"{"$numberLong":"2048938405781032962"}"####)
);
}
#[test]
fn document_id_filters_keep_explicit_hex_string_ids_as_strings() {
let hex = "507f1f77bcf86cd799439011";
let id = format!("__dbx_mongo_string_id__{}", serde_json::to_string(hex).unwrap());
let filters = document_id_filters(&id);
assert_eq!(filters.len(), 1);
assert!(matches!(filters[0].get("_id"), Some(Bson::String(value)) if value == hex));
}
#[test]
fn document_id_filters_keep_explicit_object_ids_as_object_ids() {
let filters = document_id_filters(r#"{"$oid":"507f1f77bcf86cd799439011"}"#);
assert_eq!(filters.len(), 1);
assert!(
matches!(filters[0].get("_id"), Some(Bson::ObjectId(oid)) if oid.to_hex() == "507f1f77bcf86cd799439011")
);
}
#[test]
fn document_id_filters_match_numeric_ids_before_string() {
let filters = document_id_filters("42");
assert_eq!(filters.len(), 2);
assert!(matches!(filters[0].get("_id"), Some(Bson::Int64(42))));
assert!(matches!(filters[1].get("_id"), Some(Bson::String(value)) if value == "42"));
}
#[test]
fn json_filter_to_document_preserves_extended_json_int64_values() {
let filter = serde_json::json!({ "snowflake": { "$numberLong": "2048938405781032962" } });
let document = json_filter_to_document(&filter).unwrap();
assert!(matches!(document.get("snowflake"), Some(Bson::Int64(2_048_938_405_781_032_962))));
}
#[test]
fn json_filter_to_document_matches_object_id_and_string_for_id_hex() {
let id = "507f1f77bcf86cd799439011";
let filter = serde_json::json!({ "_id": id });
let doc = json_filter_to_document(&filter).unwrap();
let Some(Bson::Document(id_filter)) = doc.get("_id") else {
panic!("expected _id operator document");
};
let Some(Bson::Array(values)) = id_filter.get("$in") else {
panic!("expected _id $in variants");
};
assert!(matches!(values.first(), Some(Bson::ObjectId(_))));
assert!(matches!(values.get(1), Some(Bson::String(value)) if value == id));
}
#[test]
fn json_filter_to_document_expands_id_operator_variants() {
let id = "507f1f77bcf86cd799439011";
let filter = serde_json::json!({ "$and": [{ "_id": { "$eq": id } }] });
let doc = json_filter_to_document(&filter).unwrap();
let Some(Bson::Array(and_items)) = doc.get("$and") else {
panic!("expected $and array");
};
let Some(Bson::Document(first)) = and_items.first() else {
panic!("expected first $and document");
};
let Some(Bson::Document(id_filter)) = first.get("_id") else {
panic!("expected _id operator document");
};
assert!(matches!(id_filter.get("$in"), Some(Bson::Array(values)) if values.len() == 2));
}
#[test]
fn json_filter_to_document_leaves_non_id_hex_strings_alone() {
let id = "507f1f77bcf86cd799439011";
let filter = serde_json::json!({ "owner_id": id });
let doc = json_filter_to_document(&filter).unwrap();
assert!(matches!(doc.get("owner_id"), Some(Bson::String(value)) if value == id));
}
#[test]
fn json_filter_to_document_decodes_extended_json_dates() {
let iso = "2025-02-25T04:57:39.965Z";
let expected = DateTime::parse_rfc3339_str(iso).unwrap();
// Direct equality must yield a BSON DateTime, not a raw { "$date": ... }
// document that the server rejects with "unknown operator: $date".
let filter = serde_json::json!({ "createdAt": { "$date": iso } });
let doc = json_filter_to_document(&filter).unwrap();
assert_eq!(doc.get("createdAt"), Some(&Bson::DateTime(expected)));
// Range operands must be decoded too, otherwise $gte compares against a
// sub-document and silently matches nothing.
let range = serde_json::json!({ "createdAt": { "$gte": { "$date": iso } } });
let range_doc = json_filter_to_document(&range).unwrap();
let Some(Bson::Document(op)) = range_doc.get("createdAt") else {
panic!("expected operator document");
};
assert_eq!(op.get("$gte"), Some(&Bson::DateTime(expected)));
}
#[test]
fn json_filter_to_document_decodes_uuid_binary_timestamp_and_key_constants() {
// Equality on shell constructor values must compare against the typed
// BSON value, not a raw { "$uuid": ... } document the server rejects
// with "unknown operator: $uuid" (or silently matches nothing).
let filter = serde_json::json!({
"_id": { "$uuid": "3b241101-e2bb-4255-8caf-4136c566a962" },
"payload": { "$binary": { "base64": "AQID", "subType": "80" } },
"moment": { "$timestamp": { "t": 1735689600, "i": 7 } },
"lower": { "$minKey": 1 },
"upper": { "$maxKey": 1 },
});
let doc = json_filter_to_document(&filter).unwrap();
let expected_uuid = uuid::Uuid::parse_str("3b241101-e2bb-4255-8caf-4136c566a962").unwrap();
assert!(matches!(
doc.get("_id"),
Some(Bson::Binary(binary))
if binary.subtype == mongodb::bson::spec::BinarySubtype::Uuid && binary.bytes == expected_uuid.as_bytes()
));
assert!(matches!(
doc.get("payload"),
Some(Bson::Binary(binary))
if binary.subtype == mongodb::bson::spec::BinarySubtype::UserDefined(0x80) && binary.bytes == [1, 2, 3]
));
assert!(matches!(
doc.get("moment"),
Some(Bson::Timestamp(timestamp)) if timestamp.time == 1735689600 && timestamp.increment == 7
));
assert!(matches!(doc.get("lower"), Some(Bson::MinKey)));
assert!(matches!(doc.get("upper"), Some(Bson::MaxKey)));
}
#[test]
fn json_filter_to_document_decodes_key_constants_inside_operators() {
// Range operands must be decoded too, exactly like extended JSON dates:
// { score: { $gt: MinKey } } and { _id: { $gt: UUID(...) } } would
// otherwise compare against a sub-document and silently match nothing.
// The _id case also covers the all-$ operator document branch, where
// operators like $gt keep recursing through json_filter_value_to_bson.
let filter = serde_json::json!({
"score": { "$gt": { "$minKey": 1 }, "$lt": { "$maxKey": 1 } },
"_id": { "$gt": { "$uuid": "3b241101-e2bb-4255-8caf-4136c566a962" } },
"snapshot": { "$gte": { "$timestamp": { "t": 1735689600, "i": 7 } } },
"blob": { "$eq": { "$binary": { "base64": "AQID", "subType": "00" } } },
});
let doc = json_filter_to_document(&filter).unwrap();
let Some(Bson::Document(score)) = doc.get("score") else {
panic!("expected score operator document");
};
assert_eq!(score.get("$gt"), Some(&Bson::MinKey));
assert_eq!(score.get("$lt"), Some(&Bson::MaxKey));
let Some(Bson::Document(id_filter)) = doc.get("_id") else {
panic!("expected _id operator document");
};
assert!(matches!(
id_filter.get("$gt"),
Some(Bson::Binary(binary)) if binary.subtype == mongodb::bson::spec::BinarySubtype::Uuid
));
let Some(Bson::Document(snapshot)) = doc.get("snapshot") else {
panic!("expected snapshot operator document");
};
assert!(matches!(
snapshot.get("$gte"),
Some(Bson::Timestamp(timestamp)) if timestamp.time == 1735689600 && timestamp.increment == 7
));
let Some(Bson::Document(blob)) = doc.get("blob") else {
panic!("expected blob operator document");
};
assert!(matches!(
blob.get("$eq"),
Some(Bson::Binary(binary)) if binary.subtype == mongodb::bson::spec::BinarySubtype::Generic && binary.bytes == [1, 2, 3]
));
}
#[test]
fn json_filter_to_document_decodes_extended_json_number_wrappers() {
// Typed number literals must compare against the typed BSON value, not a
// raw { "$numberInt": ... } document the server rejects with
// "unknown operator" (or that silently matches nothing).
let filter = serde_json::json!({
"score": { "$numberInt": "5" },
"ratio": { "$numberDouble": "1.5" },
"price": { "$numberDecimal": "3.14" },
});
let doc = json_filter_to_document(&filter).unwrap();
assert_eq!(doc.get("score"), Some(&Bson::Int32(5)));
assert_eq!(doc.get("ratio"), Some(&Bson::Double(1.5)));
let expected_decimal: mongodb::bson::Decimal128 = "3.14".parse().unwrap();
assert_eq!(doc.get("price"), Some(&Bson::Decimal128(expected_decimal)));
}
#[test]
fn json_filter_to_document_decodes_number_wrappers_inside_operators() {
// Range and $in operands must decode too, exactly like extended JSON
// dates: { score: { $gte: {"$numberInt": "5"} } } would otherwise
// compare against a sub-document and silently match nothing.
let filter = serde_json::json!({
"score": { "$gte": { "$numberInt": "5" } },
"tags": { "$in": [{ "$numberDecimal": "3.14" }, { "$numberDouble": "1.5" }] },
});
let doc = json_filter_to_document(&filter).unwrap();
let Some(Bson::Document(score)) = doc.get("score") else {
panic!("expected score operator document");
};
assert_eq!(score.get("$gte"), Some(&Bson::Int32(5)));
let Some(Bson::Document(tags)) = doc.get("tags") else {
panic!("expected tags operator document");
};
let Some(Bson::Array(values)) = tags.get("$in") else {
panic!("expected tags $in array");
};
let expected_decimal: mongodb::bson::Decimal128 = "3.14".parse().unwrap();
assert_eq!(values.first(), Some(&Bson::Decimal128(expected_decimal)));
assert_eq!(values.get(1), Some(&Bson::Double(1.5)));
}
#[test]
fn bson_to_json_displays_date_as_mongo_isodate() {
let date = DateTime::parse_rfc3339_str("2026-06-10T13:59:31.287Z").unwrap();
let value = bson_to_json(&Bson::DateTime(date));
assert_eq!(value, serde_json::json!("ISODate(\"2026-06-10T13:59:31.287Z\")"));
}
#[test]
fn mongo_document_result_keeps_extended_json_types_for_copying() {
let date = DateTime::parse_rfc3339_str("2025-05-06T08:35:32Z").unwrap();
let result = single_document_result(Some(doc! {
"lastUpdatedDate": date,
"dateText": "ISODate(\"2025-05-06T08:35:32Z\")",
}));
assert_eq!(result.documents[0]["lastUpdatedDate"], serde_json::json!("ISODate(\"2025-05-06T08:35:32Z\")"));
let extended = result.extended_documents.expect("extended documents");
assert_eq!(extended[0]["lastUpdatedDate"], serde_json::json!({ "$date": { "$numberLong": "1746520532000" } }));
assert_eq!(extended[0]["dateText"], serde_json::json!("ISODate(\"2025-05-06T08:35:32Z\")"));
}
#[test]
fn document_json_views_keep_browser_display_and_canonical_bson_types() {
let date = DateTime::parse_rfc3339_str("2026-06-10T13:59:31.287Z").unwrap();
let (browser, extended) = document_json_views(doc! {
"date": date,
"int32": Bson::Int32(42),
"int64": Bson::Int64(42),
"unsafeInt64": Bson::Int64(2_326_645_729_978_441_729),
});
assert_eq!(
browser,
serde_json::json!({
"date": "ISODate(\"2026-06-10T13:59:31.287Z\")",
"int32": 42,
"int64": 42,
"unsafeInt64": { "$numberLong": "2326645729978441729" },
})
);
assert_eq!(
extended,
serde_json::json!({
"date": { "$date": { "$numberLong": "1781099971287" } },
"int32": { "$numberInt": "42" },
"int64": { "$numberLong": "42" },
"unsafeInt64": { "$numberLong": "2326645729978441729" },
})
);
}
#[test]
fn bson_to_json_preserves_unsafe_int64_for_js() {
let value = bson_to_json(&Bson::Int64(2_326_645_729_978_441_729));
assert_eq!(value, serde_json::json!("2326645729978441729"));
}
#[test]
fn bson_to_json_preserves_int64_id_type_for_updates() {
let value = bson_to_json(&Bson::Document(doc! {
"_id": Bson::Int64(2_048_938_405_781_032_962),
"snowflake": Bson::Int64(2_048_938_405_781_032_962),
}));
assert_eq!(value["_id"], serde_json::json!({ "$numberLong": "2048938405781032962" }));
assert_eq!(value["snowflake"], serde_json::json!("2048938405781032962"));
}
#[test]
fn bson_to_json_preserves_object_id_type_for_updates() {
let oid = ObjectId::parse_str("507f1f77bcf86cd799439011").unwrap();
let value = bson_to_json(&Bson::Document(doc! {
"_id": Bson::ObjectId(oid),
}));
assert_eq!(value["_id"], serde_json::json!({ "$oid": "507f1f77bcf86cd799439011" }));
}
#[test]
fn bson_to_json_keeps_safe_int64_as_number() {
let value = bson_to_json(&Bson::Int64(42));
assert_eq!(value, serde_json::json!(42));
}
#[test]
fn bson_to_json_displays_object_id_as_string() {
let oid = ObjectId::parse_str("507f1f77bcf86cd799439011").unwrap();
let value = bson_to_json(&Bson::ObjectId(oid));
assert_eq!(value, serde_json::json!("507f1f77bcf86cd799439011"));
}
#[test]
fn browser_json_keeps_normal_scalars_readable_and_unsafe_int64_typed() {
let date = DateTime::parse_rfc3339_str("2026-06-10T13:59:31.287Z").unwrap();
let value = bson_to_browser_json(&Bson::Document(doc! {
"int32": Bson::Int32(42), "double": Bson::Double(3.5), "date": Bson::DateTime(date),
"unsafe": Bson::Int64(2_326_645_729_978_441_729),
}));
assert_eq!(value["int32"], serde_json::json!(42));
assert_eq!(value["double"], serde_json::json!(3.5));
assert_eq!(value["date"], serde_json::json!("ISODate(\"2026-06-10T13:59:31.287Z\")"));
assert_eq!(value["unsafe"], serde_json::json!({ "$numberLong": "2326645729978441729" }));
}
#[test]
fn browser_json_round_trips_normal_scalars_and_unsafe_int64_on_edit() {
let date = DateTime::parse_rfc3339_str("2026-06-10T13:59:31.287Z").unwrap();
let existing = doc! {
"name": "before",
"int32": Bson::Int32(42),
"double": Bson::Double(3.5),
"date": Bson::DateTime(date),
"unsafe": Bson::Int64(2_326_645_729_978_441_729),
};
let mut edited = bson_to_browser_json(&Bson::Document(existing.clone()));
edited["name"] = serde_json::json!("after");
let round_tripped = json_object_to_document_for_update(&edited, Some(&existing)).unwrap();
assert_eq!(round_tripped.get_str("name").unwrap(), "after");
assert!(matches!(round_tripped.get("int32"), Some(Bson::Int32(42))));
assert!(matches!(round_tripped.get("double"), Some(Bson::Double(value)) if *value == 3.5));
assert!(matches!(round_tripped.get("date"), Some(Bson::DateTime(value)) if *value == date));
assert!(matches!(round_tripped.get("unsafe"), Some(Bson::Int64(2_326_645_729_978_441_729))));
}
#[test]
fn browser_json_round_trips_mixed_bson_types_when_editing_an_unrelated_field() {
let object_id = ObjectId::parse_str("507f1f77bcf86cd799439011").unwrap();
let date = DateTime::parse_rfc3339_str("2026-06-10T13:59:31.287Z").unwrap();
let existing = doc! {
"_id": Bson::ObjectId(object_id),
"name": "before",
"date": Bson::DateTime(date),
"unsafe": Bson::Int64(2_326_645_729_978_441_729),
};
let mut edited = bson_to_browser_json(&Bson::Document(existing.clone()));
assert_eq!(edited["_id"], serde_json::json!({ "$oid": "507f1f77bcf86cd799439011" }));
assert_eq!(edited["date"], serde_json::json!("ISODate(\"2026-06-10T13:59:31.287Z\")"));
assert_eq!(edited["unsafe"], serde_json::json!({ "$numberLong": "2326645729978441729" }));
edited["name"] = serde_json::json!("after");
let round_tripped = json_object_to_document_for_update(&edited, None).unwrap();
assert_eq!(round_tripped.get_str("name").unwrap(), "after");
assert!(matches!(
round_tripped.get("_id"),
Some(Bson::ObjectId(value)) if *value == object_id
));
assert!(matches!(round_tripped.get("date"), Some(Bson::DateTime(value)) if *value == date));
assert!(matches!(round_tripped.get("unsafe"), Some(Bson::Int64(2_326_645_729_978_441_729))));
}
#[test]
fn document_update_rejects_invalid_extended_json_wrapper() {
let edited = serde_json::json!({
"unsafe": { "$numberLong": "not-an-integer" },
});
assert!(json_object_to_document_for_update(&edited, None).is_err());
}
#[test]
fn document_update_parses_wrapper_when_replacing_an_existing_document_value() {
let object_id = ObjectId::parse_str("507f1f77bcf86cd799439011").unwrap();
let existing = doc! {
"value": { "kind": "plain" },
};
let edited = serde_json::json!({
"value": { "$oid": "507f1f77bcf86cd799439011" },
});
let updated = json_object_to_document_for_update(&edited, Some(&existing)).unwrap();
assert!(matches!(updated.get("value"), Some(Bson::ObjectId(value)) if *value == object_id));
}
#[test]
fn document_update_decodes_uuid_wrapper_per_value() {
let uuid = serde_json::json!({ "$uuid": "00112233-4455-6677-8899-aabbccddeeff" });
let expected = Bson::try_from(uuid.clone()).unwrap();
let edited = serde_json::json!({
"name": "after",
"uuid": uuid,
});
let updated = json_object_to_document_for_update(&edited, None).unwrap();
assert_eq!(updated.get("uuid"), Some(&expected));
}
#[test]
fn browser_json_filters_decode_normal_scalars_and_unsafe_int64() {
let filter = serde_json::json!({
"int32": 42,
"double": 3.5,
"date": "ISODate(\"2026-06-10T13:59:31.287Z\")",
"unsafe": { "$numberLong": "2326645729978441729" },
});
let document = json_filter_to_document(&filter).unwrap();
assert!(matches!(document.get("int32"), Some(Bson::Int64(42))));
assert!(matches!(document.get("double"), Some(Bson::Double(value)) if *value == 3.5));
assert!(
matches!(document.get("date"), Some(Bson::DateTime(value)) if value.timestamp_millis() == 1_781_099_971_287)
);
assert!(matches!(document.get("unsafe"), Some(Bson::Int64(2_326_645_729_978_441_729))));
}
#[test]
fn canonical_extended_json_preserves_nested_object_ids() {
let oid = ObjectId::parse_str("507f1f77bcf86cd799439011").unwrap();
let nested_oid = ObjectId::parse_str("507f191e810c19729de860ea").unwrap();
let value = Bson::Document(doc! {
"_id": Bson::ObjectId(oid),
"owner": { "id": Bson::ObjectId(nested_oid) },
"tags": [Bson::ObjectId(nested_oid)],
})
.into_canonical_extjson();
assert_eq!(
value,
serde_json::json!({
"_id": { "$oid": "507f1f77bcf86cd799439011" },
"owner": { "id": { "$oid": "507f191e810c19729de860ea" } },
"tags": [{ "$oid": "507f191e810c19729de860ea" }],
})
);
}
#[test]
fn canonical_extended_json_round_trips_unsafe_int64() {
let original = doc! {
"counter": Bson::Int64(2_326_645_729_978_441_729),
};
let extended_json = Bson::Document(original.clone()).into_canonical_extjson();
assert_eq!(extended_json, serde_json::json!({ "counter": { "$numberLong": "2326645729978441729" } }));
let round_tripped = json_object_to_document_extended_json(&extended_json).unwrap();
assert_eq!(round_tripped, original);
}
#[test]
fn index_info_from_model_maps_mongodb_index_metadata() {
let model = IndexModel::builder()
.keys(doc! { "tenant_id": 1, "created_at": -1 })
.options(
IndexOptions::builder()
.name("tenant_created_idx".to_string())
.unique(true)
.partial_filter_expression(doc! { "archived": false })
.build(),
)
.build();
let index = index_info_from_model(model);
assert_eq!(index.name, "tenant_created_idx");
assert_eq!(index.columns, vec!["tenant_id", "created_at"]);
assert!(index.is_unique);
assert!(!index.is_primary);
assert_eq!(index.index_type.as_deref(), Some("tenant_id: 1, created_at: -1"));
assert_eq!(index.filter.as_deref(), Some("{\"archived\":false}"));
}
#[test]
fn index_info_from_model_marks_default_id_index_as_primary() {
let model = IndexModel::builder()
.keys(doc! { "_id": 1 })
.options(IndexOptions::builder().name("_id_".to_string()).unique(true).build())
.build();
let index = index_info_from_model(model);
assert_eq!(index.columns, vec!["_id"]);
assert!(index.is_unique);
assert!(index.is_primary);
}
#[test]
fn index_spec_from_document_reports_every_modeled_property() {
let spec = index_spec_from_document(&doc! {
"v": 2,
"key": { "expiresAt": 1 },
"name": "expires_ttl",
"unique": true,
"sparse": true,
"expireAfterSeconds": 3600,
"partialFilterExpression": { "archived": false },
"hidden": true,
});
assert_eq!(spec.name, "expires_ttl");
assert_eq!(spec.keys, vec![MongoIndexKey { field: "expiresAt".to_string(), direction: "1".to_string() }]);
assert!(spec.is_unique);
assert!(spec.is_sparse);
assert!(!spec.is_primary);
assert_eq!(spec.expire_after_seconds, Some(3600));
assert_eq!(spec.partial_filter_expression.as_deref(), Some("{\"archived\":false}"));
assert!(spec.hidden);
assert!(spec.properties_complete);
assert_eq!(spec.extra_options, None);
}
#[test]
fn index_spec_from_document_canonicalizes_whole_doubles_and_marks_the_default_index() {
let spec = index_spec_from_document(&doc! {
"key": { "_id": 1.0 },
"name": "_id_",
});
assert!(spec.is_primary);
assert_eq!(spec.keys, vec![MongoIndexKey { field: "_id".to_string(), direction: "1".to_string() }]);
assert!(spec.is_unique, "the default _id index is unique even when the server omits the flag");
}
#[test]
fn index_spec_from_document_keeps_non_numeric_key_directions_literal() {
let spec = index_spec_from_document(&doc! {
"key": { "content": "text", "location": "2dsphere" },
"name": "content_text_location_2dsphere",
});
assert_eq!(
spec.keys,
vec![
MongoIndexKey { field: "content".to_string(), direction: "text".to_string() },
MongoIndexKey { field: "location".to_string(), direction: "2dsphere".to_string() },
]
);
}
#[test]
fn index_spec_from_document_accepts_numeric_truthiness_for_flags() {
let spec = index_spec_from_document(&doc! {
"key": { "email": 1 },
"name": "email_1",
"unique": 1,
"sparse": 0,
"background": 1,
});
assert!(spec.is_unique);
assert!(!spec.is_sparse);
assert!(spec.background);
}
#[test]
fn index_spec_from_document_collects_unmodeled_options_without_losing_them() {
let spec = index_spec_from_document(&doc! {
"key": { "location": "2dsphere" },
"name": "location_2dsphere",
"2dsphereIndexVersion": 3,
"collation": { "locale": "en" },
});
let extra = spec.extra_options.expect("unmodeled options must be preserved");
assert!(extra.contains("2dsphereIndexVersion"), "{extra}");
assert!(extra.contains("collation"), "{extra}");
assert!(!extra.contains("\"name\""), "modeled options must not be duplicated: {extra}");
assert!(!extra.contains("\"key\""), "modeled options must not be duplicated: {extra}");
}
#[test]
fn index_spec_from_document_derives_a_name_when_the_server_omits_it() {
let spec = index_spec_from_document(&doc! { "key": { "email": 1, "createdAt": -1 } });
assert_eq!(spec.name, "email_1_createdAt_-1");
}
#[test]
fn index_spec_from_document_reads_int64_and_double_ttl_values() {
let from_int64 =
index_spec_from_document(&doc! { "key": { "a": 1 }, "name": "a_1", "expireAfterSeconds": Bson::Int64(90) });
let from_double = index_spec_from_document(
&doc! { "key": { "a": 1 }, "name": "a_1", "expireAfterSeconds": Bson::Double(90.0) },
);
assert_eq!(from_int64.expire_after_seconds, Some(90));
assert_eq!(from_double.expire_after_seconds, Some(90));
}
#[test]
fn index_spec_from_index_info_marks_properties_as_incomplete() {
let spec = index_spec_from_index_info(&IndexInfo {
name: "email_1".to_string(),
columns: vec!["email".to_string()],
is_unique: true,
is_primary: false,
filter: Some("{\"archived\":false}".to_string()),
index_type: Some("email: 1".to_string()),
included_columns: None,
comment: None,
key_is_expression: Vec::new(),
column_opclasses: vec![],
key_options: Vec::new(),
constraint_backed: false,
});
assert_eq!(spec.name, "email_1");
assert_eq!(spec.keys, vec![MongoIndexKey { field: "email".to_string(), direction: "1".to_string() }]);
assert!(spec.is_unique);
assert_eq!(spec.partial_filter_expression.as_deref(), Some("{\"archived\":false}"));
// The Legacy Agent cannot report these, so the flag tells the UI not to
// present the false values as though they came from the server.
assert!(!spec.properties_complete);
assert!(!spec.is_sparse);
assert_eq!(spec.expire_after_seconds, None);
}
#[test]
fn index_spec_from_index_info_falls_back_to_columns_without_an_index_type() {
let spec = index_spec_from_index_info(&IndexInfo {
name: "compound".to_string(),
columns: vec!["a".to_string(), "b".to_string()],
is_unique: false,
is_primary: false,
filter: None,
index_type: None,
included_columns: None,
comment: None,
key_is_expression: Vec::new(),
column_opclasses: vec![],
key_options: Vec::new(),
constraint_backed: false,
});
assert_eq!(
spec.keys,
vec![
MongoIndexKey { field: "a".to_string(), direction: String::new() },
MongoIndexKey { field: "b".to_string(), direction: String::new() },
]
);
}
#[test]
fn mongo_namespace_validation_preserves_whitespace_and_rejects_only_empty_names() {
assert_eq!(validate_mongo_namespace_name(" app ", "Database").unwrap(), " app ");
assert_eq!(validate_mongo_namespace_name(" users ", "Collection").unwrap(), " users ");
assert_eq!(validate_mongo_namespace_name(" ", "Collection").unwrap(), " ");
let error = validate_mongo_namespace_name("", "Collection").unwrap_err();
assert_eq!(error, "Collection name is required");
}
#[test]
fn create_user_command_preserves_roles_and_write_concern() {
let command = create_user_command(
r#"{"user":"test-db","pwd":"test-password","roles":[{"role":"readWrite","db":"db1"}]}"#,
Some(r#"{"w":"majority","wtimeout":5000}"#),
)
.unwrap();
assert_eq!(command.keys().next().map(String::as_str), Some("createUser"));
assert_eq!(command.get_str("createUser").unwrap(), "test-db");
assert_eq!(command.get_str("pwd").unwrap(), "test-password");
let roles = command.get_array("roles").unwrap();
assert_eq!(roles[0].as_document().unwrap(), &doc! { "role": "readWrite", "db": "db1" });
assert_eq!(command.get_document("writeConcern").unwrap(), &doc! { "w": "majority", "wtimeout": 5000_i32 });
}
#[test]
fn create_user_command_rejects_missing_names_and_reserved_fields() {
for user in [
r#"{"pwd":"secret","roles":[]}"#,
r#"{"user":"","pwd":"secret","roles":[]}"#,
r#"{"user":"app","createUser":"other","pwd":"secret","roles":[]}"#,
r#"{"user":"app","writeConcern":{"w":1},"pwd":"secret","roles":[]}"#,
] {
assert!(create_user_command(user, None).is_err(), "{user}");
}
assert!(create_user_command(r#"{"user":"app","pwd":"secret","roles":[]}"#, Some("true")).is_err());
}
#[test]
fn create_indexes_command_keeps_raw_options_and_generates_a_name() {
let (command, name) = create_indexes_command(
"users",
r#"{"email":1,"createdAt":-1,"content":"text"}"#,
Some(
r#"{"unique":true,"partialFilterExpression":{"verified":true},"customServerOption":{"enabled":true}}"#,
),
)
.unwrap();
assert_eq!(name, "email_1_createdAt_-1_content_text");
assert_eq!(command.get_str("createIndexes").unwrap(), "users");
let index = command.get_array("indexes").unwrap()[0].as_document().unwrap();
assert_eq!(
index.get_document("key").unwrap().keys().collect::<Vec<_>>(),
vec!["email", "createdAt", "content"],
"compound-index key order is part of MongoDB index semantics"
);
assert_eq!(
index.get_document("key").unwrap(),
&doc! { "email": 1_i32, "createdAt": -1_i32, "content": "text" }
);
assert!(index.get_bool("unique").unwrap());
assert_eq!(index.get_str("name").unwrap(), "email_1_createdAt_-1_content_text");
assert_eq!(index.get_document("customServerOption").unwrap(), &doc! { "enabled": true });
}
#[test]
fn create_indexes_command_canonicalizes_a_whole_double_in_the_default_name() {
let (command, name) = create_indexes_command("users", r#"{"email":1.0}"#, None).unwrap();
assert_eq!(name, "email_1");
let index = command.get_array("indexes").unwrap()[0].as_document().unwrap();
assert_eq!(index.get_str("name").unwrap(), "email_1");
assert!(
matches!(index.get("key"), Some(Bson::Document(keys)) if matches!(keys.get("email"), Some(Bson::Double(value)) if *value == 1.0))
);
}
#[test]
fn create_indexes_command_preserves_extended_json_options() {
let (command, name) = create_indexes_command(
"events",
r#"{"expiresAt":1}"#,
Some(r#"{"name":"expires_ttl","partialFilterExpression":{"createdAt":{"$date":"2026-06-10T13:59:31.287Z"}}}"#),
)
.unwrap();
assert_eq!(name, "expires_ttl");
let index = command.get_array("indexes").unwrap()[0].as_document().unwrap();
let filter = index.get_document("partialFilterExpression").unwrap();
assert!(matches!(filter.get("createdAt"), Some(Bson::DateTime(_))));
}
#[test]
fn create_indexes_command_keeps_plain_json_strings_literal() {
let (command, _) = create_indexes_command(
"events",
r#"{"expiresAt":1}"#,
Some(r#"{"partialFilterExpression":{"source":"ISODate(\"2026-06-10T13:59:31.287Z\")"}}"#),
)
.unwrap();
let index = command.get_array("indexes").unwrap()[0].as_document().unwrap();
let filter = index.get_document("partialFilterExpression").unwrap();
assert!(
matches!(filter.get("source"), Some(Bson::String(value)) if value == "ISODate(\"2026-06-10T13:59:31.287Z\")")
);
}
#[test]
fn create_indexes_command_rejects_key_inside_options() {
let error = create_indexes_command("users", r#"{"email":1}"#, Some(r#"{"key":{"other":1}}"#)).unwrap_err();
assert!(error.contains("cannot contain \"key\""), "{error}");
}
#[test]
fn create_indexes_command_rejects_empty_or_non_string_names() {
for options in [r#"{"name":""}"#, r#"{"name":" "}"#, r#"{"name":null}"#, r#"{"name":1}"#] {
let error = create_indexes_command("users", r#"{"email":1}"#, Some(options)).unwrap_err();
assert!(error.contains("non-empty string"), "{error}");
}
}
#[test]
fn parse_drop_indexes_value_validates_drop_index_arguments() {
assert!(matches!(
parse_drop_indexes_value(Some(r#""users_email_unique""#), true),
Ok(Bson::String(name)) if name == "users_email_unique"
));
assert!(matches!(
parse_drop_indexes_value(Some(r#"{"email":1}"#), true),
Ok(Bson::Document(doc)) if doc.get_i64("email").ok() == Some(1)
));
let wildcard = parse_drop_indexes_value(Some(r#""*""#), true).unwrap_err();
assert!(wildcard.contains("dropIndex does not accept"));
let array = parse_drop_indexes_value(Some(r#"["a_1"]"#), true).unwrap_err();
assert!(array.contains("arrays are not supported"));
let empty = parse_drop_indexes_value(None, true).unwrap_err();
assert!(empty.contains("dropIndex requires"));
}
#[test]
fn parse_drop_indexes_value_validates_drop_indexes_arguments() {
assert!(matches!(
parse_drop_indexes_value(None, false),
Ok(Bson::String(name)) if name == "*"
));
assert!(matches!(
parse_drop_indexes_value(Some(r#""*""#), false),
Ok(Bson::String(name)) if name == "*"
));
assert!(matches!(
parse_drop_indexes_value(Some(r#""users_email_unique""#), false),
Ok(Bson::String(name)) if name == "users_email_unique"
));
assert!(matches!(
parse_drop_indexes_value(Some(r#"{"email":1}"#), false),
Ok(Bson::Document(doc)) if doc.get_i64("email").ok() == Some(1)
));
assert!(matches!(
parse_drop_indexes_value(Some(r#"["a_1","b_1"]"#), false),
Ok(Bson::Array(values))
if values
== vec![Bson::String("a_1".to_string()), Bson::String("b_1".to_string())]
));
let invalid_array = parse_drop_indexes_value(Some(r#"[{"a":1}]"#), false).unwrap_err();
assert!(invalid_array.contains("arrays of string index names"));
}
#[test]
fn drop_indexes_validation_rejects_the_default_id_index() {
for (indexes_json, single) in [
(Some(r#""_id_""#), true),
(Some(r#""_id_""#), false),
(Some(r#"{"_id":1}"#), true),
(Some(r#"{"_id":1}"#), false),
(Some(r#"{"_id":1.0}"#), true),
(Some(r#"{"_id":{"$numberInt":"1"}}"#), true),
(Some(r#"{"_id":{"$numberLong":"1"}}"#), false),
(Some(r#"{"_id":{"$numberDouble":"1.0"}}"#), false),
(Some(r#"{"_id":{"$numberDecimal":"1.0"}}"#), true),
(Some(r#"["email_1","_id_"]"#), false),
] {
let error = validate_drop_indexes_request(indexes_json, single).unwrap_err();
assert!(error.contains("_id_"), "{error}");
}
// MongoDB defines dropIndexes("*") to retain the default _id_ index.
assert!(matches!(validate_drop_indexes_request(Some(r#""*""#), false), Ok(())));
}
#[test]
fn serial_drop_index_names_is_portable_and_prevalidates_all_names() {
assert_eq!(
serial_drop_index_names(Some(r#"["email_1","email_1","createdAt_-1"]"#), false).unwrap(),
Some(vec!["email_1".to_string(), "createdAt_-1".to_string()])
);
assert_eq!(serial_drop_index_names(Some(r#""*""#), false).unwrap(), None);
let error = serial_drop_index_names(Some(r#"["email_1","_id_"]"#), false).unwrap_err();
assert!(error.contains("_id_"), "{error}");
}
#[test]
fn serial_drop_indexes_fallback_is_limited_to_pre_42_servers() {
for version in ["3.4.24", "4.0.28", "MongoDB 3.6.23"] {
assert!(mongo_server_requires_serial_drop_indexes(version), "{version}");
}
for version in ["4.2.0", "4.4.29", "5.0.0-rc0", "8.0.1", "unknown", "4"] {
assert!(!mongo_server_requires_serial_drop_indexes(version), "{version}");
}
}
#[test]
fn diff_dropped_index_names_reports_removed_indexes() {
let before = vec![
IndexInfo {
name: "_id_".to_string(),
columns: vec!["_id".to_string()],
is_unique: true,
is_primary: true,
filter: None,
index_type: Some("_id: 1".to_string()),
included_columns: None,
comment: None,
key_is_expression: Vec::new(),
column_opclasses: vec![],
key_options: Vec::new(),
constraint_backed: false,
},
IndexInfo {
name: "users_email_unique".to_string(),
columns: vec!["email".to_string()],
is_unique: true,
is_primary: false,
filter: None,
index_type: Some("email: 1".to_string()),
included_columns: None,
comment: None,
key_is_expression: Vec::new(),
column_opclasses: vec![],
key_options: Vec::new(),
constraint_backed: false,
},
IndexInfo {
name: "users_status_idx".to_string(),
columns: vec!["status".to_string()],
is_unique: false,
is_primary: false,
filter: None,
index_type: Some("status: 1".to_string()),
included_columns: None,
comment: None,
key_is_expression: Vec::new(),
column_opclasses: vec![],
key_options: Vec::new(),
constraint_backed: false,
},
];
let after = vec![before[0].clone(), before[2].clone()];
assert_eq!(diff_dropped_index_names(&before, &after), vec!["users_email_unique".to_string()]);
}
#[test]
fn json_object_to_document_parses_extended_json_date() {
let value = serde_json::json!({
"created_at": { "$date": "2026-06-10T13:59:31.287Z" },
"updated_at": { "$date": { "$numberLong": "1781099971287" } }
});
let doc = json_object_to_document(&value).unwrap();
assert!(matches!(doc.get("created_at"), Some(Bson::DateTime(_))));
assert!(matches!(
doc.get("updated_at"),
Some(Bson::DateTime(value)) if value.timestamp_millis() == 1_781_099_971_287
));
}
#[test]
fn json_object_to_document_parses_extended_json_object_id() {
let value = serde_json::json!({
"_id": { "$oid": "507f1f77bcf86cd799439011" },
});
let doc = json_object_to_document(&value).unwrap();
assert!(matches!(doc.get("_id"), Some(Bson::ObjectId(oid)) if oid.to_hex() == "507f1f77bcf86cd799439011"));
}
#[test]
fn json_object_to_document_extended_json_parses_official_wrappers() {
let value = serde_json::json!({
"_id": { "$oid": "507f1f77bcf86cd799439011" },
"created_at": { "$date": "2026-06-10T13:59:31.287Z" },
"count": { "$numberLong": "42" },
});
let doc = json_object_to_document_extended_json(&value).unwrap();
assert!(matches!(doc.get("_id"), Some(Bson::ObjectId(oid)) if oid.to_hex() == "507f1f77bcf86cd799439011"));
assert!(matches!(doc.get("created_at"), Some(Bson::DateTime(_))));
assert!(matches!(doc.get("count"), Some(Bson::Int64(42))));
}
#[test]
fn json_filter_to_document_parses_extended_json_regex() {
let value = serde_json::json!({
"packagingRatio": {
"$regularExpression": {
"pattern": "^[^::]*盒",
"options": "im",
}
}
});
let doc = json_filter_to_document(&value).unwrap();
assert!(matches!(
doc.get("packagingRatio"),
Some(Bson::RegularExpression(regex))
if regex.pattern == "^[^::]*盒" && regex.options == "im"
));
}
#[test]
fn json_object_to_document_extended_json_keeps_shell_date_strings_literal() {
let value = serde_json::json!({
"date_text": "ISODate(\"2026-08-10T00:00:00.000Z\")",
"actual_date": { "$date": "2026-08-10T00:00:00.000Z" },
});
let doc = json_object_to_document_extended_json(&value).unwrap();
assert!(matches!(
doc.get("date_text"),
Some(Bson::String(value)) if value == "ISODate(\"2026-08-10T00:00:00.000Z\")"
));
assert!(matches!(doc.get("actual_date"), Some(Bson::DateTime(_))));
}
#[test]
fn parse_gridfs_file_id_accepts_extended_json_object_id() {
let id = parse_gridfs_file_id(r#"{"$oid":"507f1f77bcf86cd799439011"}"#).unwrap();
assert!(matches!(id, Bson::ObjectId(oid) if oid.to_hex() == "507f1f77bcf86cd799439011"));
}
#[test]
fn parse_gridfs_file_id_accepts_json_string_ids() {
let id = parse_gridfs_file_id(r#""report-42""#).unwrap();
assert!(matches!(id, Bson::String(value) if value == "report-42"));
}
#[test]
fn gridfs_file_id_to_string_keeps_plain_strings_unquoted() {
let id = gridfs_file_id_to_string(&Bson::String("report-42".to_string()));
assert_eq!(id, "report-42");
}
#[test]
fn gridfs_file_info_includes_navicat_style_metadata_fields() {
let info = gridfs_file_info_from_document(&doc! {
"_id": "report-42",
"filename": "report.zip",
"length": 128_i64,
"chunkSize": 255_i32,
"md5": "abc123",
"contentType": "application/zip",
"aliases": ["archive", "nightly"],
});
assert_eq!(info.id, "report-42");
assert_eq!(info.filename.as_deref(), Some("report.zip"));
assert_eq!(info.md5.as_deref(), Some("abc123"));
assert_eq!(info.content_type.as_deref(), Some("application/zip"));
assert_eq!(info.aliases, Some(vec!["archive".to_string(), "nightly".to_string()]));
}
#[test]
fn gridfs_file_sort_uses_upload_date_desc_by_default() {
assert_eq!(gridfs_file_sort_document(None).unwrap(), doc! { "uploadDate": -1_i32, "_id": -1_i32 });
}
#[test]
fn gridfs_file_sort_parses_explicit_sort_json() {
assert_eq!(gridfs_file_sort_document(Some(r#"{"filename":1}"#)).unwrap(), doc! { "filename": 1_i64 });
}
#[test]
fn json_object_to_document_parses_find_projection() {
let value = serde_json::json!({
"title": 1,
"_id": 0,
});
let doc = json_object_to_document(&value).unwrap();
assert!(matches!(doc.get("title"), Some(Bson::Int64(1))));
assert!(matches!(doc.get("_id"), Some(Bson::Int64(0))));
}
#[test]
fn server_version_from_build_info_reads_version_field() {
let version = server_version_from_build_info(&doc! { "version": "4.4.29" }).unwrap();
assert_eq!(version, "4.4.29");
}
#[test]
fn server_version_from_build_info_rejects_missing_version() {
let error = server_version_from_build_info(&doc! { "ok": 1 }).unwrap_err();
assert!(error.contains("MongoDB server version not found"));
}
#[test]
fn object_statistics_maps_collstats_fields() {
let stats = object_statistics_from_collection_stats(
"orders_10k",
"dbx_mongo_demo",
&doc! {
"count": 10000_i64,
"size": 2084944_i64,
"storageSize": 536576_i64,
"totalIndexSize": 364544_i64,
},
);
assert_eq!(stats.name, "orders_10k");
assert_eq!(stats.schema.as_deref(), Some("dbx_mongo_demo"));
assert_eq!(stats.estimated_rows, Some(10000));
// 536576 + 364544: the on-disk data + index footprint.
assert_eq!(stats.total_bytes, Some(901120));
}
#[test]
fn object_statistics_accepts_int32_collstats_fields() {
let stats = object_statistics_from_collection_stats(
"stores",
"app",
&doc! {
"count": 50_i32,
"storageSize": 20480_i32,
"totalIndexSize": 20480_i32,
},
);
assert_eq!(stats.estimated_rows, Some(50));
assert_eq!(stats.total_bytes, Some(40960));
}
#[test]
fn object_statistics_keeps_missing_fields_unknown() {
let stats = object_statistics_from_collection_stats("empty", "app", &doc! { "ok": 1_i32 });
assert_eq!(stats.estimated_rows, None);
assert_eq!(stats.total_bytes, None);
}
#[test]
fn object_statistics_reports_zero_sized_collections() {
let stats = object_statistics_from_collection_stats(
"fresh",
"app",
&doc! {
"count": 0_i32,
"storageSize": 0_i64,
"totalIndexSize": 0_i64,
},
);
assert_eq!(stats.estimated_rows, Some(0));
assert_eq!(stats.total_bytes, Some(0));
}
#[test]
fn collection_stats_result_reads_expected_fields() {
let result = collection_stats_result_from_document(&doc! {
"count": 12_i64,
"size": 4096_i64,
"avgObjSize": 341.3_f64,
"storageSize": 8192_i64,
"totalIndexSize": 2048_i64,
"nindexes": 3_i32,
});
assert_eq!(
result,
MongoCollectionStatsResult {
count: serde_json::json!(12),
size: serde_json::json!(4096),
avg_obj_size: serde_json::json!(341.3),
storage_size: serde_json::json!(8192),
total_index_size: serde_json::json!(2048),
nindexes: serde_json::json!(3),
}
);
}
#[test]
fn collection_stats_result_fills_missing_fields_with_null() {
let result = collection_stats_result_from_document(&doc! {
"count": 7_i32,
"storageSize": 512_i32,
});
assert_eq!(result.count, serde_json::json!(7));
assert_eq!(result.size, serde_json::Value::Null);
assert_eq!(result.avg_obj_size, serde_json::Value::Null);
assert_eq!(result.storage_size, serde_json::json!(512));
assert_eq!(result.total_index_size, serde_json::Value::Null);
assert_eq!(result.nindexes, serde_json::Value::Null);
}
#[test]
fn collection_stats_command_serializes_scale() {
let command = collection_stats_command("users", Some(&serde_json::Number::from(1024))).unwrap();
assert_eq!(command.get_str("collStats").unwrap(), "users");
assert!(matches!(command.get("scale"), Some(Bson::Int64(1024))));
assert!(!collection_stats_command("users", None).unwrap().contains_key("scale"));
assert!(collection_stats_command(" ", None).unwrap_err().contains("Collection name is required"));
}
#[test]
fn create_database_name_is_trimmed_and_required() {
assert_eq!(validate_create_database_name(" app "), Ok("app"));
assert!(validate_create_database_name(" ").unwrap_err().contains("Database name is required"));
}
#[test]
fn json_object_to_document_parses_mongo_shell_isodate_strings() {
let value = serde_json::json!({
"created_at": "ISODate(\"2026-06-10T13:59:31.287Z\")",
"updated_at": "new Date('2026-06-10T14:59:31.287Z')",
});
let doc = json_object_to_document(&value).unwrap();
assert!(matches!(doc.get("created_at"), Some(Bson::DateTime(_))));
assert!(matches!(doc.get("updated_at"), Some(Bson::DateTime(_))));
}
#[test]
fn json_object_to_document_preserves_date_shaped_strings() {
let value = serde_json::json!({
"$set": {
"create_time": "2025-04-01 19:46:03",
"nested": { "updated": "2025-08-14T02:25:43" },
"items": ["2025-08-14 02:25:43"],
}
});
let doc = json_object_to_document(&value).unwrap();
let set = doc.get_document("$set").unwrap();
assert_eq!(set.get_str("create_time").unwrap(), "2025-04-01 19:46:03");
assert_eq!(set.get_document("nested").unwrap().get_str("updated").unwrap(), "2025-08-14T02:25:43");
assert_eq!(set.get_array("items").unwrap()[0].as_str().unwrap(), "2025-08-14 02:25:43");
assert!(is_update_operator_document(&doc));
}
#[test]
fn detects_update_operator_documents() {
assert!(is_update_operator_document(&doc! { "$set": { "name": "Ada" } }));
assert!(is_update_operator_document(&doc! { "$set": { "name": "Ada" }, "$unset": { "old": "" } }));
assert!(!is_update_operator_document(&doc! { "name": "Ada" }));
assert!(!is_update_operator_document(&Document::new()));
}
#[test]
fn find_one_options_accept_sort_and_reject_unimplemented_fields() {
let options = parse_find_one_options(Some(r#"{"sort":{"createdAt":-1}}"#)).unwrap();
assert_eq!(options.sort, Some(serde_json::json!({ "createdAt": -1 })));
let error = parse_find_one_options(Some(r#"{"hint":{"createdAt":1}}"#)).unwrap_err();
assert!(error.contains("unknown field `hint`"));
}
#[test]
fn find_and_modify_options_reject_fields_not_applied_by_each_command() {
let update_error = parse_find_and_modify_options::<MongoFindOneAndUpdateOptions>(
Some(r#"{"hint":{"name":1}}"#),
"findOneAndUpdate",
)
.unwrap_err();
assert!(update_error.contains("unknown field `hint`"));
let replace_error = parse_find_and_modify_options::<MongoFindOneAndReplaceOptions>(
Some(r#"{"arrayFilters":[{"item.active":true}]}"#),
"findOneAndReplace",
)
.unwrap_err();
assert!(replace_error.contains("unknown field `arrayFilters`"));
let delete_error = parse_find_and_modify_options::<MongoFindOneAndDeleteOptions>(
Some(r#"{"returnDocument":"after"}"#),
"findOneAndDelete",
)
.unwrap_err();
assert!(delete_error.contains("unknown field `returnDocument`"));
}
#[test]
fn find_and_modify_return_document_rejects_invalid_values() {
assert!(find_and_modify_returns_after(Some("after"), None, None).unwrap());
assert!(!find_and_modify_returns_after(Some("before"), None, None).unwrap());
assert!(find_and_modify_returns_after(Some("newest"), None, None).is_err());
}
#[test]
fn find_and_modify_commands_mirror_the_native_options() {
let update = find_one_and_update_command(
"users",
r#"{"_id":{"$oid":"64b1f0c2a1b2c3d4e5f60718"}}"#,
r#"{"$set":{"active":true}}"#,
Some(r#"{"returnDocument":"after","upsert":true,"projection":{"active":1},"sort":{"_id":1},"arrayFilters":[{"item.active":true}]}"#),
)
.unwrap();
assert_eq!(update.get_str("findAndModify"), Ok("users"));
assert_eq!(
update.get_document("query").unwrap().get_object_id("_id").unwrap().to_hex(),
"64b1f0c2a1b2c3d4e5f60718"
);
assert_eq!(update.get_document("update"), Ok(&doc! { "$set": { "active": true } }));
assert_eq!(update.get_bool("new"), Ok(true));
assert_eq!(update.get_bool("upsert"), Ok(true));
assert_eq!(update.get_document("fields"), Ok(&doc! { "active": 1i64 }));
assert_eq!(update.get_document("sort"), Ok(&doc! { "_id": 1i64 }));
assert_eq!(update.get_array("arrayFilters").unwrap().len(), 1);
// Default return is the pre-change document, and absent options stay absent.
let minimal = find_one_and_update_command("users", "{}", r#"{"$inc":{"n":1}}"#, None).unwrap();
assert_eq!(minimal.keys().collect::<Vec<_>>(), vec!["findAndModify", "query", "update"]);
// A pipeline update is passed through as an array.
let pipeline = find_one_and_update_command("users", "{}", r#"[{"$set":{"n":1}}]"#, None).unwrap();
assert!(pipeline.get_array("update").is_ok());
let replace =
find_one_and_replace_command("users", r#"{"_id":1}"#, r#"{"name":"Grace"}"#, Some(r#"{"new":true}"#))
.unwrap();
assert_eq!(replace.get_document("update"), Ok(&doc! { "name": "Grace" }));
assert_eq!(replace.get_bool("new"), Ok(true));
assert!(!replace.contains_key("remove"));
// The server would run an operator document as an update; refuse it up front instead.
let refused = find_one_and_replace_command("users", "{}", r#"{"$set":{"name":"Grace"}}"#, None).unwrap_err();
assert!(refused.contains("must not contain update operators such as $set"), "{refused}");
let delete = find_one_and_delete_command("users", r#"{"_id":1}"#, Some(r#"{"sort":{"_id":-1}}"#)).unwrap();
assert_eq!(delete.get_bool("remove"), Ok(true));
assert_eq!(delete.get_document("sort"), Ok(&doc! { "_id": -1i64 }));
assert!(!delete.contains_key("update"));
}
#[test]
fn distinct_command_and_response_mirror_the_native_helper() {
let command = distinct_command("users", "status", Some(r#"{"age":{"$gte":18}}"#)).unwrap();
assert_eq!(command.get_str("distinct"), Ok("users"));
assert_eq!(command.get_str("key"), Ok("status"));
assert_eq!(command.get_document("query"), Ok(&doc! { "age": { "$gte": 18i64 } }));
assert_eq!(distinct_command("users", "status", None).unwrap().get_document("query"), Ok(&doc! {}));
assert!(distinct_command("users", " ", None).unwrap_err().contains("field name is required"));
assert!(distinct_command("users", "status", Some("{not json")).unwrap_err().contains("Invalid filter JSON"));
let result = distinct_response_result(doc! { "values": ["a", 2i64, Bson::Null], "ok": 1.0 }).unwrap();
assert_eq!(result.total, 3);
assert_eq!(result.documents, vec![serde_json::json!("a"), serde_json::json!(2), serde_json::Value::Null]);
assert!(distinct_response_result(doc! { "ok": 1.0 }).is_err());
}
#[test]
fn find_and_modify_response_maps_value_to_zero_or_one_document() {
let matched =
find_and_modify_response_result(&doc! { "value": { "_id": 1, "name": "Ada" }, "ok": 1.0 }).unwrap();
assert_eq!(matched.total, 1);
assert_eq!(matched.documents[0]["name"], "Ada");
let none = find_and_modify_response_result(&doc! { "value": Bson::Null, "ok": 1.0 }).unwrap();
assert_eq!(none.total, 0);
assert!(find_and_modify_response_result(&doc! { "value": "oops", "ok": 1.0 }).is_err());
}
#[test]
fn single_document_result_has_zero_or_one_metadata() {
let empty = single_document_result(None);
assert!(empty.documents.is_empty());
assert_eq!(empty.total, 0);
let one = single_document_result(Some(doc! { "_id": 1, "name": "Ada" }));
assert_eq!(one.documents.len(), 1);
assert_eq!(one.total, 1);
}
#[test]
fn json_object_to_document_preserves_unchanged_bson_date_fields() {
let date = DateTime::parse_rfc3339_str("2026-06-10T13:59:31.287Z").unwrap();
let existing = doc! {
"_id": "doc-1",
"created_at": Bson::DateTime(date),
"name": "before",
"profile": {
"last_seen": Bson::DateTime(date),
"status": "old",
},
};
let displayed = bson_to_json(&Bson::Document(existing.clone()));
let mut edited = displayed.as_object().cloned().unwrap();
edited.insert("name".to_string(), serde_json::json!("after"));
if let Some(serde_json::Value::Object(profile)) = edited.get_mut("profile") {
profile.insert("status".to_string(), serde_json::json!("new"));
}
let doc =
json_object_to_document_preserving_existing(&serde_json::Value::Object(edited), Some(&existing)).unwrap();
assert!(matches!(doc.get("created_at"), Some(Bson::DateTime(value)) if *value == date));
let Some(Bson::Document(profile)) = doc.get("profile") else {
panic!("expected profile document");
};
assert!(matches!(profile.get("last_seen"), Some(Bson::DateTime(value)) if *value == date));
assert!(matches!(profile.get("status"), Some(Bson::String(value)) if value == "new"));
}
}