diff --git a/agents/drivers/mongodb/src/main/java/com/dbx/agent/mongodb/MongoAgent.java b/agents/drivers/mongodb/src/main/java/com/dbx/agent/mongodb/MongoAgent.java index dc2d92eb9..1b63f1b72 100644 --- a/agents/drivers/mongodb/src/main/java/com/dbx/agent/mongodb/MongoAgent.java +++ b/agents/drivers/mongodb/src/main/java/com/dbx/agent/mongodb/MongoAgent.java @@ -388,7 +388,7 @@ public final class MongoAgent { if (filterDoc == null) { filterDoc = new Document(); } - long total = col.countDocuments(filterDoc); + CollectionTotal total = collectionTotal(col, filterDoc); var iterable = col.find(filterDoc).skip((int) skip).limit(limit); if (projectionDoc != null) { @@ -402,10 +402,7 @@ public final class MongoAgent { for (Document document : iterable) { documents.add(bsonToJson(document)); } - Map result = new LinkedHashMap<>(); - result.put("documents", documents); - result.put("total", total); - return result; + return documentQueryResult(documents, total); } /** @@ -426,7 +423,7 @@ public final class MongoAgent { if (filterDoc == null) { filterDoc = new Document(); } - long total = col.countDocuments(filterDoc); + CollectionTotal total = collectionTotal(col, filterDoc); var iterable = col.find(filterDoc).skip((int) skip).limit(limit); if (projectionDoc != null) { @@ -440,12 +437,28 @@ public final class MongoAgent { for (Document document : iterable) { documents.add(bsonToExtendedJson(document)); } + return documentQueryResult(documents, total); + } + + static CollectionTotal collectionTotal(MongoCollection collection, Document filter) { + if (filter.isEmpty()) { + return new CollectionTotal(collection.estimatedDocumentCount(), false); + } + return new CollectionTotal(collection.countDocuments(filter), true); + } + + static Map documentQueryResult(List documents, CollectionTotal total) { Map result = new LinkedHashMap<>(); result.put("documents", documents); - result.put("total", total); + result.put("total", total.value()); + if (!total.exact()) { + result.put("total_is_exact", false); + } return result; } + record CollectionTotal(long value, boolean exact) {} + private static Object countDocuments(JsonObject params) { MongoClient c = requireClient(); String database = params.get("database").getAsString(); diff --git a/agents/drivers/mongodb/src/test/java/com/dbx/agent/mongodb/MongoAgentTest.java b/agents/drivers/mongodb/src/test/java/com/dbx/agent/mongodb/MongoAgentTest.java index 4bb1df5ed..68b69ebb8 100644 --- a/agents/drivers/mongodb/src/test/java/com/dbx/agent/mongodb/MongoAgentTest.java +++ b/agents/drivers/mongodb/src/test/java/com/dbx/agent/mongodb/MongoAgentTest.java @@ -28,6 +28,7 @@ import java.util.Base64; import java.util.Collections; import java.util.Date; import java.util.List; +import java.util.Map; import org.bson.Document; import org.bson.types.ObjectId; import org.junit.jupiter.api.BeforeAll; @@ -148,6 +149,53 @@ class MongoAgentTest { assertFalse(json.getAsJsonObject("error").get("message").getAsString().contains("Unknown method")); } + @Test + void collectionTotalUsesEstimatedCountForEmptyFilter() { + List calls = new ArrayList<>(); + MongoCollection collection = recordingCountCollection(calls); + + MongoAgent.CollectionTotal total = MongoAgent.collectionTotal(collection, new Document()); + + assertEquals(10_000_000L, total.value()); + assertFalse(total.exact()); + assertEquals(List.of("estimatedDocumentCount"), calls); + } + + @Test + void collectionTotalUsesExactCountForNonEmptyFilter() { + List calls = new ArrayList<>(); + MongoCollection collection = recordingCountCollection(calls); + Document filter = new Document("status", "active"); + + MongoAgent.CollectionTotal total = MongoAgent.collectionTotal(collection, filter); + + assertEquals(42L, total.value()); + assertTrue(total.exact()); + assertEquals(List.of("countDocuments:{\"status\": \"active\"}"), calls); + } + + @Test + void estimatedDocumentQueryResultMarksTotalAsInexact() { + Map result = MongoAgent.documentQueryResult( + List.of(new Document("_id", 1)), + new MongoAgent.CollectionTotal(10_000_000L, false) + ); + + assertEquals(10_000_000L, result.get("total")); + assertEquals(false, result.get("total_is_exact")); + } + + @Test + void exactDocumentQueryResultKeepsExistingWireShape() { + Map result = MongoAgent.documentQueryResult( + List.of(new Document("_id", 1)), + new MongoAgent.CollectionTotal(42L, true) + ); + + assertEquals(42L, result.get("total")); + assertFalse(result.containsKey("total_is_exact")); + } + @Test void parsesOptionalDocumentParameters() { JsonObject params = new JsonObject(); @@ -670,6 +718,26 @@ class MongoAgentTest { // ─── helpers ─── + @SuppressWarnings("unchecked") + private static MongoCollection recordingCountCollection(List calls) { + return (MongoCollection) Proxy.newProxyInstance( + MongoCollection.class.getClassLoader(), + new Class[] {MongoCollection.class}, + (proxy, method, args) -> { + if ("estimatedDocumentCount".equals(method.getName())) { + calls.add("estimatedDocumentCount"); + return 10_000_000L; + } + if ("countDocuments".equals(method.getName())) { + Document filter = (Document) args[0]; + calls.add("countDocuments:" + filter.toJson()); + return 42L; + } + throw new UnsupportedOperationException(method.getName()); + } + ); + } + private static void assertRpcModifiedCount(MongoClient client, int id, String updateJson, boolean many) { JsonObject params = new JsonObject(); params.addProperty("database", "app"); diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue index ac7a93171..916f7a988 100644 --- a/apps/desktop/src/components/document/DocumentBrowser.vue +++ b/apps/desktop/src/components/document/DocumentBrowser.vue @@ -17,6 +17,7 @@ import { useConnectionStore } from "@/stores/connectionStore"; import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit"; import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle"; import { clampDocumentPage, documentPageRequestLimit, resetElasticsearchDocumentTotals, resolveElasticsearchDocumentTotals } from "@/lib/document/elasticsearchDocumentTotals"; +import { canGoNextDocumentPage, resolveDocumentQueryTotals } from "@/lib/document/documentQueryTotals"; import { arrayObjectAncestorPathForDocumentField, buildDocumentFilterCondition, @@ -135,7 +136,16 @@ let elasticsearchCountExecutionId = ""; let elasticsearchCountGeneration = 0; const documentStoreProvider = computed(() => documentStoreProviderFor(props.databaseType)); -const pageTotal = computed(() => paginationTotal.value ?? total.value ?? 0); +const pageTotal = computed(() => paginationTotal.value); +const documentPageCount = computed(() => (pageTotal.value === undefined ? undefined : Math.max(1, Math.ceil(pageTotal.value / pageSize.value)))); +const canGoNextPage = computed(() => + canGoNextDocumentPage({ + page: page.value, + pageSize: pageSize.value, + rowCount: documents.value.length, + paginationTotal: pageTotal.value, + }), +); const documentRequestLimit = computed(() => { if (documentStoreProvider.value.kind !== "elasticsearch" || paginationTotal.value === undefined) return pageSize.value; return documentPageRequestLimit(page.value, pageSize.value, paginationTotal.value); @@ -147,7 +157,7 @@ const tableFindPaneStyle = computed(() => { }); const documentFontStyle = computed(() => documentViewerFontStyle(settingsStore.editorSettings)); const documentStoreLabels = computed(() => ({ - documentsLabel: documentStoreProvider.value.documentsLabel({ total: total.value ?? 0, t }), + documentsLabel: documentStoreProvider.value.documentsLabel({ total: total.value ?? 0, totalIsExact: totalIsExact.value, t }), queryPreview: documentQueryPreview.value, })); @@ -843,9 +853,10 @@ async function load() { applyElasticsearchSearchTotal(result.total, result.total_is_exact !== false, filter); } else { cancelElasticsearchCount(); - total.value = result.total; - totalIsExact.value = true; - paginationTotal.value = result.total; + const totals = resolveDocumentQueryTotals(result.total, result.total_is_exact !== false); + total.value = totals.total; + totalIsExact.value = totals.totalIsExact; + paginationTotal.value = totals.paginationTotal; } syncSelectedDocumentAfterLoad(previousSelectedIdx, previousSelectedId); } catch (e: unknown) { @@ -1313,7 +1324,7 @@ function prevPage() { } function nextPage() { - if ((page.value + 1) * pageSize.value >= pageTotal.value) return; + if (!canGoNextPage.value) return; page.value++; void load(); } @@ -1479,8 +1490,9 @@ defineExpose({ focusSearch }); - {{ page + 1 }} / {{ Math.max(1, Math.ceil(pageTotal / pageSize)) }} - diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 0a8d63d15..f4fab0781 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -133,7 +133,7 @@ import { applyColumnFormatter, buildColumnFormatterKey, getSupportedTimeZoneOpti import { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/dataGrid/dataGridTemporalEditor"; import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts"; import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter"; -import { canGoNextDataGridPage, hasCompleteLocalDataGridResult } from "@/lib/dataGrid/dataGridPagination"; +import { canGoNextDataGridPage, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal } from "@/lib/dataGrid/dataGridPagination"; import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions"; import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll"; import { CANVAS_DATA_GRID_ROW_HEIGHT, dataGridSearchMatchKey, drawCanvasDataGrid } from "@/lib/dataGrid/canvasDataGridRenderer"; @@ -2398,7 +2398,13 @@ const displayedTotalRowCount = computed(() => serverKnownTotalRowCount.value ?? const totalRowCountIsExact = computed(() => props.totalRowCountIsExact !== false); // A backend can expose an exact display total while deliberately restricting // offset pagination to a smaller safe range. -const paginationTotalRowCount = computed(() => props.paginationTotalRowCount ?? serverKnownTotalRowCount.value); +const paginationTotalRowCount = computed(() => + resolveDataGridPaginationTotal({ + paginationTotalRowCount: props.paginationTotalRowCount, + serverKnownTotalRowCount: serverKnownTotalRowCount.value, + totalRowCountIsExact: totalRowCountIsExact.value, + }), +); // Only a server-confirmed total drives pagination — an inferred total means // rows exist that we never fetched, so navigation must stay inside rows.length. const hasKnownPaginationTotalRowCount = computed(() => typeof paginationTotalRowCount.value === "number" && paginationTotalRowCount.value >= 0); diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 6a13bf39c..7e78ea0ac 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -1190,6 +1190,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand :page-limit="activeTab.resultPageLimit" :count-sql="activeTab.resultCountSql" :total-row-count="activeTab.resultTotalRowCount" + :total-row-count-is-exact="activeTab.resultTotalRowCount !== undefined || activeTab.result.total_is_exact !== false" :total-row-count-loading="activeTab.resultTotalRowCountLoading" :on-execute-sql="async (sql: string) => emit('executeSql', sql)" :full-export-result="(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => queryStore.fetchTabResultForExport(activeTab.id, onProgress)" @@ -1517,6 +1518,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand :page-offset="activeTab.resultPageOffset" :page-limit="activeTab.resultPageLimit" :total-row-count="activeTab.resultTotalRowCount" + :total-row-count-is-exact="activeTab.resultTotalRowCount !== undefined || activeTab.result.total_is_exact !== false" :total-row-count-loading="activeTab.resultTotalRowCountLoading" :on-execute-sql="async (sql: string) => emit('executeSql', sql)" :full-export-result="(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => queryStore.fetchTabResultForExport(activeTab.id, onProgress)" diff --git a/apps/desktop/src/lib/__tests__/document/documentQueryTotals.spec.ts b/apps/desktop/src/lib/__tests__/document/documentQueryTotals.spec.ts new file mode 100644 index 000000000..834c663de --- /dev/null +++ b/apps/desktop/src/lib/__tests__/document/documentQueryTotals.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { canGoNextDocumentPage, resolveDocumentQueryTotals } from "@/lib/document/documentQueryTotals"; + +describe("document query totals", () => { + it("uses exact totals as the pagination bound", () => { + expect(resolveDocumentQueryTotals(42, true)).toEqual({ + total: 42, + totalIsExact: true, + paginationTotal: 42, + }); + expect(canGoNextDocumentPage({ page: 3, pageSize: 10, rowCount: 10, paginationTotal: 42 })).toBe(true); + expect(canGoNextDocumentPage({ page: 4, pageSize: 10, rowCount: 2, paginationTotal: 42 })).toBe(false); + }); + + it("does not use estimated totals as the pagination bound", () => { + expect(resolveDocumentQueryTotals(10_000_000, false)).toEqual({ + total: 10_000_000, + totalIsExact: false, + paginationTotal: undefined, + }); + expect(canGoNextDocumentPage({ page: 999_999, pageSize: 10, rowCount: 10 })).toBe(true); + expect(canGoNextDocumentPage({ page: 1_000_000, pageSize: 10, rowCount: 3 })).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/app/documentStoreProvider.ts b/apps/desktop/src/lib/app/documentStoreProvider.ts index 6f300194c..a5e9009fd 100644 --- a/apps/desktop/src/lib/app/documentStoreProvider.ts +++ b/apps/desktop/src/lib/app/documentStoreProvider.ts @@ -44,7 +44,7 @@ export type DocumentStoreProvider = { kind: DocumentStoreKind; filterInputLabel: string; sortInputLabel: string; - documentsLabel(options: { total: number; t: ComposerTranslation }): string; + documentsLabel(options: { total: number; totalIsExact: boolean; t: ComposerTranslation }): string; queryPreview(options: DocumentStoreQueryPreviewOptions): string; sortInputForColumn(column: string, direction: "asc" | "desc" | null): string; }; @@ -80,7 +80,7 @@ const mongoDocumentProvider: DocumentStoreProvider = { kind: "mongodb", filterInputLabel: "find", sortInputLabel: "sort", - documentsLabel: ({ total, t }) => t("mongo.documents", { count: total }), + documentsLabel: ({ total, totalIsExact, t }) => `${totalIsExact ? "" : "≈"}${t("mongo.documents", { count: total })}`, queryPreview: ({ collection, filterJson, sortJson, skip, limit }) => { const collectionRef = `db.getCollection(${JSON.stringify(collection)})`; const parts = [`${collectionRef}.find(${mongoShellPreviewLiteral(filterJson || "{}")})`]; diff --git a/apps/desktop/src/lib/dataGrid/dataGridPagination.ts b/apps/desktop/src/lib/dataGrid/dataGridPagination.ts index f880a6467..eb04e5bd8 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridPagination.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridPagination.ts @@ -21,6 +21,11 @@ export interface CompleteLocalDataGridResultOptions { hasMore?: boolean; } +export function resolveDataGridPaginationTotal(options: { paginationTotalRowCount?: number; serverKnownTotalRowCount?: number; totalRowCountIsExact: boolean }): number | undefined { + if (options.paginationTotalRowCount !== undefined) return options.paginationTotalRowCount; + return options.totalRowCountIsExact ? options.serverKnownTotalRowCount : undefined; +} + export function hasCompleteLocalDataGridResult(options: CompleteLocalDataGridResultOptions): boolean { if (!options.isResultsContext || options.truncated === true || options.hasMore === true) return false; if (options.pageLimit === undefined) return true; diff --git a/apps/desktop/src/lib/document/documentQueryTotals.ts b/apps/desktop/src/lib/document/documentQueryTotals.ts new file mode 100644 index 000000000..214e4a266 --- /dev/null +++ b/apps/desktop/src/lib/document/documentQueryTotals.ts @@ -0,0 +1,20 @@ +export interface DocumentQueryTotals { + total: number; + totalIsExact: boolean; + paginationTotal: number | undefined; +} + +export function resolveDocumentQueryTotals(total: number, totalIsExact: boolean): DocumentQueryTotals { + return { + total, + totalIsExact, + paginationTotal: totalIsExact ? total : undefined, + }; +} + +export function canGoNextDocumentPage(options: { page: number; pageSize: number; rowCount: number; paginationTotal?: number }): boolean { + if (typeof options.paginationTotal === "number") { + return (options.page + 1) * options.pageSize < options.paginationTotal; + } + return options.rowCount >= options.pageSize; +} diff --git a/apps/desktop/src/lib/mongo/mongoShellCommand.ts b/apps/desktop/src/lib/mongo/mongoShellCommand.ts index a5768f1a3..6f4f4a9b5 100644 --- a/apps/desktop/src/lib/mongo/mongoShellCommand.ts +++ b/apps/desktop/src/lib/mongo/mongoShellCommand.ts @@ -645,7 +645,7 @@ export function evaluateMongoAggregateSafety(command: MongoAggregateCommand, opt return { allowed: true }; } -export function mongoDocumentsToQueryResult(documents: unknown[], executionTimeMs: number, total: number, copyDocuments?: unknown[]): QueryResult { +export function mongoDocumentsToQueryResult(documents: unknown[], executionTimeMs: number, total: number, copyDocuments?: unknown[], totalIsExact = true): QueryResult { const columns: string[] = []; for (const doc of documents) { @@ -668,6 +668,7 @@ export function mongoDocumentsToQueryResult(documents: unknown[], executionTimeM rows, mongo_documents: documents, ...(copyDocuments?.length === documents.length ? { mongo_copy_documents: copyDocuments } : {}), + ...(totalIsExact ? {} : { total_is_exact: false }), affected_rows: total, execution_time_ms: Math.max(0, Math.round(executionTimeMs)), truncated: total > documents.length, diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index e3dad59c7..1f1093103 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -3113,7 +3113,7 @@ export const useQueryStore = defineStore("query", () => { case "find": { queryExecutionLog("info", "mongo-find:start", { traceId, collection: mongoCommand.collection, database: currentDatabase }); const result = await api.mongoFindDocuments(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.skip, mongoCommand.limit, mongoCommand.filter, mongoCommand.projection, mongoCommand.sort, executionId); - const queryResult = markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents))); + const queryResult = markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents, result.total_is_exact !== false))); allResults.push(queryResult); mongoEditTarget = mongoCommands.length === 1 && !mongoCommand.projection && queryResult.columns.includes("_id") ? { collection: mongoCommand.collection, idColumn: "_id" } : undefined; queryExecutionLog("info", "mongo-find:done", { @@ -3129,7 +3129,7 @@ export const useQueryStore = defineStore("query", () => { case "findOne": { queryExecutionLog("info", "mongo-find-one:start", { traceId, collection: mongoCommand.collection, database: currentDatabase }); const result = await api.mongoFindOne(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.projection, mongoCommand.options, executionId); - const queryResult = markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents))); + const queryResult = markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents, result.total_is_exact !== false))); allResults.push(queryResult); mongoEditTarget = mongoCommands.length === 1 && !mongoCommand.projection && queryResult.columns.includes("_id") ? { collection: mongoCommand.collection, idColumn: "_id" } : undefined; queryExecutionLog("info", "mongo-find-one:done", { @@ -3176,7 +3176,7 @@ export const useQueryStore = defineStore("query", () => { queryExecutionLog("info", "mongo-aggregate:start", { traceId, collection: mongoCommand.collection, database: currentDatabase }); const aggregateMaxRows = normalizeResultPageSize(pageLimit ?? options?.pagination?.limit ?? settingsStore.editorSettings.pageSize); const result = await api.mongoAggregateDocuments(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.pipeline, aggregateMaxRows, mongoCommand.options, executionId); - allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents)))); + allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents, result.total_is_exact !== false)))); mongoEditTarget = undefined; queryExecutionLog("info", "mongo-aggregate:done", { traceId, @@ -3255,7 +3255,7 @@ export const useQueryStore = defineStore("query", () => { : mongoCommand.kind === "findOneAndReplace" ? await api.mongoFindOneAndReplace(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.replacement, mongoCommand.options) : await api.mongoFindOneAndDelete(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.options); - allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents)))); + allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents, result.total_is_exact !== false)))); mongoEditTarget = undefined; queryExecutionLog("info", "mongo-find-and-modify:done", { traceId, @@ -3602,7 +3602,7 @@ export const useQueryStore = defineStore("query", () => { // affected_rows. Use it directly so the result-grid can compute the // page count without issuing a separate COUNT query. let totalRowCountResolved = false; - if (current.result && current.mode === "query" && typeof pageLimit === "number" && !countSql && typeof current.result.affected_rows === "number" && current.result.affected_rows > current.result.rows.length) { + if (current.result && current.result.total_is_exact !== false && current.mode === "query" && typeof pageLimit === "number" && !countSql && typeof current.result.affected_rows === "number" && current.result.affected_rows > current.result.rows.length) { current.resultTotalRowCount = current.result.affected_rows; current.resultTotalRowCountLoading = false; totalRowCountResolved = true; diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 228ec06aa..004ddb71b 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -573,6 +573,8 @@ export interface QueryResult { mongo_copy_documents?: unknown[]; affected_rows: number; execution_time_ms: number; + /** Whether a backend-reported result total is exact. */ + total_is_exact?: boolean; truncated?: boolean; session_id?: string | null; has_more?: boolean; diff --git a/crates/dbx-core/src/db/document_result.rs b/crates/dbx-core/src/db/document_result.rs index b9f1bb5d9..c35b4529e 100644 --- a/crates/dbx-core/src/db/document_result.rs +++ b/crates/dbx-core/src/db/document_result.rs @@ -47,4 +47,18 @@ mod tests { .unwrap(); assert!(deserialized.total_is_exact); } + + #[test] + fn inexact_results_preserve_the_explicit_flag() { + let deserialized: DocumentQueryResult = serde_json::from_value(serde_json::json!({ + "documents": [], + "total": 10_000_000, + "total_is_exact": false, + })) + .unwrap(); + assert!(!deserialized.total_is_exact); + + let serialized = serde_json::to_value(deserialized).unwrap(); + assert_eq!(serialized.get("total_is_exact"), Some(&serde_json::json!(false))); + } } diff --git a/crates/dbx-core/src/db/mongo_driver.rs b/crates/dbx-core/src/db/mongo_driver.rs index c1d18e93b..c28593796 100644 --- a/crates/dbx-core/src/db/mongo_driver.rs +++ b/crates/dbx-core/src/db/mongo_driver.rs @@ -637,10 +637,11 @@ pub async fn find_documents( _ => doc! {}, }; - let total = if filter_doc.is_empty() { - col.estimated_document_count().await.map_err(|e| e.to_string())? - } else { + let total_is_exact = !filter_doc.is_empty(); + let total = if total_is_exact { col.count_documents(filter_doc.clone()).await.map_err(|e| e.to_string())? + } else { + col.estimated_document_count().await.map_err(|e| e.to_string())? }; let mut find = col.find(filter_doc).skip(skip).limit(limit); @@ -675,7 +676,7 @@ pub async fn find_documents( raw_documents: None, extended_documents: Some(extended_documents), total, - total_is_exact: true, + total_is_exact, }) } @@ -781,10 +782,11 @@ pub async fn find_documents_extended_json( _ => doc! {}, }; - let total = if filter_doc.is_empty() { - col.estimated_document_count().await.map_err(|e| e.to_string())? - } else { + let total_is_exact = !filter_doc.is_empty(); + let total = if total_is_exact { col.count_documents(filter_doc.clone()).await.map_err(|e| e.to_string())? + } else { + col.estimated_document_count().await.map_err(|e| e.to_string())? }; let mut find = col.find(filter_doc).skip(skip).limit(limit); @@ -820,7 +822,7 @@ pub async fn find_documents_extended_json( documents, raw_documents: None, total, - total_is_exact: true, + total_is_exact, }) } diff --git a/packages/app-tests/dataGridPagination.test.ts b/packages/app-tests/dataGridPagination.test.ts index 5723e4c2b..c3d7bdd35 100644 --- a/packages/app-tests/dataGridPagination.test.ts +++ b/packages/app-tests/dataGridPagination.test.ts @@ -1,6 +1,31 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; -import { canGoNextDataGridPage, hasCompleteLocalDataGridResult } from "../../apps/desktop/src/lib/dataGrid/dataGridPagination.ts"; +import { canGoNextDataGridPage, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal } from "../../apps/desktop/src/lib/dataGrid/dataGridPagination.ts"; + +test("estimated display totals do not become pagination bounds", () => { + assert.equal( + resolveDataGridPaginationTotal({ + serverKnownTotalRowCount: 10_000_000, + totalRowCountIsExact: false, + }), + undefined, + ); + assert.equal( + resolveDataGridPaginationTotal({ + serverKnownTotalRowCount: 10_000_000, + totalRowCountIsExact: true, + }), + 10_000_000, + ); + assert.equal( + resolveDataGridPaginationTotal({ + paginationTotalRowCount: 500, + serverKnownTotalRowCount: 10_000_000, + totalRowCountIsExact: false, + }), + 500, + ); +}); test("first query page is complete when its known total is already loaded", () => { assert.equal( diff --git a/packages/app-tests/documentStoreProvider.test.ts b/packages/app-tests/documentStoreProvider.test.ts index 2cc7c5793..ca4877c32 100644 --- a/packages/app-tests/documentStoreProvider.test.ts +++ b/packages/app-tests/documentStoreProvider.test.ts @@ -39,11 +39,12 @@ test("providers build store-specific query previews", () => { const mongo = documentStoreProviderFor("mongodb"); const elasticsearch = documentStoreProviderFor("elasticsearch"); - assert.equal(mongo.documentsLabel({ total: 7, t }), "mongo.documents:7"); + assert.equal(mongo.documentsLabel({ total: 7, totalIsExact: true, t }), "mongo.documents:7"); + assert.equal(mongo.documentsLabel({ total: 7, totalIsExact: false, t }), "≈mongo.documents:7"); assert.equal(mongo.queryPreview({ collection: "orders", filterJson: '{"city":"长治"}', sortJson: '{"createdAt":-1}', skip: 20, limit: 10 }), 'db.getCollection("orders").find({"city":"长治"}).sort({"createdAt":-1}).skip(20).limit(10)'); assert.equal(mongo.queryPreview({ collection: "order-events", filterJson: '{"city":"长治"}', sortJson: undefined, skip: 0, limit: 100 }), 'db.getCollection("order-events").find({"city":"长治"}).skip(0).limit(100)'); assert.equal(mongo.queryPreview({ collection: "orders", filterJson: '{"snowflake":{"$numberLong":"9007199254740993"}}', sortJson: undefined, skip: 0, limit: 100 }), 'db.getCollection("orders").find({"snowflake":NumberLong("9007199254740993")}).skip(0).limit(100)'); - assert.equal(elasticsearch.documentsLabel({ total: 7, t }), "Documents"); + assert.equal(elasticsearch.documentsLabel({ total: 7, totalIsExact: false, t }), "Documents"); assert.equal(elasticsearch.filterInputLabel, "filter"); assert.equal( elasticsearch.queryPreview({ collection: "orders", filterJson: '{"city":"长治"}', sortJson: '{"createdAt":-1}', skip: 20, limit: 10 }), diff --git a/packages/app-tests/mongoShellCommand.test.ts b/packages/app-tests/mongoShellCommand.test.ts index e8c4ce459..352d0cbe7 100644 --- a/packages/app-tests/mongoShellCommand.test.ts +++ b/packages/app-tests/mongoShellCommand.test.ts @@ -1020,6 +1020,15 @@ test("mongoDocumentsToQueryResult keeps aligned extended documents for copying", assert.equal(mongoDocumentsToQueryResult(documents, 5, 1, []).mongo_copy_documents, undefined); }); +test("mongoDocumentsToQueryResult preserves an inexact total marker", () => { + const result = mongoDocumentsToQueryResult([{ _id: "1" }], 5, 10_000_000, undefined, false); + + assert.equal(result.total_is_exact, false); + assert.equal(result.affected_rows, 10_000_000); + assert.equal(result.truncated, true); + assert.equal(mongoDocumentsToQueryResult([{ _id: "1" }], 5, 1).total_is_exact, undefined); +}); + test("mongoDocumentsToQueryResult displays ids without losing raw type metadata", () => { const documents = [ { _id: { $oid: "6743e4bfa3f6f84bc3fff6c8" }, name: "object id" }, diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index d5602d953..9537cb296 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -4887,6 +4887,70 @@ test("query execution keeps automatically counting total rows in the background" } }); +test("inexact backend totals do not become query pagination bounds", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const settingsStore = useSettingsStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + + settingsStore.updateEditorSettings({ autoCalculateTotalRows: false }); + connectionStore.addEphemeralConnection(conn("conn-1")); + const tabId = store.createTab("conn-1", "db", "Query", "query", "public"); + const tab = store.tabs.find((item) => item.id === tabId); + assert.ok(tab); + + globalThis.fetch = withConnectionHealthMock(async (input) => { + const url = String(input); + if (url === "/api/query/prepare-pagination-plan") { + return new Response( + JSON.stringify({ + sqlToExecute: "select id from users limit 100", + pageSql: "select id from users limit 100", + pageLimit: 100, + pageOffset: 0, + useAgentResultSession: false, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === "/api/query/execute-multi") { + return new Response( + JSON.stringify([ + { + columns: ["id"], + rows: Array.from({ length: 100 }, (_, index) => [index + 1]), + affected_rows: 10_000_000, + execution_time_ms: 1, + total_is_exact: false, + }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === "/api/query/analyze-editability") { + return new Response(JSON.stringify({ editable: false, reason: "complex-source" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("unexpected request", { status: 500 }); + }); + + try { + await store.executeTabSql(tabId, "select id from users"); + + assert.equal(tab.result?.total_is_exact, false); + assert.equal(tab.result?.affected_rows, 10_000_000); + assert.equal(tab.resultTotalRowCount, undefined); + assert.equal(tab.resultTotalRowCountLoading, false); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + for (const resultState of [ { label: "truncated", result: { truncated: true, has_more: false } }, { label: "ambiguous exhaustion", result: {} },