parent
bac8f4166f
commit
996ce42e80
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<>();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
Loading…
Reference in New Issue