feat(mongodb): find and modify over the legacy agent

This commit is contained in:
Loc Nguyen
2026-09-21 13:37:09 +08:00
committed by GitHub
parent 933c085455
commit 98d3209026
3 changed files with 367 additions and 3 deletions
+46 -3
View File
@@ -11,6 +11,35 @@ use crate::types::QueryResult;
pub const MONGO_SHOW_DATABASES_DATABASE: &str = "admin";
pub const MONGO_SHOW_DATABASES_COMMAND_JSON: &str = r#"{"listDatabases":1}"#;
/// Runs one server command through the legacy agent's generic `runCommand` and returns the
/// response document. The agent surfaces `ok: 0` as an error, so a returned document succeeded.
async fn agent_run_command_document(
client: &crate::db::agent_driver::PooledAgentClient,
database: &str,
command: mongodb::bson::Document,
label: &str,
) -> Result<mongodb::bson::Document, String> {
let mut client = client.lock().await;
if !client.supports_capability(AgentCapability::MongoRunCommand) {
return Err(format!(
"MongoDB Legacy Agent does not support {label}; upgrade or reinstall the MongoDB Legacy driver"
));
}
let result: MongoDocumentResult = client
.mongo_run_command(serde_json::json!({
"database": database,
"command_json": mongo_driver::document_to_canonical_extended_json(&command).to_string(),
}))
.await?;
let response = result
.extended_documents
.as_ref()
.and_then(|documents| documents.first())
.or_else(|| result.documents.first())
.ok_or_else(|| format!("MongoDB Legacy Agent returned an empty {label} response"))?;
mongo_driver::json_object_to_document_extended_json(response)
}
async fn ensure_document_pool(state: &AppState, connection_id: &str) -> Result<(), String> {
state.get_or_create_pool(connection_id, None).await.map(|_| ())
}
@@ -983,7 +1012,12 @@ pub async fn mongo_find_one_and_update_core(
mongo_driver::find_one_and_update(client, database, collection, filter_json, update_json, options_json)
.await
}
PoolKind::Agent(_) => Err("MongoDB legacy agent does not support findOneAndUpdate".to_string()),
PoolKind::Agent(client) => {
let command =
mongo_driver::find_one_and_update_command(collection, filter_json, update_json, options_json)?;
let response = agent_run_command_document(client, database, command, "findOneAndUpdate").await?;
mongo_driver::find_and_modify_response_result(&response)
}
_ => Err("Not a MongoDB connection".to_string()),
}
}
@@ -1011,7 +1045,12 @@ pub async fn mongo_find_one_and_replace_core(
)
.await
}
PoolKind::Agent(_) => Err("MongoDB legacy agent does not support findOneAndReplace".to_string()),
PoolKind::Agent(client) => {
let command =
mongo_driver::find_one_and_replace_command(collection, filter_json, replacement_json, options_json)?;
let response = agent_run_command_document(client, database, command, "findOneAndReplace").await?;
mongo_driver::find_and_modify_response_result(&response)
}
_ => Err("Not a MongoDB connection".to_string()),
}
}
@@ -1030,7 +1069,11 @@ pub async fn mongo_find_one_and_delete_core(
PoolKind::MongoDb(client) => {
mongo_driver::find_one_and_delete(client, database, collection, filter_json, options_json).await
}
PoolKind::Agent(_) => Err("MongoDB legacy agent does not support findOneAndDelete".to_string()),
PoolKind::Agent(client) => {
let command = mongo_driver::find_one_and_delete_command(collection, filter_json, options_json)?;
let response = agent_run_command_document(client, database, command, "findOneAndDelete").await?;
mongo_driver::find_and_modify_response_result(&response)
}
_ => Err("Not a MongoDB connection".to_string()),
}
}
@@ -0,0 +1,154 @@
//! `findOneAndUpdate` / `findOneAndReplace` / `findOneAndDelete` over the MongoDB Legacy Agent,
//! driven through the same parse → dispatch path the query tab uses.
use dbx_core::{
connection::{AppState, PoolKind},
models::connection::ConnectionConfig,
mongo_ops::{execute_mongo_command_core, mongo_run_command_core},
mongo_shell,
storage::Storage,
};
use mongodb::bson::{doc, Document};
async fn command(state: &AppState, id: &str, database: &str, command: Document) -> Document {
let json = mongodb::bson::Bson::Document(command).into_canonical_extjson().to_string();
let result = mongo_run_command_core(state, id, database, &json).await.unwrap();
dbx_core::db::mongo_driver::json_object_to_document_extended_json(&result.extended_documents.unwrap()[0]).unwrap()
}
/// `runCommand` replies come back through the agent as relaxed Extended JSON, so integers lose
/// their width; compare numerically.
fn number(document: &Document, key: &str) -> Option<i64> {
match document.get(key)? {
mongodb::bson::Bson::Int32(n) => Some(i64::from(*n)),
mongodb::bson::Bson::Int64(n) => Some(*n),
mongodb::bson::Bson::Double(n) => Some(*n as i64),
_ => None,
}
}
async fn find_all(state: &AppState, id: &str, database: &str, collection: &str) -> Vec<Document> {
let response = command(state, id, database, doc! { "find": collection, "sort": { "_id": 1 } }).await;
response
.get_document("cursor")
.unwrap()
.get_array("firstBatch")
.unwrap()
.iter()
.map(|b| b.as_document().unwrap().clone())
.collect()
}
async fn shell(state: &AppState, id: &str, database: &str, text: &str) -> Result<dbx_core::types::QueryResult, String> {
let parsed = mongo_shell::parse(text)?;
execute_mongo_command_core(state, id, database, &parsed, 100).await
}
#[tokio::test]
#[ignore = "opt-in: DBX_MONGO_LEGACY_DUMP_TEST_HOST (host:port, MongoDB 3.6+ without auth) and an installed MongoDB Legacy Agent; creates a temporary database"]
async fn find_and_modify_commands_run_over_the_legacy_agent() {
let endpoint = std::env::var("DBX_MONGO_LEGACY_DUMP_TEST_HOST").expect("DBX_MONGO_LEGACY_DUMP_TEST_HOST");
let (host, port) = endpoint.split_once(':').expect("host:port");
let files = tempfile::tempdir().unwrap();
let database = format!("dbx_legacy_fam_{}", uuid::Uuid::new_v4().simple());
let state = AppState::new(Storage::open(&files.path().join("storage.db")).await.unwrap());
let id = "legacy-find-and-modify-test";
let config: ConnectionConfig = serde_json::from_value(serde_json::json!({ "id": id, "name": "Legacy findAndModify test", "db_type": "mongodb", "host": host, "port": port.parse::<u16>().unwrap(), "username": "", "password": "", "database": database, "driver_profile": "mongodb-legacy" })).unwrap();
state.configs.write().await.insert(id.into(), config);
let key = state.get_or_create_pool(id, Some(&database)).await.unwrap();
assert!(matches!(state.pool_handle(&key).await, Some(PoolKind::Agent(_))), "test must run over the legacy agent");
command(&state, id, &database, doc! { "insert": "users", "documents": [
{ "_id": 1, "name": "Ada", "score": 10i64, "tags": [ { "k": "a", "on": false }, { "k": "b", "on": false } ] },
{ "_id": 2, "name": "Grace", "score": 20i64 },
] }).await;
// findOneAndUpdate: default returns the document before the change.
let before = shell(&state, id, &database, "db.users.findOneAndUpdate({_id: 1}, {$inc: {score: 5}})").await.unwrap();
assert_eq!(before.rows.len(), 1, "{before:?}");
assert!(format!("{:?}", before.rows[0]).contains("10"), "pre-change score expected: {:?}", before.rows[0]);
// …and with returnDocument: 'after', sort and projection: sort {_id: -1} must pick Grace.
let after = shell(
&state,
id,
&database,
r#"db.users.findOneAndUpdate({}, {$set: {level: 2}}, {sort: {_id: -1}, returnDocument: "after", projection: {_id: 1, name: 1, level: 1}})"#,
)
.await
.unwrap();
assert_eq!(after.rows.len(), 1);
let shown = format!("{:?}", after.rows[0]);
assert!(shown.contains("Grace") && shown.contains("2") && !shown.contains("score"), "{shown}");
let docs = find_all(&state, id, &database, "users").await;
assert_eq!(number(&docs[0], "score"), Some(15));
assert!(!docs[0].contains_key("level"), "sort honoured: Ada untouched");
assert_eq!(number(&docs[1], "level"), Some(2));
// arrayFilters must flip only tag "a".
shell(
&state,
id,
&database,
r#"db.users.findOneAndUpdate({_id: 1}, {$set: {"tags.$[t].on": true}}, {arrayFilters: [{"t.k": "a"}]})"#,
)
.await
.unwrap();
let ada = &find_all(&state, id, &database, "users").await[0];
assert_eq!(ada.get_array("tags").unwrap()[0].as_document().unwrap().get_bool("on"), Ok(true));
assert_eq!(ada.get_array("tags").unwrap()[1].as_document().unwrap().get_bool("on"), Ok(false));
// A server-side failure comes back as the server's own message.
let server_error = shell(
&state,
id,
&database,
r#"db.users.findOneAndUpdate({_id: 2}, {$set: {"tags.$[t].on": true}}, {arrayFilters: [{"t.k": "a"}]})"#,
)
.await
.unwrap_err();
assert!(server_error.contains("must exist in the document"), "{server_error}");
// upsert creates when nothing matches.
let upserted = shell(
&state,
id,
&database,
r#"db.users.findOneAndUpdate({_id: 3}, {$set: {name: "Linus"}}, {upsert: true, new: true})"#,
)
.await
.unwrap();
assert_eq!(upserted.rows.len(), 1);
assert_eq!(find_all(&state, id, &database, "users").await.len(), 3);
// No match, no upsert: empty result, not an error.
let missing = shell(&state, id, &database, "db.users.findOneAndUpdate({_id: 99}, {$set: {x: 1}})").await.unwrap();
assert_eq!(missing.rows.len(), 0);
// findOneAndReplace replaces the whole document.
let replaced = shell(
&state,
id,
&database,
r#"db.users.findOneAndReplace({_id: 2}, {name: "Grace H", level: 1}, {returnDocument: "after"})"#,
)
.await
.unwrap();
assert_eq!(replaced.rows.len(), 1);
let grace = &find_all(&state, id, &database, "users").await[1];
assert_eq!(grace.get_str("name"), Ok("Grace H"));
assert!(!grace.contains_key("score"), "replacement drops old fields: {grace}");
// A replacement carrying update operators is refused before reaching the server.
let refused =
shell(&state, id, &database, r#"db.users.findOneAndReplace({_id: 2}, {$set: {name: "x"}})"#).await.unwrap_err();
assert!(refused.contains("update operators"), "{refused}");
// findOneAndDelete removes and returns the matched document (with projection and sort).
let deleted =
shell(&state, id, &database, r#"db.users.findOneAndDelete({}, {sort: {_id: -1}, projection: {name: 1}})"#)
.await
.unwrap();
assert_eq!(deleted.rows.len(), 1);
assert!(format!("{:?}", deleted.rows[0]).contains("Linus"), "highest _id deleted first: {:?}", deleted.rows[0]);
assert_eq!(find_all(&state, id, &database, "users").await.len(), 2);
command(&state, id, &database, doc! { "dropDatabase": 1 }).await;
}
+167
View File
@@ -2496,6 +2496,116 @@ fn single_document_result(document: Option<Document>) -> MongoDocumentResult {
}
}
/// 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,
@@ -2546,6 +2656,7 @@ pub async fn find_one_and_replace(
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);
@@ -4939,6 +5050,62 @@ mod tests {
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 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);