feat(mongodb): 支持聚合查询、批量写入和 MCP 集成

- 桌面端:解析 db.<集合>.aggregate([...]) shell 语法,新增 mongoAggregateDocuments
  接口(Tauri + HTTP);mongo 入口统一补 ensureConnected;ObjectId(...) 自动
  转换为 {"\$oid":"..."} 扩展 JSON 表示。
- dbx-core:新增 aggregate_documents、insert/update/delete_documents bulk
  操作;扩展 JSON 解析识别 \$oid;schema list_tables_core 补 MongoDB/Elasticsearch
  分支返回 COLLECTION/INDEX。
- dbx-web / Tauri 命令 / MCP bridge:注册 4 个新 mongo 路由
  (aggregate/insert/update/delete documents);MCP bridge 新增 mongo HTTP 入口
  和 lazy 连接复用。
- node-core (MCP 后端):mongodb 走专属 executeQuery / listTables /
  describeTable;read-only 默认 + DBX_MCP_ALLOW_WRITES /
  DBX_MCP_ALLOW_DANGEROUS_SQL 两级开关;aggregate 含 \$out/\$merge 走同样开关;
  describeTable 抽样 20 条文档推断字段类型,_id 自动标记主键。
- mcp-server:mongodb 跳过 SQL safety evaluator(mongo shell 与 SQL 语法
  不兼容),改由 backend 做命令级 gating;附注释。
- AiAssistant 同时 emit replaceSql + executeSql 让编辑器和结果保持一致;
  App.vue ensureQueryTab 在无上下文时回退到连接默认库;aiMessageRender
  识别 mongodb / mongo 代码块。
- Review 修复:合并两份重复的 evaluateMongo*Safety 实现(同时修复 web-backend
  用字符串比较导致空 filter 检查可被空格绕过的 bug);ObjectId 正则兼容
  单/双引号;mongo_ops 错误文案 "MCP" 改为 "bulk";补 aggregate 解析测试用例。

测试:pnpm test 911 通过;vue-tsc 通过;cargo check -p dbx-core 通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
徐文彬andClaude Opus 4.7 committed 2026-05-28 21:25:43 +08:00
1 parent d57660eba9
commit c56bcb8e2d
22 files changed
+1492 -10

No files matched your search

+230
View File
@@ -39,6 +39,52 @@ struct DescribeTableRequest {
table: String,
}
#[derive(Deserialize)]
struct MongoFindDocumentsRequest {
connection_name: String,
database: Option<String>,
collection: String,
skip: Option<u64>,
limit: Option<i64>,
filter: Option<String>,
sort: Option<String>,
}
#[derive(Deserialize)]
struct MongoAggregateDocumentsRequest {
connection_name: String,
database: Option<String>,
collection: String,
pipeline_json: String,
}
#[derive(Deserialize)]
struct MongoInsertDocumentsRequest {
connection_name: String,
database: Option<String>,
collection: String,
docs_json: String,
}
#[derive(Deserialize)]
struct MongoUpdateDocumentsRequest {
connection_name: String,
database: Option<String>,
collection: String,
filter_json: String,
update_json: String,
many: bool,
}
#[derive(Deserialize)]
struct MongoDeleteDocumentsRequest {
connection_name: String,
database: Option<String>,
collection: String,
filter_json: String,
many: bool,
}
#[derive(Clone, Serialize)]
pub struct McpOpenTableEvent {
pub connection_id: String,
@@ -92,6 +138,18 @@ pub fn start(app_handle: AppHandle, state: Arc<AppState>) {
handle_list_tables_data(&st, body, &mut stream).await;
} else if first_line.starts_with("POST /data/describe-table") {
handle_describe_table_data(&st, body, &mut stream).await;
} else if first_line.starts_with("POST /data/mongo/list-collections") {
handle_mongo_list_collections_data(&st, body, &mut stream).await;
} else if first_line.starts_with("POST /data/mongo/find-documents") {
handle_mongo_find_documents_data(&st, body, &mut stream).await;
} else if first_line.starts_with("POST /data/mongo/aggregate-documents") {
handle_mongo_aggregate_documents_data(&st, body, &mut stream).await;
} else if first_line.starts_with("POST /data/mongo/insert-documents") {
handle_mongo_insert_documents_data(&st, body, &mut stream).await;
} else if first_line.starts_with("POST /data/mongo/update-documents") {
handle_mongo_update_documents_data(&st, body, &mut stream).await;
} else if first_line.starts_with("POST /data/mongo/delete-documents") {
handle_mongo_delete_documents_data(&st, body, &mut stream).await;
} else if first_line.starts_with("POST /data/execute-query") {
handle_execute_query_data(&st, body, &mut stream).await;
} else if first_line.starts_with("POST /execute-query") {
@@ -148,6 +206,30 @@ async fn resolve_connection(
Ok(config)
}
async fn resolve_mongo_pool_key(
state: &Arc<AppState>,
connection_name: &str,
database: Option<String>,
stream: &mut tokio::net::TcpStream,
) -> Option<(String, String)> {
let config = match resolve_connection(state, connection_name).await {
Ok(c) => c,
Err(e) => {
respond_error(stream, "404 Not Found", &e).await;
return None;
}
};
let database = database.unwrap_or_else(|| config.database.clone().unwrap_or_default());
let pool_key = match state.get_or_create_pool(&config.id, Some(&database)).await {
Ok(key) => key,
Err(e) => {
respond_error(stream, "500 Internal Server Error", &e).await;
return None;
}
};
Some((pool_key, database))
}
async fn handle_open_table(app: &AppHandle, state: &Arc<AppState>, body: &str, stream: &mut tokio::net::TcpStream) {
let req: OpenTableRequest = match serde_json::from_str(body) {
Ok(r) => r,
@@ -251,6 +333,154 @@ async fn handle_describe_table_data(state: &Arc<AppState>, body: &str, stream: &
}
}
async fn handle_mongo_list_collections_data(state: &Arc<AppState>, body: &str, stream: &mut tokio::net::TcpStream) {
let req: ListTablesRequest = match serde_json::from_str(body) {
Ok(r) => r,
Err(_) => {
respond_error(stream, "400 Bad Request", "Invalid JSON").await;
return;
}
};
let Some((pool_key, database)) = resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await
else {
return;
};
match dbx_core::mongo_ops::mongo_list_collections_core(state, &pool_key, &database).await {
Ok(collections) => respond_json(stream, &collections).await,
Err(e) => respond_error(stream, "500 Internal Server Error", &e).await,
}
}
async fn handle_mongo_find_documents_data(state: &Arc<AppState>, body: &str, stream: &mut tokio::net::TcpStream) {
let req: MongoFindDocumentsRequest = match serde_json::from_str(body) {
Ok(r) => r,
Err(_) => {
respond_error(stream, "400 Bad Request", "Invalid JSON").await;
return;
}
};
let Some((pool_key, database)) = resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await
else {
return;
};
match dbx_core::mongo_ops::mongo_find_documents_core(
state,
&pool_key,
&database,
&req.collection,
req.skip.unwrap_or(0),
req.limit.unwrap_or(100),
req.filter.as_deref(),
req.sort.as_deref(),
)
.await
{
Ok(result) => respond_json(stream, &result).await,
Err(e) => respond_error(stream, "500 Internal Server Error", &e).await,
}
}
async fn handle_mongo_aggregate_documents_data(state: &Arc<AppState>, body: &str, stream: &mut tokio::net::TcpStream) {
let req: MongoAggregateDocumentsRequest = match serde_json::from_str(body) {
Ok(r) => r,
Err(_) => {
respond_error(stream, "400 Bad Request", "Invalid JSON").await;
return;
}
};
let Some((pool_key, database)) = resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await
else {
return;
};
match dbx_core::mongo_ops::mongo_aggregate_documents_core(
state,
&pool_key,
&database,
&req.collection,
&req.pipeline_json,
)
.await
{
Ok(result) => respond_json(stream, &result).await,
Err(e) => respond_error(stream, "500 Internal Server Error", &e).await,
}
}
async fn handle_mongo_insert_documents_data(state: &Arc<AppState>, body: &str, stream: &mut tokio::net::TcpStream) {
let req: MongoInsertDocumentsRequest = match serde_json::from_str(body) {
Ok(r) => r,
Err(_) => {
respond_error(stream, "400 Bad Request", "Invalid JSON").await;
return;
}
};
let Some((pool_key, database)) = resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await
else {
return;
};
match dbx_core::mongo_ops::mongo_insert_documents_core(state, &pool_key, &database, &req.collection, &req.docs_json)
.await
{
Ok(inserted) => respond_json(stream, &serde_json::json!({ "affected_rows": inserted })).await,
Err(e) => respond_error(stream, "500 Internal Server Error", &e).await,
}
}
async fn handle_mongo_update_documents_data(state: &Arc<AppState>, body: &str, stream: &mut tokio::net::TcpStream) {
let req: MongoUpdateDocumentsRequest = match serde_json::from_str(body) {
Ok(r) => r,
Err(_) => {
respond_error(stream, "400 Bad Request", "Invalid JSON").await;
return;
}
};
let Some((pool_key, database)) = resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await
else {
return;
};
match dbx_core::mongo_ops::mongo_update_documents_core(
state,
&pool_key,
&database,
&req.collection,
&req.filter_json,
&req.update_json,
req.many,
)
.await
{
Ok(modified) => respond_json(stream, &serde_json::json!({ "affected_rows": modified })).await,
Err(e) => respond_error(stream, "500 Internal Server Error", &e).await,
}
}
async fn handle_mongo_delete_documents_data(state: &Arc<AppState>, body: &str, stream: &mut tokio::net::TcpStream) {
let req: MongoDeleteDocumentsRequest = match serde_json::from_str(body) {
Ok(r) => r,
Err(_) => {
respond_error(stream, "400 Bad Request", "Invalid JSON").await;
return;
}
};
let Some((pool_key, database)) = resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await
else {
return;
};
match dbx_core::mongo_ops::mongo_delete_documents_core(
state,
&pool_key,
&database,
&req.collection,
&req.filter_json,
req.many,
)
.await
{
Ok(deleted) => respond_json(stream, &serde_json::json!({ "affected_rows": deleted })).await,
Err(e) => respond_error(stream, "500 Internal Server Error", &e).await,
}
}
async fn handle_execute_query_data(state: &Arc<AppState>, body: &str, stream: &mut tokio::net::TcpStream) {
let req: ExecuteQueryRequest = match serde_json::from_str(body) {
Ok(r) => r,
+12
View File
@@ -45,6 +45,18 @@ pub async fn mongo_find_documents(
.await
}
#[tauri::command]
pub async fn mongo_aggregate_documents(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
collection: String,
pipeline_json: String,
) -> Result<MongoDocumentResult, String> {
dbx_core::mongo_ops::mongo_aggregate_documents_core(&state, &connection_id, &database, &collection, &pipeline_json)
.await
}
#[tauri::command]
pub async fn mongo_insert_document(
state: State<'_, Arc<AppState>>,