From 4346192713662b424c9e1bbac4e2c0deb0470892 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 22 Jun 2026 12:42:31 +0800 Subject: [PATCH] feat(mongodb): show collection metadata in sidebar --- .../src/components/sidebar/TreeItem.vue | 15 ++- apps/desktop/src/lib/sidebarTreeItemLayout.ts | 2 +- apps/desktop/src/stores/connectionStore.ts | 33 ++++++- crates/dbx-core/src/db/mongo_driver.rs | 92 ++++++++++++++----- crates/dbx-core/src/schema.rs | 1 + .../app-tests/sidebarTreeItemLayout.test.ts | 7 ++ 6 files changed, 120 insertions(+), 30 deletions(-) create mode 100644 packages/app-tests/sidebarTreeItemLayout.test.ts diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 9becd85522..88af3dd8a2 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -481,9 +481,7 @@ async function toggle() { } else if (node.type === "mongo-db" && node.connectionId && node.database) { await connectionStore.loadMongoCollections(node.connectionId, node.database); } else if (node.type === "mongo-collection" && node.connectionId && node.database) { - const tabTitle = `${node.database}.${node.label}`; - const tab = queryStore.createTab(node.connectionId, node.database, tabTitle, "mongo"); - queryStore.updateSql(tab, node.label); + await connectionStore.loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id); } else if (node.type === "elasticsearch-index" && node.connectionId) { const tab = queryStore.createTab(node.connectionId, node.database || "default", node.label, "mongo"); queryStore.updateSql(tab, node.label); @@ -547,6 +545,8 @@ function runRowClickAction() { const action = treeNodeRowAction(node.type, canExpand.value, settingsStore.editorSettings.sidebarActivation); if (action === "open-data") { openData(); + } else if (node.type === "mongo-collection") { + openMongoCollectionData(node); } else if (node.type === "procedure" || node.type === "function" || node.type === "sequence" || node.type === "package" || node.type === "package-body") { void viewObjectSource(); } else if (action === "toggle") { @@ -773,11 +773,20 @@ function onDoubleClick() { openData(); } else if (action === "open-source") { void viewObjectSource(); + } else if (action === "toggle" && props.node.type === "mongo-collection") { + openMongoCollectionData(props.node); } else if (action === "toggle") { toggle(); } } +function openMongoCollectionData(node: TreeNode) { + if (node.type !== "mongo-collection" || !node.connectionId || !node.database) return; + const tabTitle = `${node.database}.${node.label}`; + const tab = queryStore.createTab(node.connectionId, node.database, tabTitle, "mongo"); + queryStore.updateSql(tab, node.label); +} + async function openObjectBrowser() { const node = props.node; if (!node.connectionId) return; diff --git a/apps/desktop/src/lib/sidebarTreeItemLayout.ts b/apps/desktop/src/lib/sidebarTreeItemLayout.ts index 9cf688fdd6..087891e214 100644 --- a/apps/desktop/src/lib/sidebarTreeItemLayout.ts +++ b/apps/desktop/src/lib/sidebarTreeItemLayout.ts @@ -1,6 +1,6 @@ import type { TreeNodeType } from "@/types/database"; -const leafTypes: Set = new Set(["column", "index", "fkey", "trigger", "procedure", "function", "package", "package-body", "object-browser", "redis-db", "mq-tenant", "mongo-collection", "vector-collection", "elasticsearch-index", "user-admin", "saved-sql-file", "load-more"]); +const leafTypes: Set = new Set(["column", "index", "fkey", "trigger", "procedure", "function", "package", "package-body", "object-browser", "redis-db", "mq-tenant", "vector-collection", "elasticsearch-index", "user-admin", "saved-sql-file", "load-more"]); const fullWidthLabelTypes: Set = new Set(["table", "view", "materialized_view", "mongo-collection", "vector-collection", "elasticsearch-index"]); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index a4a0524b75..64baf1d89d 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -1656,7 +1656,7 @@ export const useConnectionStore = defineStore("connection", () => { const config = getConfig(connectionId); const metadataCapabilities = getTableMetadataCapabilities(effectiveDatabaseTypeForConnection(config)); - if (node.type === "table" && !parseSqlServerLinkedSchema(schema)) { + if ((node.type === "table" || node.type === "mongo-collection") && !parseSqlServerLinkedSchema(schema)) { if (metadataCapabilities.indexes) { children.push({ id: `${parentId}:__indexes`, @@ -1670,6 +1670,8 @@ export const useConnectionStore = defineStore("connection", () => { children: [], }); } + } + if (node.type === "table" && !parseSqlServerLinkedSchema(schema)) { if (metadataCapabilities.foreignKeys) { children.push({ id: `${parentId}:__fkeys`, @@ -1709,6 +1711,33 @@ export const useConnectionStore = defineStore("connection", () => { node.isLoading = true; try { + if (effectiveDatabaseTypeForConnection(getConfig(connectionId)) === "mongodb") { + const fields = await listMongoCompletionFields(connectionId, database, table); + setChildren( + node, + fields.map((field) => { + const column = { + name: field.name, + data_type: field.type || "unknown", + is_nullable: true, + column_default: null, + is_primary_key: field.name === "_id", + extra: "sampled", + }; + return { + id: `${parentId}:${field.name}`, + label: `${field.name} (${column.data_type})`, + type: "column" as const, + connectionId, + database, + tableName: table, + meta: column, + }; + }), + ); + node.isExpanded = true; + return; + } const querySchema = metadataQuerySchema(connectionId, database, schema); const columns = await api.getColumns(connectionId, database, querySchema, table); setChildren( @@ -1876,6 +1905,8 @@ export const useConnectionStore = defineStore("connection", () => { } } else if (node.type === "mongo-db" && node.connectionId && node.database) { await loadMongoCollections(node.connectionId, node.database); + } else if (node.type === "mongo-collection" && node.connectionId && node.database) { + await loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id); } else if (node.type === "database" && node.connectionId && hasTreeNodeDatabaseContext(node)) { const config = getConfig(node.connectionId); const effectiveDbType = effectiveDatabaseTypeForConnection(config); diff --git a/crates/dbx-core/src/db/mongo_driver.rs b/crates/dbx-core/src/db/mongo_driver.rs index 225239122c..1e0be91682 100644 --- a/crates/dbx-core/src/db/mongo_driver.rs +++ b/crates/dbx-core/src/db/mongo_driver.rs @@ -1,7 +1,7 @@ use mongodb::{ bson::{doc, oid::ObjectId, Bson, DateTime, Document}, options::ClientOptions, - Client, + Client, IndexModel, }; use serde::{Deserialize, Serialize}; @@ -115,34 +115,38 @@ pub async fn list_indexes(client: &Client, database: &str, collection: &str) -> let mut cursor = col.list_indexes().await.map_err(|e| e.to_string())?; let mut indexes = Vec::new(); while let Some(model) = cursor.try_next().await.map_err(|e| e.to_string())? { - let name = model.options.as_ref().and_then(|options| options.name.clone()).unwrap_or_else(|| { - model.keys.iter().map(|(field, value)| format!("{field}_{value}")).collect::>().join("_") - }); - let columns = model.keys.keys().cloned().collect::>(); - let index_type = if model.keys.is_empty() { - None - } else { - Some(model.keys.iter().map(|(field, value)| format!("{field}: {value}")).collect::>().join(", ")) - }; - let filter = model - .options - .as_ref() - .and_then(|options| options.partial_filter_expression.as_ref()) - .map(|filter| bson_to_json(&Bson::Document(filter.clone())).to_string()); - indexes.push(IndexInfo { - is_unique: model.options.as_ref().and_then(|options| options.unique).unwrap_or(false), - is_primary: name == "_id_", - name, - columns, - filter, - index_type, - included_columns: None, - comment: None, - }); + indexes.push(index_info_from_model(model)); } Ok(indexes) } +fn index_info_from_model(model: IndexModel) -> IndexInfo { + let name = model.options.as_ref().and_then(|options| options.name.clone()).unwrap_or_else(|| { + model.keys.iter().map(|(field, value)| format!("{field}_{value}")).collect::>().join("_") + }); + let columns = model.keys.keys().cloned().collect::>(); + let index_type = if model.keys.is_empty() { + None + } else { + Some(model.keys.iter().map(|(field, value)| format!("{field}: {value}")).collect::>().join(", ")) + }; + let filter = model + .options + .as_ref() + .and_then(|options| options.partial_filter_expression.as_ref()) + .map(|filter| bson_to_json(&Bson::Document(filter.clone())).to_string()); + IndexInfo { + is_unique: model.options.as_ref().and_then(|options| options.unique).unwrap_or(false), + is_primary: name == "_id_", + name, + columns, + filter, + index_type, + included_columns: None, + comment: None, + } +} + pub async fn find_documents( client: &Client, database: &str, @@ -567,6 +571,7 @@ fn expand_object_id_string_array(items: &[serde_json::Value]) -> Bson { #[cfg(test)] mod tests { use super::*; + use mongodb::options::IndexOptions; #[test] fn document_id_filters_try_object_id_then_string_for_hex_ids() { @@ -652,6 +657,43 @@ mod tests { assert_eq!(value, serde_json::json!(42)); } + #[test] + fn index_info_from_model_maps_mongodb_index_metadata() { + let model = IndexModel::builder() + .keys(doc! { "tenant_id": 1, "created_at": -1 }) + .options( + IndexOptions::builder() + .name("tenant_created_idx".to_string()) + .unique(true) + .partial_filter_expression(doc! { "archived": false }) + .build(), + ) + .build(); + + let index = index_info_from_model(model); + + assert_eq!(index.name, "tenant_created_idx"); + assert_eq!(index.columns, vec!["tenant_id", "created_at"]); + assert!(index.is_unique); + assert!(!index.is_primary); + assert_eq!(index.index_type.as_deref(), Some("tenant_id: 1, created_at: -1")); + assert_eq!(index.filter.as_deref(), Some("{\"archived\":false}")); + } + + #[test] + fn index_info_from_model_marks_default_id_index_as_primary() { + let model = IndexModel::builder() + .keys(doc! { "_id": 1 }) + .options(IndexOptions::builder().name("_id_".to_string()).unique(true).build()) + .build(); + + let index = index_info_from_model(model); + + assert_eq!(index.columns, vec!["_id"]); + assert!(index.is_unique); + assert!(index.is_primary); + } + #[test] fn json_object_to_document_parses_extended_json_date() { let value = serde_json::json!({ diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 0d0aa8502d..e44c04ec20 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -1573,6 +1573,7 @@ pub async fn list_indexes_core( PoolKind::Postgres(p) => db::postgres::list_indexes(p, schema, table).await, PoolKind::Sqlite(p) => db::sqlite::list_indexes(p, schema, table).await, PoolKind::Rqlite(client) => db::rqlite_driver::list_indexes(client, schema, table).await, + PoolKind::MongoDb(client) => db::mongo_driver::list_indexes(client, database, table).await, _ => Ok(vec![]), } }) diff --git a/packages/app-tests/sidebarTreeItemLayout.test.ts b/packages/app-tests/sidebarTreeItemLayout.test.ts new file mode 100644 index 0000000000..d46e9f0c9f --- /dev/null +++ b/packages/app-tests/sidebarTreeItemLayout.test.ts @@ -0,0 +1,7 @@ +import { test } from "vitest"; +import assert from "node:assert/strict"; +import { canTreeNodeShowExpander } from "../../apps/desktop/src/lib/sidebarTreeItemLayout.ts"; + +test("mongodb collection rows can show an expander for metadata groups", () => { + assert.equal(canTreeNodeShowExpander({ type: "mongo-collection", childCount: 0 }), true); +});