fix(mongodb): support find explain commands

Closes #5619
This commit is contained in:
t8y2 2026-08-07 18:51:04 +08:00
parent bac8f4166f
commit 996ce42e80
No known key found for this signature in database
11 changed files with 544 additions and 2 deletions

View File

@ -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,

View File

@ -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);

View File

@ -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<String> calls = new ArrayList<>();

View File

@ -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<T: DeserializeOwned + Send + 'static>(
&mut self,
params: Value,
) -> Result<T, String> {
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<T: DeserializeOwned + Send + 'static>(
&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");

View File

@ -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<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,
@ -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();

View File

@ -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<serde_json::Value, String> {
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,

View File

@ -18,6 +18,17 @@ pub enum MongoCommand {
skip: u64,
limit: i64,
},
#[serde(rename = "findExplain")]
FindExplain {
collection: String,
filter: String,
projection: Option<String>,
sort: Option<String>,
collation: Option<String>,
skip: u64,
limit: i64,
verbosity: String,
},
#[serde(rename = "findOne")]
FindOne { collection: String, filter: String, projection: Option<String>, options: Option<String> },
#[serde(rename = "countDocuments")]
@ -350,7 +361,9 @@ pub fn parse(input: &str) -> Result<MongoCommand, String> {
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<MongoCommand, String> {
"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<String, String> {
value.as_str().map(ToOwned::to_owned).ok_or_else(|| "MongoDB argument must be a string.".to_string())
}
fn parse_explain_verbosity(args: &[String]) -> Result<String, String> {
if args.len() > 1 {
return Err("MongoDB explain() accepts at most one verbosity string.".to_string());
}
let verbosity = match args.first() {
Some(value) => parse_string_arg(value)?,
None => "queryPlanner".to_string(),
};
match verbosity.as_str() {
"queryPlanner" | "executionStats" | "allPlansExecution" => Ok(verbosity),
_ => Err("MongoDB explain() verbosity must be queryPlanner, executionStats, or allPlansExecution.".to_string()),
}
}
fn normalized_json(input: &str) -> Result<String, String> {
let transformed = transform_shell_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!(

View File

@ -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::<Value>()
.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::<usize>()
.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();

View File

@ -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::<u16>()
.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() {

View File

@ -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))

View File

@ -101,6 +101,22 @@ pub struct MongoFindRequest {
pub execution_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MongoFindExplainRequest {
pub connection_id: String,
pub database: String,
pub collection: String,
pub skip: Option<u64>,
pub limit: Option<i64>,
pub filter: Option<String>,
pub projection: Option<String>,
pub sort: Option<String>,
pub collation: Option<String>,
pub verbosity: Option<String>,
pub execution_id: Option<String>,
}
#[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<Arc<WebState>>,
headers: HeaderMap,
Json(req): Json<MongoFindExplainRequest>,
) -> Result<Json<serde_json::Value>, 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<Arc<WebState>>,
Json(req): Json<MongoFindOneRequest>,