fix(mongodb): avoid exact count for unfiltered queries

This commit is contained in:
Guoyu Su authored and GitHub committed 2026-07-25 19:40:09 +08:00
1 parent 7e0f77eded
commit 3da4dd5ff9
18 files changed
+304 -36

No files matched your search

@@ -388,7 +388,7 @@ public final class MongoAgent {
if (filterDoc == null) { if (filterDoc == null) {
filterDoc = new Document(); filterDoc = new Document();
} }
long total = col.countDocuments(filterDoc); CollectionTotal total = collectionTotal(col, filterDoc);
var iterable = col.find(filterDoc).skip((int) skip).limit(limit); var iterable = col.find(filterDoc).skip((int) skip).limit(limit);
if (projectionDoc != null) { if (projectionDoc != null) {
@@ -402,10 +402,7 @@ public final class MongoAgent {
for (Document document : iterable) { for (Document document : iterable) {
documents.add(bsonToJson(document)); documents.add(bsonToJson(document));
} }
Map<String, Object> result = new LinkedHashMap<>(); return documentQueryResult(documents, total);
result.put("documents", documents);
result.put("total", total);
return result;
} }
/** /**
@@ -426,7 +423,7 @@ public final class MongoAgent {
if (filterDoc == null) { if (filterDoc == null) {
filterDoc = new Document(); filterDoc = new Document();
} }
long total = col.countDocuments(filterDoc); CollectionTotal total = collectionTotal(col, filterDoc);
var iterable = col.find(filterDoc).skip((int) skip).limit(limit); var iterable = col.find(filterDoc).skip((int) skip).limit(limit);
if (projectionDoc != null) { if (projectionDoc != null) {
@@ -440,12 +437,28 @@ public final class MongoAgent {
for (Document document : iterable) { for (Document document : iterable) {
documents.add(bsonToExtendedJson(document)); documents.add(bsonToExtendedJson(document));
} }
return documentQueryResult(documents, total);
}
static CollectionTotal collectionTotal(MongoCollection<Document> collection, Document filter) {
if (filter.isEmpty()) {
return new CollectionTotal(collection.estimatedDocumentCount(), false);
}
return new CollectionTotal(collection.countDocuments(filter), true);
}
static Map<String, Object> documentQueryResult(List<?> documents, CollectionTotal total) {
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
result.put("documents", documents); result.put("documents", documents);
result.put("total", total); result.put("total", total.value());
if (!total.exact()) {
result.put("total_is_exact", false);
}
return result; return result;
} }
record CollectionTotal(long value, boolean exact) {}
private static Object countDocuments(JsonObject params) { private static Object countDocuments(JsonObject params) {
MongoClient c = requireClient(); MongoClient c = requireClient();
String database = params.get("database").getAsString(); String database = params.get("database").getAsString();
@@ -28,6 +28,7 @@ import java.util.Base64;
import java.util.Collections; import java.util.Collections;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Map;
import org.bson.Document; import org.bson.Document;
import org.bson.types.ObjectId; import org.bson.types.ObjectId;
import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeAll;
@@ -148,6 +149,53 @@ class MongoAgentTest {
assertFalse(json.getAsJsonObject("error").get("message").getAsString().contains("Unknown method")); assertFalse(json.getAsJsonObject("error").get("message").getAsString().contains("Unknown method"));
} }
@Test
void collectionTotalUsesEstimatedCountForEmptyFilter() {
List<String> calls = new ArrayList<>();
MongoCollection<Document> 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<String> calls = new ArrayList<>();
MongoCollection<Document> 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<String, Object> 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<String, Object> 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 @Test
void parsesOptionalDocumentParameters() { void parsesOptionalDocumentParameters() {
JsonObject params = new JsonObject(); JsonObject params = new JsonObject();
@@ -670,6 +718,26 @@ class MongoAgentTest {
// ─── helpers ─── // ─── helpers ───
@SuppressWarnings("unchecked")
private static MongoCollection<Document> recordingCountCollection(List<String> calls) {
return (MongoCollection<Document>) 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) { private static void assertRpcModifiedCount(MongoClient client, int id, String updateJson, boolean many) {
JsonObject params = new JsonObject(); JsonObject params = new JsonObject();
params.addProperty("database", "app"); params.addProperty("database", "app");
@@ -17,6 +17,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit"; import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle"; import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle";
import { clampDocumentPage, documentPageRequestLimit, resetElasticsearchDocumentTotals, resolveElasticsearchDocumentTotals } from "@/lib/document/elasticsearchDocumentTotals"; import { clampDocumentPage, documentPageRequestLimit, resetElasticsearchDocumentTotals, resolveElasticsearchDocumentTotals } from "@/lib/document/elasticsearchDocumentTotals";
import { canGoNextDocumentPage, resolveDocumentQueryTotals } from "@/lib/document/documentQueryTotals";
import { import {
arrayObjectAncestorPathForDocumentField, arrayObjectAncestorPathForDocumentField,
buildDocumentFilterCondition, buildDocumentFilterCondition,
@@ -135,7 +136,16 @@ let elasticsearchCountExecutionId = "";
let elasticsearchCountGeneration = 0; let elasticsearchCountGeneration = 0;
const documentStoreProvider = computed(() => documentStoreProviderFor(props.databaseType)); 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(() => { const documentRequestLimit = computed(() => {
if (documentStoreProvider.value.kind !== "elasticsearch" || paginationTotal.value === undefined) return pageSize.value; if (documentStoreProvider.value.kind !== "elasticsearch" || paginationTotal.value === undefined) return pageSize.value;
return documentPageRequestLimit(page.value, pageSize.value, paginationTotal.value); return documentPageRequestLimit(page.value, pageSize.value, paginationTotal.value);
@@ -147,7 +157,7 @@ const tableFindPaneStyle = computed(() => {
}); });
const documentFontStyle = computed(() => documentViewerFontStyle(settingsStore.editorSettings)); const documentFontStyle = computed(() => documentViewerFontStyle(settingsStore.editorSettings));
const documentStoreLabels = computed(() => ({ 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, queryPreview: documentQueryPreview.value,
})); }));
@@ -843,9 +853,10 @@ async function load() {
applyElasticsearchSearchTotal(result.total, result.total_is_exact !== false, filter); applyElasticsearchSearchTotal(result.total, result.total_is_exact !== false, filter);
} else { } else {
cancelElasticsearchCount(); cancelElasticsearchCount();
total.value = result.total; const totals = resolveDocumentQueryTotals(result.total, result.total_is_exact !== false);
totalIsExact.value = true; total.value = totals.total;
paginationTotal.value = result.total; totalIsExact.value = totals.totalIsExact;
paginationTotal.value = totals.paginationTotal;
} }
syncSelectedDocumentAfterLoad(previousSelectedIdx, previousSelectedId); syncSelectedDocumentAfterLoad(previousSelectedIdx, previousSelectedId);
} catch (e: unknown) { } catch (e: unknown) {
@@ -1313,7 +1324,7 @@ function prevPage() {
} }
function nextPage() { function nextPage() {
if ((page.value + 1) * pageSize.value >= pageTotal.value) return; if (!canGoNextPage.value) return;
page.value++; page.value++;
void load(); void load();
} }
@@ -1479,8 +1490,9 @@ defineExpose({ focusSearch });
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="page <= 0" @click="prevPage"> <Button variant="ghost" size="icon" class="h-5 w-5" :disabled="page <= 0" @click="prevPage">
<ChevronLeft class="h-3 w-3" /> <ChevronLeft class="h-3 w-3" />
</Button> </Button>
<span>{{ page + 1 }} / {{ Math.max(1, Math.ceil(pageTotal / pageSize)) }}</span> <span v-if="documentPageCount !== undefined">{{ page + 1 }} / {{ documentPageCount }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="(page + 1) * pageSize >= pageTotal" @click="nextPage"> <span v-else>{{ page + 1 }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="!canGoNextPage" @click="nextPage">
<ChevronRight class="h-3 w-3" /> <ChevronRight class="h-3 w-3" />
</Button> </Button>
</div> </div>
@@ -133,7 +133,7 @@ import { applyColumnFormatter, buildColumnFormatterKey, getSupportedTimeZoneOpti
import { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/dataGrid/dataGridTemporalEditor"; import { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/dataGrid/dataGridTemporalEditor";
import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts"; import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts";
import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter"; 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 { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions";
import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll"; import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll";
import { CANVAS_DATA_GRID_ROW_HEIGHT, dataGridSearchMatchKey, drawCanvasDataGrid } from "@/lib/dataGrid/canvasDataGridRenderer"; 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); const totalRowCountIsExact = computed(() => props.totalRowCountIsExact !== false);
// A backend can expose an exact display total while deliberately restricting // A backend can expose an exact display total while deliberately restricting
// offset pagination to a smaller safe range. // 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 // Only a server-confirmed total drives pagination — an inferred total means
// rows exist that we never fetched, so navigation must stay inside rows.length. // rows exist that we never fetched, so navigation must stay inside rows.length.
const hasKnownPaginationTotalRowCount = computed(() => typeof paginationTotalRowCount.value === "number" && paginationTotalRowCount.value >= 0); const hasKnownPaginationTotalRowCount = computed(() => typeof paginationTotalRowCount.value === "number" && paginationTotalRowCount.value >= 0);
@@ -1190,6 +1190,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
:page-limit="activeTab.resultPageLimit" :page-limit="activeTab.resultPageLimit"
:count-sql="activeTab.resultCountSql" :count-sql="activeTab.resultCountSql"
:total-row-count="activeTab.resultTotalRowCount" :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" :total-row-count-loading="activeTab.resultTotalRowCountLoading"
:on-execute-sql="async (sql: string) => emit('executeSql', sql)" :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)" :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-offset="activeTab.resultPageOffset"
:page-limit="activeTab.resultPageLimit" :page-limit="activeTab.resultPageLimit"
:total-row-count="activeTab.resultTotalRowCount" :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" :total-row-count-loading="activeTab.resultTotalRowCountLoading"
:on-execute-sql="async (sql: string) => emit('executeSql', sql)" :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)" :full-export-result="(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => queryStore.fetchTabResultForExport(activeTab.id, onProgress)"
@@ -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);
});
});
@@ -44,7 +44,7 @@ export type DocumentStoreProvider = {
kind: DocumentStoreKind; kind: DocumentStoreKind;
filterInputLabel: string; filterInputLabel: string;
sortInputLabel: string; sortInputLabel: string;
documentsLabel(options: { total: number; t: ComposerTranslation }): string; documentsLabel(options: { total: number; totalIsExact: boolean; t: ComposerTranslation }): string;
queryPreview(options: DocumentStoreQueryPreviewOptions): string; queryPreview(options: DocumentStoreQueryPreviewOptions): string;
sortInputForColumn(column: string, direction: "asc" | "desc" | null): string; sortInputForColumn(column: string, direction: "asc" | "desc" | null): string;
}; };
@@ -80,7 +80,7 @@ const mongoDocumentProvider: DocumentStoreProvider = {
kind: "mongodb", kind: "mongodb",
filterInputLabel: "find", filterInputLabel: "find",
sortInputLabel: "sort", 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 }) => { queryPreview: ({ collection, filterJson, sortJson, skip, limit }) => {
const collectionRef = `db.getCollection(${JSON.stringify(collection)})`; const collectionRef = `db.getCollection(${JSON.stringify(collection)})`;
const parts = [`${collectionRef}.find(${mongoShellPreviewLiteral(filterJson || "{}")})`]; const parts = [`${collectionRef}.find(${mongoShellPreviewLiteral(filterJson || "{}")})`];
@@ -21,6 +21,11 @@ export interface CompleteLocalDataGridResultOptions {
hasMore?: boolean; 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 { export function hasCompleteLocalDataGridResult(options: CompleteLocalDataGridResultOptions): boolean {
if (!options.isResultsContext || options.truncated === true || options.hasMore === true) return false; if (!options.isResultsContext || options.truncated === true || options.hasMore === true) return false;
if (options.pageLimit === undefined) return true; if (options.pageLimit === undefined) return true;
@@ -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;
}
@@ -645,7 +645,7 @@ export function evaluateMongoAggregateSafety(command: MongoAggregateCommand, opt
return { allowed: true }; 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[] = []; const columns: string[] = [];
for (const doc of documents) { for (const doc of documents) {
@@ -668,6 +668,7 @@ export function mongoDocumentsToQueryResult(documents: unknown[], executionTimeM
rows, rows,
mongo_documents: documents, mongo_documents: documents,
...(copyDocuments?.length === documents.length ? { mongo_copy_documents: copyDocuments } : {}), ...(copyDocuments?.length === documents.length ? { mongo_copy_documents: copyDocuments } : {}),
...(totalIsExact ? {} : { total_is_exact: false }),
affected_rows: total, affected_rows: total,
execution_time_ms: Math.max(0, Math.round(executionTimeMs)), execution_time_ms: Math.max(0, Math.round(executionTimeMs)),
truncated: total > documents.length, truncated: total > documents.length,
+5 -5
View File
@@ -3113,7 +3113,7 @@ export const useQueryStore = defineStore("query", () => {
case "find": { case "find": {
queryExecutionLog("info", "mongo-find:start", { traceId, collection: mongoCommand.collection, database: currentDatabase }); 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 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); allResults.push(queryResult);
mongoEditTarget = mongoCommands.length === 1 && !mongoCommand.projection && queryResult.columns.includes("_id") ? { collection: mongoCommand.collection, idColumn: "_id" } : undefined; mongoEditTarget = mongoCommands.length === 1 && !mongoCommand.projection && queryResult.columns.includes("_id") ? { collection: mongoCommand.collection, idColumn: "_id" } : undefined;
queryExecutionLog("info", "mongo-find:done", { queryExecutionLog("info", "mongo-find:done", {
@@ -3129,7 +3129,7 @@ export const useQueryStore = defineStore("query", () => {
case "findOne": { case "findOne": {
queryExecutionLog("info", "mongo-find-one:start", { traceId, collection: mongoCommand.collection, database: currentDatabase }); 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 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); allResults.push(queryResult);
mongoEditTarget = mongoCommands.length === 1 && !mongoCommand.projection && queryResult.columns.includes("_id") ? { collection: mongoCommand.collection, idColumn: "_id" } : undefined; mongoEditTarget = mongoCommands.length === 1 && !mongoCommand.projection && queryResult.columns.includes("_id") ? { collection: mongoCommand.collection, idColumn: "_id" } : undefined;
queryExecutionLog("info", "mongo-find-one:done", { 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 }); queryExecutionLog("info", "mongo-aggregate:start", { traceId, collection: mongoCommand.collection, database: currentDatabase });
const aggregateMaxRows = normalizeResultPageSize(pageLimit ?? options?.pagination?.limit ?? settingsStore.editorSettings.pageSize); 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); 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; mongoEditTarget = undefined;
queryExecutionLog("info", "mongo-aggregate:done", { queryExecutionLog("info", "mongo-aggregate:done", {
traceId, traceId,
@@ -3255,7 +3255,7 @@ export const useQueryStore = defineStore("query", () => {
: mongoCommand.kind === "findOneAndReplace" : mongoCommand.kind === "findOneAndReplace"
? await api.mongoFindOneAndReplace(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.replacement, mongoCommand.options) ? 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); : 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; mongoEditTarget = undefined;
queryExecutionLog("info", "mongo-find-and-modify:done", { queryExecutionLog("info", "mongo-find-and-modify:done", {
traceId, traceId,
@@ -3602,7 +3602,7 @@ export const useQueryStore = defineStore("query", () => {
// affected_rows. Use it directly so the result-grid can compute the // affected_rows. Use it directly so the result-grid can compute the
// page count without issuing a separate COUNT query. // page count without issuing a separate COUNT query.
let totalRowCountResolved = false; 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.resultTotalRowCount = current.result.affected_rows;
current.resultTotalRowCountLoading = false; current.resultTotalRowCountLoading = false;
totalRowCountResolved = true; totalRowCountResolved = true;
+2
View File
@@ -573,6 +573,8 @@ export interface QueryResult {
mongo_copy_documents?: unknown[]; mongo_copy_documents?: unknown[];
affected_rows: number; affected_rows: number;
execution_time_ms: number; execution_time_ms: number;
/** Whether a backend-reported result total is exact. */
total_is_exact?: boolean;
truncated?: boolean; truncated?: boolean;
session_id?: string | null; session_id?: string | null;
has_more?: boolean; has_more?: boolean;
+14
View File
@@ -47,4 +47,18 @@ mod tests {
.unwrap(); .unwrap();
assert!(deserialized.total_is_exact); 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)));
}
} }
+10 -8
View File
@@ -637,10 +637,11 @@ pub async fn find_documents(
_ => doc! {}, _ => doc! {},
}; };
let total = if filter_doc.is_empty() { let total_is_exact = !filter_doc.is_empty();
col.estimated_document_count().await.map_err(|e| e.to_string())? let total = if total_is_exact {
} else {
col.count_documents(filter_doc.clone()).await.map_err(|e| e.to_string())? 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); let mut find = col.find(filter_doc).skip(skip).limit(limit);
@@ -675,7 +676,7 @@ pub async fn find_documents(
raw_documents: None, raw_documents: None,
extended_documents: Some(extended_documents), extended_documents: Some(extended_documents),
total, total,
total_is_exact: true, total_is_exact,
}) })
} }
@@ -781,10 +782,11 @@ pub async fn find_documents_extended_json(
_ => doc! {}, _ => doc! {},
}; };
let total = if filter_doc.is_empty() { let total_is_exact = !filter_doc.is_empty();
col.estimated_document_count().await.map_err(|e| e.to_string())? let total = if total_is_exact {
} else {
col.count_documents(filter_doc.clone()).await.map_err(|e| e.to_string())? 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); let mut find = col.find(filter_doc).skip(skip).limit(limit);
@@ -820,7 +822,7 @@ pub async fn find_documents_extended_json(
documents, documents,
raw_documents: None, raw_documents: None,
total, total,
total_is_exact: true, total_is_exact,
}) })
} }
+26 -1
View File
@@ -1,6 +1,31 @@
import { strict as assert } from "node:assert"; import { strict as assert } from "node:assert";
import { test } from "vitest"; 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", () => { test("first query page is complete when its known total is already loaded", () => {
assert.equal( assert.equal(
+3 -2
View File
@@ -39,11 +39,12 @@ test("providers build store-specific query previews", () => {
const mongo = documentStoreProviderFor("mongodb"); const mongo = documentStoreProviderFor("mongodb");
const elasticsearch = documentStoreProviderFor("elasticsearch"); 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: "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: "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(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.filterInputLabel, "filter");
assert.equal( assert.equal(
elasticsearch.queryPreview({ collection: "orders", filterJson: '{"city":"长治"}', sortJson: '{"createdAt":-1}', skip: 20, limit: 10 }), elasticsearch.queryPreview({ collection: "orders", filterJson: '{"city":"长治"}', sortJson: '{"createdAt":-1}', skip: 20, limit: 10 }),
+9
View File
@@ -1020,6 +1020,15 @@ test("mongoDocumentsToQueryResult keeps aligned extended documents for copying",
assert.equal(mongoDocumentsToQueryResult(documents, 5, 1, []).mongo_copy_documents, undefined); 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", () => { test("mongoDocumentsToQueryResult displays ids without losing raw type metadata", () => {
const documents = [ const documents = [
{ _id: { $oid: "6743e4bfa3f6f84bc3fff6c8" }, name: "object id" }, { _id: { $oid: "6743e4bfa3f6f84bc3fff6c8" }, name: "object id" },
+64
View File
@@ -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 [ for (const resultState of [
{ label: "truncated", result: { truncated: true, has_more: false } }, { label: "truncated", result: { truncated: true, has_more: false } },
{ label: "ambiguous exhaustion", result: {} }, { label: "ambiguous exhaustion", result: {} },