fix(mongodb): avoid exact count for unfiltered queries
This commit is contained in:
parent
7e0f77eded
commit
3da4dd5ff9
|
|
@ -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<String, Object> 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<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<>();
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
void parsesOptionalDocumentParameters() {
|
||||
JsonObject params = new JsonObject();
|
||||
|
|
@ -670,6 +718,26 @@ class MongoAgentTest {
|
|||
|
||||
// ─── 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) {
|
||||
JsonObject params = new JsonObject();
|
||||
params.addProperty("database", "app");
|
||||
|
|
|
|||
|
|
@ -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 });
|
|||
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="page <= 0" @click="prevPage">
|
||||
<ChevronLeft class="h-3 w-3" />
|
||||
</Button>
|
||||
<span>{{ page + 1 }} / {{ Math.max(1, Math.ceil(pageTotal / pageSize)) }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="(page + 1) * pageSize >= pageTotal" @click="nextPage">
|
||||
<span v-if="documentPageCount !== undefined">{{ page + 1 }} / {{ documentPageCount }}</span>
|
||||
<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" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
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 || "{}")})`];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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: {} },
|
||||
|
|
|
|||
Loading…
Reference in New Issue