diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 604cf1e01a..96e28eb140 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -290,10 +290,15 @@ const effectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection( const isGaussdbM = computed(() => effectiveDatabaseType.value === "gaussdb" && props.connection.driver_profile?.toLowerCase() === "gaussdb-m"); const isVictoriaMetrics = computed(() => effectiveDatabaseType.value === "victoriametrics"); const isMongodb = computed(() => props.connection.db_type === "mongodb"); -const supportsObjectRowStats = computed(() => !isMongodb.value); -const supportsObjectSizeStats = computed(() => !isVictoriaMetrics.value && !isMongodb.value); +// Victoria Metrics reports series instead of rows and has no byte size to show; +// every other engine (MongoDB collections included, via `collStats`) fills both +// the row and size columns. +const supportsObjectSizeStats = computed(() => !isVictoriaMetrics.value); +// The batch table toolbar (export/copy/truncate/empty/drop selected) is SQL-only: +// MongoDB collections are not dropped or truncated through it. +const supportsBatchTableActions = computed(() => !isVictoriaMetrics.value && !isMongodb.value); const showTableStatistics = computed(() => objectFilter.value === "all" || objectFilter.value === "tables"); -const showObjectRowStats = computed(() => supportsObjectRowStats.value && showTableStatistics.value); +const showObjectRowStats = computed(() => showTableStatistics.value); const showObjectSizeStats = computed(() => supportsObjectSizeStats.value && showTableStatistics.value); const objectRowsLabel = computed(() => t(isVictoriaMetrics.value ? "objects.series" : "objects.rows")); @@ -2940,7 +2945,7 @@ async function loadObjects(options?: { allowCached?: boolean; preserveExistingRo if (!objectBrowserRowsLoadGuard.isCurrent(request)) return; applyObjectBrowserRows(nextRows); const cachedAt = cacheObjectBrowserRows(cacheWriteToken, nextRows); - if (props.connection.db_type !== "mongodb") void loadObjectStatistics(request, cacheWriteToken, cachedAt); + void loadObjectStatistics(request, cacheWriteToken, cachedAt); } catch (e: any) { if (!objectBrowserRowsLoadGuard.isCurrent(request)) return; // Keep visible rows on a background revalidate failure — surface a lightweight @@ -3503,23 +3508,23 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
{{ t("objects.selectedTables", { count: selectedTableCount }) }}
- - - - - diff --git a/apps/desktop/src/components/objects/__tests__/ObjectBrowser.mongoCollectionStats.spec.ts b/apps/desktop/src/components/objects/__tests__/ObjectBrowser.mongoCollectionStats.spec.ts new file mode 100644 index 0000000000..29c14dcdab --- /dev/null +++ b/apps/desktop/src/components/objects/__tests__/ObjectBrowser.mongoCollectionStats.spec.ts @@ -0,0 +1,28 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const objectBrowserSource = readFileSync(new URL("../ObjectBrowser.vue", import.meta.url), "utf8"); + +describe("ObjectBrowser MongoDB collection statistics (#9705)", () => { + it("requests table statistics for every engine, MongoDB included", () => { + // The loader used to skip MongoDB wholesale, so the columns below stayed + // empty even though the backend can now answer `collStats` per collection. + expect(objectBrowserSource).not.toContain('if (props.connection.db_type !== "mongodb") void loadObjectStatistics'); + expect(objectBrowserSource).toContain("void loadObjectStatistics(request, cacheWriteToken, cachedAt);"); + }); + + it("renders the row/size columns and their sort keys for MongoDB", () => { + expect(objectBrowserSource).toContain("const showObjectRowStats = computed(() => showTableStatistics.value);"); + expect(objectBrowserSource).toContain("const supportsObjectSizeStats = computed(() => !isVictoriaMetrics.value);"); + }); + + it("keeps the batch table toolbar SQL-only", () => { + expect(objectBrowserSource).toContain("const supportsBatchTableActions = computed(() => !isVictoriaMetrics.value && !isMongodb.value);"); + for (const handler of ["openBatchDatabaseExport", "copySelectedTablesToClipboard", "requestBatchTruncateTables", "requestBatchEmptyTables", "requestBatchDropTables"]) { + const button = new RegExp(`v-if="supportsBatchTableActions[^"]*"[^>]*@click="${handler}"`); + expect(objectBrowserSource).toMatch(button); + } + // No toolbar action may key off the stats-column flag any more. + expect(objectBrowserSource).not.toContain('v-if="supportsObjectSizeStats'); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/table/objectBrowserRows.spec.ts b/apps/desktop/src/lib/__tests__/table/objectBrowserRows.spec.ts index 85f59a0a0a..662fa5a27c 100644 --- a/apps/desktop/src/lib/__tests__/table/objectBrowserRows.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/objectBrowserRows.spec.ts @@ -152,6 +152,22 @@ describe("buildMongoObjectBrowserRows", () => { ]); }); + it("sorts collections by document count and on-disk bytes once stats are merged", () => { + const stats = [ + { name: "stores", estimatedRows: 50, totalBytes: 40_960 }, + { name: "customer_events", estimatedRows: 30_000, totalBytes: 1_912_832 }, + { name: "orders_10k", estimatedRows: 10_000, totalBytes: 901_120 }, + ]; + const rows = buildMongoObjectBrowserRows({ collections: stats.map(({ name }) => ({ name, kind: "collection" })), database: "app" }).map((row, index) => ({ + ...row, + estimatedRows: stats[index]!.estimatedRows, + totalBytes: stats[index]!.totalBytes, + })); + + expect(sortObjectBrowserRows(rows, "totalBytes", "desc").map((row) => row.name)).toEqual(["customer_events", "orders_10k", "stores"]); + expect(sortObjectBrowserRows(rows, "estimatedRows", "desc").map((row) => row.name)).toEqual(["customer_events", "orders_10k", "stores"]); + }); + it("preserves distinct MongoDB collection identifiers verbatim", () => { const rows = buildMongoObjectBrowserRows({ collections: [{ name: " users " }, { name: "users" }, { name: " " }, { name: "" }], diff --git a/crates/dbx-core/src/schema/mod.rs b/crates/dbx-core/src/schema/mod.rs index 8e4db3f85d..02e86dc200 100644 --- a/crates/dbx-core/src/schema/mod.rs +++ b/crates/dbx-core/src/schema/mod.rs @@ -6348,6 +6348,7 @@ async fn list_object_statistics_once( PoolKind::ClickHouse(client) => { db::clickhouse_driver::list_object_statistics(client, clickhouse_metadata_database(database, schema)).await } + PoolKind::MongoDb(client) => db::mongo_driver::list_object_statistics(client, database).await, _ => Ok(vec![]), } } diff --git a/crates/dbx-drivers/src/db/mongo_driver.rs b/crates/dbx-drivers/src/db/mongo_driver.rs index 88416ace59..687c33c6d0 100644 --- a/crates/dbx-drivers/src/db/mongo_driver.rs +++ b/crates/dbx-drivers/src/db/mongo_driver.rs @@ -10,9 +10,9 @@ use serde::{Deserialize, Serialize}; use super::with_connection_timeout; use crate::models::connection::DatabaseConnectionInfo; -use crate::types::IndexInfo; +use crate::types::{IndexInfo, ObjectStatistics}; use dbx_types::document::{MongoGridFsBucketInfo, MongoGridFsFileInfo}; -use futures::{io::AsyncReadExt, io::AsyncWriteExt, TryStreamExt}; +use futures::{io::AsyncReadExt, io::AsyncWriteExt, stream, StreamExt, TryStreamExt}; use percent_encoding::percent_decode_str; use std::{collections::HashSet, time::Duration}; @@ -274,6 +274,65 @@ pub async fn run_command(client: &Client, database: &str, command_json: &str) -> }) } +/// Bounded `collStats` round trips for the database view's per-collection stats. +const MONGO_OBJECT_STATISTICS_CONCURRENCY: usize = 8; + +/// Per-collection document counts and on-disk sizes for the database view's +/// "rows"/"size" columns. +/// +/// `listCollections` carries no size metadata, so every collection needs its own +/// `collStats`. Views are skipped (they own no storage and `collStats` rejects +/// them) and the round trips run with bounded concurrency so a database with +/// hundreds of collections does not serialize into one long wait. A collection +/// whose `collStats` fails is left out; the view then keeps that row blank. +pub async fn list_object_statistics(client: &Client, database: &str) -> Result, String> { + let database = database.trim(); + if database.is_empty() { + return Err("Database name is required".to_string()); + } + + let collections = list_collection_specs(client, database).await?; + let handle = client.database(database); + let stats = + stream::iter(collections.into_iter().filter(|spec| spec.kind != MongoCollectionKind::View).map(|spec| { + let handle = handle.clone(); + async move { + let result = handle.run_command(collection_stats_command_document(&spec.name, None)).await.ok()?; + Some(object_statistics_from_collection_stats(&spec.name, database, &result)) + } + })) + .buffer_unordered(MONGO_OBJECT_STATISTICS_CONCURRENCY) + .collect::>() + .await; + + Ok(stats.into_iter().flatten().collect()) +} + +/// Map `collStats` onto the shared statistics shape: the document count, and the +/// on-disk footprint (data + indexes) the SQL engines report as +/// `DATA_LENGTH + INDEX_LENGTH`. +fn object_statistics_from_collection_stats(name: &str, database: &str, result: &Document) -> ObjectStatistics { + ObjectStatistics { + name: name.to_string(), + schema: Some(database.to_string()), + estimated_rows: collection_stats_int(result, "count"), + total_bytes: match (collection_stats_int(result, "storageSize"), collection_stats_int(result, "totalIndexSize")) + { + (None, None) => None, + (data, index) => Some(data.unwrap_or(0).saturating_add(index.unwrap_or(0))), + }, + } +} + +fn collection_stats_int(result: &Document, key: &str) -> Option { + match result.get(key) { + Some(Bson::Int32(value)) => Some(i64::from(*value)), + Some(Bson::Int64(value)) => Some(*value), + Some(Bson::Double(value)) if value.is_finite() => Some(*value as i64), + _ => None, + } +} + fn server_version_from_build_info(result: &Document) -> Result { result.get_str("version").map(str::to_string).map_err(|e| format!("MongoDB server version not found: {e}")) } @@ -4695,6 +4754,66 @@ mod tests { assert!(error.contains("MongoDB server version not found")); } + #[test] + fn object_statistics_maps_collstats_fields() { + let stats = object_statistics_from_collection_stats( + "orders_10k", + "dbx_mongo_demo", + &doc! { + "count": 10000_i64, + "size": 2084944_i64, + "storageSize": 536576_i64, + "totalIndexSize": 364544_i64, + }, + ); + + assert_eq!(stats.name, "orders_10k"); + assert_eq!(stats.schema.as_deref(), Some("dbx_mongo_demo")); + assert_eq!(stats.estimated_rows, Some(10000)); + // 536576 + 364544: the on-disk data + index footprint. + assert_eq!(stats.total_bytes, Some(901120)); + } + + #[test] + fn object_statistics_accepts_int32_collstats_fields() { + let stats = object_statistics_from_collection_stats( + "stores", + "app", + &doc! { + "count": 50_i32, + "storageSize": 20480_i32, + "totalIndexSize": 20480_i32, + }, + ); + + assert_eq!(stats.estimated_rows, Some(50)); + assert_eq!(stats.total_bytes, Some(40960)); + } + + #[test] + fn object_statistics_keeps_missing_fields_unknown() { + let stats = object_statistics_from_collection_stats("empty", "app", &doc! { "ok": 1_i32 }); + + assert_eq!(stats.estimated_rows, None); + assert_eq!(stats.total_bytes, None); + } + + #[test] + fn object_statistics_reports_zero_sized_collections() { + let stats = object_statistics_from_collection_stats( + "fresh", + "app", + &doc! { + "count": 0_i32, + "storageSize": 0_i64, + "totalIndexSize": 0_i64, + }, + ); + + assert_eq!(stats.estimated_rows, Some(0)); + assert_eq!(stats.total_bytes, Some(0)); + } + #[test] fn collection_stats_result_reads_expected_fields() { let result = collection_stats_result_from_document(&doc! { diff --git a/docs/screenshots/issue-9705-mongo-sorted-by-size.png b/docs/screenshots/issue-9705-mongo-sorted-by-size.png new file mode 100644 index 0000000000..0fad817c11 Binary files /dev/null and b/docs/screenshots/issue-9705-mongo-sorted-by-size.png differ diff --git a/docs/screenshots/issue-9705-mongo-stats-after.png b/docs/screenshots/issue-9705-mongo-stats-after.png new file mode 100644 index 0000000000..04ff17cc3c Binary files /dev/null and b/docs/screenshots/issue-9705-mongo-stats-after.png differ diff --git a/docs/screenshots/issue-9705-mongo-stats-before.png b/docs/screenshots/issue-9705-mongo-stats-before.png new file mode 100644 index 0000000000..9471c135f6 Binary files /dev/null and b/docs/screenshots/issue-9705-mongo-stats-before.png differ