From 996ce42e80387bba4b33a2bf1713f590ef79d476 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 7 Aug 2026 18:51:04 +0800 Subject: [PATCH] fix(mongodb): support find explain commands Closes #5619 --- .../java/com/dbx/agent/AgentProtocol.java | 2 + .../com/dbx/agent/mongodb/MongoAgent.java | 49 ++++++++ .../com/dbx/agent/mongodb/MongoAgentTest.java | 36 ++++++ crates/dbx-core/src/db/agent_driver.rs | 13 +- crates/dbx-core/src/db/mongo_driver.rs | 95 ++++++++++++++ crates/dbx-core/src/mongo_ops.rs | 61 +++++++++ crates/dbx-core/src/mongo_shell.rs | 79 +++++++++++- crates/dbx-mcp/src/backend.rs | 116 ++++++++++++++++++ crates/dbx-mcp/tests/local.rs | 50 ++++++++ crates/dbx-web/src/main.rs | 1 + crates/dbx-web/src/routes/mongo.rs | 44 +++++++ 11 files changed, 544 insertions(+), 2 deletions(-) diff --git a/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java b/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java index b29ad89d2..5c2a20dd1 100644 --- a/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java +++ b/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java @@ -49,6 +49,7 @@ public final class AgentProtocol { public static final String MONGO_METHOD_LIST_COLLECTIONS = "list_collections"; public static final String MONGO_METHOD_FIND_DOCUMENTS = "find_documents"; public static final String MONGO_METHOD_FIND_ONE = "find_one"; + public static final String MONGO_METHOD_EXPLAIN_FIND = "explain_find"; /** * MongoDB read path that returns documents as relaxed Extended JSON for transfer. */ @@ -235,6 +236,7 @@ public final class AgentProtocol { MONGO_METHOD_LIST_COLLECTIONS, MONGO_METHOD_FIND_DOCUMENTS, MONGO_METHOD_FIND_ONE, + MONGO_METHOD_EXPLAIN_FIND, MONGO_METHOD_FIND_DOCUMENTS_EXTENDED_JSON, MONGO_METHOD_COUNT_DOCUMENTS, MONGO_METHOD_SERVER_VERSION, diff --git a/agents/drivers/mongodb/src/main/java/com/dbx/agent/mongodb/MongoAgent.java b/agents/drivers/mongodb/src/main/java/com/dbx/agent/mongodb/MongoAgent.java index 154c50986..3f9f21e3a 100644 --- a/agents/drivers/mongodb/src/main/java/com/dbx/agent/mongodb/MongoAgent.java +++ b/agents/drivers/mongodb/src/main/java/com/dbx/agent/mongodb/MongoAgent.java @@ -455,6 +455,54 @@ public final class MongoAgent { return documentQueryResult(documents, total); } + private static Object explainFind(JsonObject params) { + MongoClient c = requireClient(); + String database = params.get("database").getAsString(); + Document result = c.getDatabase(database).runCommand(buildFindExplainCommand(params)); + return bsonToExtendedJson(result); + } + + static Document buildFindExplainCommand(JsonObject params) { + String collection = params.get("collection").getAsString(); + Document find = new Document("find", collection); + Document filter = documentOrNull(params, "filter"); + find.append("filter", filter == null ? new Document() : filter); + + Document projection = documentOrNull(params, "projection"); + if (projection != null) { + find.append("projection", projection); + } + Document sort = documentOrNull(params, "sort"); + if (sort != null) { + find.append("sort", sort); + } + Document collation = documentOrNull(params, "collation"); + if (collation != null) { + collationOrNull(collation); + find.append("collation", collation); + } + + long skip = params.has("skip") ? params.get("skip").getAsLong() : 0; + if (skip > 0) { + find.append("skip", skip); + } + long limit = params.has("limit") ? params.get("limit").getAsLong() : 0; + if (limit > 0) { + find.append("limit", limit); + } + return new Document("explain", find) + .append("verbosity", findExplainVerbosity(params)); + } + + private static String findExplainVerbosity(JsonObject params) { + String verbosity = defaultString(stringOrNull(params, "verbosity"), "queryPlanner"); + if (!Set.of("queryPlanner", "executionStats", "allPlansExecution").contains(verbosity)) { + throw new IllegalArgumentException( + "MongoDB explain verbosity must be queryPlanner, executionStats, or allPlansExecution"); + } + return verbosity; + } + private static Object findOne(JsonObject params) { MongoClient c = requireClient(); String database = params.get("database").getAsString(); @@ -1294,6 +1342,7 @@ public final class MongoAgent { case AgentProtocol.METHOD_LIST_INDEXES -> listIndexes(params); case AgentProtocol.MONGO_METHOD_FIND_DOCUMENTS -> findDocuments(params); case AgentProtocol.MONGO_METHOD_FIND_ONE -> findOne(params); + case AgentProtocol.MONGO_METHOD_EXPLAIN_FIND -> explainFind(params); case AgentProtocol.MONGO_METHOD_FIND_DOCUMENTS_EXTENDED_JSON -> findDocumentsExtendedJson(params); case AgentProtocol.MONGO_METHOD_COUNT_DOCUMENTS -> countDocuments(params); case AgentProtocol.MONGO_METHOD_SERVER_VERSION -> serverVersion(params); diff --git a/agents/drivers/mongodb/src/test/java/com/dbx/agent/mongodb/MongoAgentTest.java b/agents/drivers/mongodb/src/test/java/com/dbx/agent/mongodb/MongoAgentTest.java index 1f66fa1a1..7f7bcdc20 100644 --- a/agents/drivers/mongodb/src/test/java/com/dbx/agent/mongodb/MongoAgentTest.java +++ b/agents/drivers/mongodb/src/test/java/com/dbx/agent/mongodb/MongoAgentTest.java @@ -172,6 +172,42 @@ class MongoAgentTest { assertFalse(json.getAsJsonObject("error").get("message").getAsString().contains("Unknown method")); } + @Test + void explainFindBuildsOneCommandWithFindOptions() { + JsonObject params = JsonParser.parseString( + "{\"database\":\"app\",\"collection\":\"orders\"," + + "\"filter\":\"{\\\"status\\\":\\\"open\\\"}\"," + + "\"projection\":\"{\\\"email\\\":1}\"," + + "\"sort\":\"{\\\"createdAt\\\":-1}\"," + + "\"collation\":\"{\\\"locale\\\":\\\"en\\\",\\\"strength\\\":1}\"," + + "\"skip\":2,\"limit\":5,\"verbosity\":\"executionStats\"}" + ).getAsJsonObject(); + + Document command = MongoAgent.buildFindExplainCommand(params); + Document find = command.get("explain", Document.class); + + assertEquals("orders", find.getString("find")); + assertEquals(new Document("status", "open"), find.get("filter")); + assertEquals(new Document("email", 1), find.get("projection")); + assertEquals(new Document("createdAt", -1), find.get("sort")); + assertEquals(new Document("locale", "en").append("strength", 1), find.get("collation")); + assertEquals(2L, find.getLong("skip")); + assertEquals(5L, find.getLong("limit")); + assertEquals("executionStats", command.getString("verbosity")); + } + + @Test + void explainFindMethodIsRecognizedOverJsonRpc() { + String response = MongoAgent.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":19,\"method\":\"explain_find\"," + + "\"params\":{\"database\":\"app\",\"collection\":\"orders\"}}" + ); + + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + assertEquals("Not connected", error.get("message").getAsString()); + assertFalse(error.get("message").getAsString().contains("Unknown method")); + } + @Test void findOneUsesOneBoundedReadWithoutCounting() { List calls = new ArrayList<>(); diff --git a/crates/dbx-core/src/db/agent_driver.rs b/crates/dbx-core/src/db/agent_driver.rs index 6d06344ad..7fc7a5aa2 100644 --- a/crates/dbx-core/src/db/agent_driver.rs +++ b/crates/dbx-core/src/db/agent_driver.rs @@ -1273,6 +1273,7 @@ pub enum MongoAgentMethod { ListCollections, FindDocuments, FindOne, + ExplainFind, FindDocumentsExtendedJson, CountDocuments, ServerVersion, @@ -1288,11 +1289,12 @@ pub enum MongoAgentMethod { } impl MongoAgentMethod { - pub const ALL: [Self; 16] = [ + pub const ALL: [Self; 17] = [ Self::ListDatabases, Self::ListCollections, Self::FindDocuments, Self::FindOne, + Self::ExplainFind, Self::FindDocumentsExtendedJson, Self::CountDocuments, Self::ServerVersion, @@ -1313,6 +1315,7 @@ impl MongoAgentMethod { Self::ListCollections => "list_collections", Self::FindDocuments => "find_documents", Self::FindOne => "find_one", + Self::ExplainFind => "explain_find", Self::FindDocumentsExtendedJson => "find_documents_extended_json", Self::CountDocuments => "count_documents", Self::ServerVersion => "server_version", @@ -2471,6 +2474,13 @@ impl AgentDriverClient { self.call_mongo_method(MongoAgentMethod::FindOne, params).await } + pub async fn mongo_explain_find( + &mut self, + params: Value, + ) -> Result { + self.call_mongo_method(MongoAgentMethod::ExplainFind, params).await + } + /// Calls the Mongo agent read method that returns MongoDB relaxed Extended JSON. pub async fn mongo_find_documents_extended_json( &mut self, @@ -4322,6 +4332,7 @@ for line in sys.stdin: assert_eq!(MongoAgentMethod::ListCollections.as_str(), "list_collections"); assert_eq!(MongoAgentMethod::FindDocuments.as_str(), "find_documents"); assert_eq!(MongoAgentMethod::FindOne.as_str(), "find_one"); + assert_eq!(MongoAgentMethod::ExplainFind.as_str(), "explain_find"); assert_eq!(MongoAgentMethod::FindDocumentsExtendedJson.as_str(), "find_documents_extended_json"); assert_eq!(MongoAgentMethod::CountDocuments.as_str(), "count_documents"); assert_eq!(MongoAgentMethod::ServerVersion.as_str(), "server_version"); diff --git a/crates/dbx-core/src/db/mongo_driver.rs b/crates/dbx-core/src/db/mongo_driver.rs index 583c68cb5..baa9c2e34 100644 --- a/crates/dbx-core/src/db/mongo_driver.rs +++ b/crates/dbx-core/src/db/mongo_driver.rs @@ -732,6 +732,70 @@ pub async fn find_documents_without_total( .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 { + 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 { + 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, @@ -2240,6 +2304,37 @@ mod tests { .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_eq!(find.get_document("filter").unwrap().get_bool("active").unwrap(), true); + 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(); diff --git a/crates/dbx-core/src/mongo_ops.rs b/crates/dbx-core/src/mongo_ops.rs index 891e92dcf..7a3dd2abd 100644 --- a/crates/dbx-core/src/mongo_ops.rs +++ b/crates/dbx-core/src/mongo_ops.rs @@ -232,6 +232,49 @@ pub async fn mongo_find_one_core( } } +#[allow(clippy::too_many_arguments)] +pub async fn mongo_explain_find_core( + state: &AppState, + connection_id: &str, + database: &str, + collection: &str, + skip: u64, + limit: i64, + filter: Option<&str>, + projection: Option<&str>, + sort: Option<&str>, + collation: Option<&str>, + verbosity: &str, +) -> Result { + ensure_document_pool(state, connection_id).await?; + let connections = state.connections.read().await; + match connections.get(connection_id).ok_or("Not found")? { + PoolKind::MongoDb(client) => { + mongo_driver::explain_find( + client, database, collection, skip, limit, filter, projection, sort, collation, verbosity, + ) + .await + } + PoolKind::Agent(client) => { + let mut client = client.lock().await; + client + .mongo_explain_find(serde_json::json!({ + "database": database, + "collection": collection, + "skip": skip, + "limit": limit, + "filter": filter, + "projection": projection, + "sort": sort, + "collation": collation, + "verbosity": verbosity, + })) + .await + } + _ => Err("Not a MongoDB connection".to_string()), + } +} + pub async fn mongo_count_documents_core( state: &AppState, connection_id: &str, @@ -712,6 +755,24 @@ pub async fn execute_mongo_command_core( .await?; Ok(mongo_documents_query_result(result.documents)) } + MongoCommand::FindExplain { collection, filter, projection, sort, collation, skip, limit, verbosity } => { + let limit = bounded_mongo_find_limit(*limit, max_rows); + let plan = mongo_explain_find_core( + state, + connection_id, + database, + collection, + *skip, + limit, + Some(filter), + projection.as_deref(), + sort.as_deref(), + collation.as_deref(), + verbosity, + ) + .await?; + Ok(mongo_documents_query_result(vec![plan])) + } MongoCommand::FindOne { collection, filter, projection, options } => { let result = mongo_find_one_core( state, diff --git a/crates/dbx-core/src/mongo_shell.rs b/crates/dbx-core/src/mongo_shell.rs index 5cd5ff1cc..8bbcccb3b 100644 --- a/crates/dbx-core/src/mongo_shell.rs +++ b/crates/dbx-core/src/mongo_shell.rs @@ -18,6 +18,17 @@ pub enum MongoCommand { skip: u64, limit: i64, }, + #[serde(rename = "findExplain")] + FindExplain { + collection: String, + filter: String, + projection: Option, + sort: Option, + collation: Option, + skip: u64, + limit: i64, + verbosity: String, + }, #[serde(rename = "findOne")] FindOne { collection: String, filter: String, projection: Option, options: Option }, #[serde(rename = "countDocuments")] @@ -350,7 +361,9 @@ pub fn parse(input: &str) -> Result { let mut collation = None; let mut skip = 0; let mut limit = 100; - for (name, call_args) in chained_calls(&tail)? { + let calls = chained_calls(&tail)?; + let call_count = calls.len(); + for (index, (name, call_args)) in calls.into_iter().enumerate() { match name.as_str() { "sort" => sort = Some(normalized_json(call_args.first().map(String::as_str).unwrap_or("{}"))?), "collation" => { @@ -364,6 +377,21 @@ pub fn parse(input: &str) -> Result { "count" if call_args.is_empty() => { return Ok(MongoCommand::Count { collection, filter, accurate: false }); } + "explain" => { + if index + 1 != call_count { + return Err("MongoDB explain() must be the final find() chain operation.".to_string()); + } + return Ok(MongoCommand::FindExplain { + collection, + filter, + projection, + sort, + collation, + skip, + limit, + verbosity: parse_explain_verbosity(&call_args)?, + }); + } _ => return Err(format!("Unsupported MongoDB find() chain: {name}()")), } } @@ -656,6 +684,20 @@ fn parse_string_arg(arg: &str) -> Result { value.as_str().map(ToOwned::to_owned).ok_or_else(|| "MongoDB argument must be a string.".to_string()) } +fn parse_explain_verbosity(args: &[String]) -> Result { + if args.len() > 1 { + return Err("MongoDB explain() accepts at most one verbosity string.".to_string()); + } + let verbosity = match args.first() { + Some(value) => parse_string_arg(value)?, + None => "queryPlanner".to_string(), + }; + match verbosity.as_str() { + "queryPlanner" | "executionStats" | "allPlansExecution" => Ok(verbosity), + _ => Err("MongoDB explain() verbosity must be queryPlanner, executionStats, or allPlansExecution.".to_string()), + } +} + fn normalized_json(input: &str) -> Result { let transformed = transform_shell_constructors(input.trim())?; let value: Value = @@ -860,6 +902,41 @@ mod tests { ); } + #[test] + fn parses_find_explain_with_query_options_and_verbosity() { + let command = parse( + r#"db.im_msg.find({active: true}, {email: 1}).sort({email: 1}).collation({locale: "en", strength: 1}).skip(2).limit(5).explain("executionStats")"#, + ) + .unwrap(); + + assert_eq!( + serde_json::to_value(command).unwrap(), + serde_json::json!({ + "kind": "findExplain", + "collection": "im_msg", + "filter": "{\"active\":true}", + "projection": "{\"email\":1}", + "sort": "{\"email\":1}", + "collation": "{\"locale\":\"en\",\"strength\":1}", + "skip": 2, + "limit": 5, + "verbosity": "executionStats" + }) + ); + } + + #[test] + fn find_explain_defaults_and_validates_verbosity() { + let default = serde_json::to_value(parse("db.items.find({}).explain()").unwrap()).unwrap(); + assert_eq!(default["verbosity"], "queryPlanner"); + + let all_plans = serde_json::to_value(parse("db.items.find({}).explain('allPlansExecution')").unwrap()).unwrap(); + assert_eq!(all_plans["verbosity"], "allPlansExecution"); + + assert!(parse("db.items.find({}).explain('invalid')").unwrap_err().contains("verbosity")); + assert!(parse("db.items.find({}).explain('executionStats').limit(1)").unwrap_err().contains("final")); + } + #[test] fn parses_get_collection_and_count() { assert_eq!( diff --git a/crates/dbx-mcp/src/backend.rs b/crates/dbx-mcp/src/backend.rs index 443a54ff4..faae38c1d 100644 --- a/crates/dbx-mcp/src/backend.rs +++ b/crates/dbx-mcp/src/backend.rs @@ -881,6 +881,30 @@ impl DbxBackend for WebBackend { .map_err(|error| format!("Invalid MongoDB find response: {error}"))?; Ok(mongo_documents_query_result(result.documents)) } + MongoCommand::FindExplain { collection, filter, projection, sort, collation, skip, limit, verbosity } => { + let result = self + .request( + reqwest::Method::POST, + "/api/mongo/explain-find", + Some(json!({ + "connectionId": connection_id, + "database": database, + "collection": collection, + "skip": skip, + "limit": limit, + "filter": filter, + "projection": projection, + "sort": sort, + "collation": collation, + "verbosity": verbosity, + })), + ) + .await? + .json::() + .await + .map_err(|error| format!("Invalid MongoDB explain response: {error}"))?; + Ok(mongo_documents_query_result(vec![result])) + } MongoCommand::FindOne { collection, filter, projection, options } => { let result = self .request( @@ -1590,6 +1614,98 @@ mod tests { assert_eq!(result.affected_rows, 1); } + #[tokio::test] + async fn web_mongo_find_explain_uses_explain_endpoint_and_preserves_options() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (request_sender, request_receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + let header_end = loop { + let count = stream.read(&mut buffer).unwrap(); + request.extend_from_slice(&buffer[..count]); + if let Some(position) = request.windows(4).position(|window| window == b"\r\n\r\n") { + break position + 4; + } + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length").then_some(value.trim()) + }) + .unwrap() + .parse::() + .unwrap(); + while request.len() < header_end + content_length { + let count = stream.read(&mut buffer).unwrap(); + request.extend_from_slice(&buffer[..count]); + } + let request = String::from_utf8(request).unwrap(); + let body = request[header_end..header_end + content_length].to_string(); + request_sender.send((request.lines().next().unwrap().to_string(), body)).unwrap(); + + let response_body = r#"{"queryPlanner":{"winningPlan":{"stage":"COLLSCAN"}}}"#; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response_body.len(), + response_body + ) + .unwrap(); + }); + + let backend = WebBackend::new(format!("http://{address}"), String::new()).unwrap(); + backend.auth.lock().await.checked = true; + let connection = new_connection_config( + "legacy".to_string(), + "Legacy MongoDB".to_string(), + DatabaseType::MongoDb, + "localhost".to_string(), + 27017, + String::new(), + String::new(), + Some("app".to_string()), + false, + Some("mongodb-legacy".to_string()), + ) + .unwrap(); + backend.connected.lock().await.insert(connection.id.clone(), connection.clone()); + + let command = MongoCommand::FindExplain { + collection: "im_msg".to_string(), + filter: r#"{"active":true}"#.to_string(), + projection: Some(r#"{"email":1}"#.to_string()), + sort: Some(r#"{"email":1}"#.to_string()), + collation: Some(r#"{"locale":"en","strength":1}"#.to_string()), + skip: 2, + limit: 5, + verbosity: "executionStats".to_string(), + }; + let result = backend.execute_mongo_command(&connection, "app", &command).await.unwrap(); + + server.join().unwrap(); + let (request_line, body) = request_receiver.recv().unwrap(); + assert_eq!(request_line, "POST /api/mongo/explain-find HTTP/1.1"); + let request: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(request["connectionId"], "legacy"); + assert_eq!(request["database"], "app"); + assert_eq!(request["collection"], "im_msg"); + assert_eq!(request["skip"], 2); + assert_eq!(request["limit"], 5); + assert_eq!(request["filter"], r#"{"active":true}"#); + assert_eq!(request["projection"], r#"{"email":1}"#); + assert_eq!(request["sort"], r#"{"email":1}"#); + assert_eq!(request["collation"], r#"{"locale":"en","strength":1}"#); + assert_eq!(request["verbosity"], "executionStats"); + assert_eq!(result.columns, ["queryPlanner"]); + assert_eq!(result.rows.len(), 1); + } + #[tokio::test] async fn local_backend_uses_desktop_plugin_directory() { let data_dir = tempfile::tempdir().unwrap(); diff --git a/crates/dbx-mcp/tests/local.rs b/crates/dbx-mcp/tests/local.rs index dfd3fa667..1e2f76c32 100644 --- a/crates/dbx-mcp/tests/local.rs +++ b/crates/dbx-mcp/tests/local.rs @@ -300,6 +300,56 @@ async fn executes_legacy_mongo_get_indexes_without_desktop_process() { server_task.abort(); } +#[tokio::test] +#[ignore = "requires DBX_MCP_TEST_MONGO_HOST and DBX_MCP_TEST_MONGO_PORT pointing at MongoDB 4.0+"] +async fn executes_legacy_mongo_find_explain_without_desktop_process() { + let host = std::env::var("DBX_MCP_TEST_MONGO_HOST").expect("MongoDB host"); + let port = std::env::var("DBX_MCP_TEST_MONGO_PORT") + .unwrap_or_else(|_| "27017".to_string()) + .parse::() + .expect("MongoDB port"); + let collection = std::env::var("DBX_MCP_TEST_MONGO_COLLECTION").unwrap_or_else(|_| "im_msg".to_string()); + let directory = tempdir().expect("temporary data directory"); + let db_path = directory.path().join("dbx.db"); + let storage = Storage::open(&db_path).await.expect("open storage"); + let connection: ConnectionConfig = serde_json::from_value(json!({ + "id": "mongo-e2e", + "name": "mongo-legacy-e2e", + "db_type": "mongodb", + "driver_profile": "mongodb-legacy", + "host": host, + "port": port, + "username": "", + "password": "", + "database": "dbx_mcp_test", + "ssl": false + })) + .expect("MongoDB Legacy connection config"); + storage.save_connections(&[connection]).await.expect("save connection"); + + let backend = Arc::new(LocalBackend::open(&db_path).await.expect("open local backend")); + let server = DbxMcpServer::with_runtime_options(backend, McpScope::default(), false); + let (server_transport, client_transport) = tokio::io::duplex(32 * 1024); + let server_task = tokio::spawn(async move { server.serve(server_transport).await }); + let client = ().serve(client_transport).await.expect("initialize client"); + + let planner = + call_query(&client, &format!("db.{collection}.find({{active: true}}).sort({{email: 1}}).limit(1).explain()")) + .await; + assert!(planner.contains("queryPlanner"), "unexpected MongoDB explain result: {planner}"); + + let result = call_query( + &client, + &format!("db.{collection}.find({{active: true}}).sort({{email: 1}}).limit(1).explain(\"executionStats\")"), + ) + .await; + assert!(result.contains("queryPlanner"), "unexpected MongoDB explain result: {result}"); + assert!(result.contains("executionStats"), "unexpected MongoDB explain result: {result}"); + + client.cancel().await.expect("close client"); + server_task.abort(); +} + #[test] #[cfg(feature = "mq-admin")] fn mcp_default_features_include_message_queue_admin() { diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index f133cb97e..44c2c3798 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -623,6 +623,7 @@ async fn main() { .route("/document-store/delete-document", post(routes::document_store::delete_document)) .route("/mongo/find-documents", post(routes::mongo::find_documents)) .route("/mongo/parse-shell-command", post(routes::mongo::parse_shell_command)) + .route("/mongo/explain-find", post(routes::mongo::explain_find)) .route("/mongo/find-one", post(routes::mongo::find_one)) .route("/mongo/count-documents", post(routes::mongo::count_documents)) .route("/mongo/server-version", post(routes::mongo::server_version)) diff --git a/crates/dbx-web/src/routes/mongo.rs b/crates/dbx-web/src/routes/mongo.rs index 87431dddc..b509dafb7 100644 --- a/crates/dbx-web/src/routes/mongo.rs +++ b/crates/dbx-web/src/routes/mongo.rs @@ -101,6 +101,22 @@ pub struct MongoFindRequest { pub execution_id: Option, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MongoFindExplainRequest { + pub connection_id: String, + pub database: String, + pub collection: String, + pub skip: Option, + pub limit: Option, + pub filter: Option, + pub projection: Option, + pub sort: Option, + pub collation: Option, + pub verbosity: Option, + pub execution_id: Option, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct MongoFindOneRequest { @@ -409,6 +425,34 @@ pub async fn find_documents( Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } +pub async fn explain_find( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + super::mcp_policy::ensure_scope(&state, &headers, &req.connection_id).await?; + let verbosity = req.verbosity.as_deref().unwrap_or("queryPlanner"); + let result = run_cancellable( + &state, + req.execution_id.clone(), + dbx_core::mongo_ops::mongo_explain_find_core( + &state.app, + &req.connection_id, + &req.database, + &req.collection, + req.skip.unwrap_or(0), + req.limit.unwrap_or(100), + req.filter.as_deref(), + req.projection.as_deref(), + req.sort.as_deref(), + req.collation.as_deref(), + verbosity, + ), + ) + .await?; + Ok(Json(result)) +} + pub async fn find_one( State(state): State>, Json(req): Json,