fix(mongodb): export all documents from collection view

Closes #4197
This commit is contained in:
t8y2 committed 2026-08-12 05:00:59 +00:00
1 parent e865e4102e
commit 891e60c2f9
2 files changed
+172

No files matched your search

@@ -80,6 +80,7 @@ import {
serializeMongoDocumentId,
type MongoInputValue,
} from "@/lib/mongo/mongoDocumentValues";
import { mongoDocumentsToQueryResult } from "@/lib/mongo/mongoShellCommand";
import type { GridNewRowMeta } from "@/lib/dataGrid/gridNewRowPlacement";
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
import { documentDataGridColumnLayoutScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
@@ -338,6 +339,54 @@ const gridResult = computed<QueryResult>(() => {
return { columns, column_types: columnTypes, rows, mongo_documents: docs, mongo_copy_documents: copyDocuments.value, affected_rows: 0, execution_time_ms: 0, truncated: false };
});
async function exportAllMongoDocuments(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void): Promise<QueryResult | undefined> {
if (documentStoreProvider.value.kind !== "mongodb") return undefined;
const connectionId = props.connectionId;
const database = props.database;
const collection = props.collection;
const filter = currentDocumentFilter();
const sort = currentDocumentSortJson(sortInput.value);
const exportSettings = settingsStore.editorSettings;
const batchSize = Math.max(1, Math.trunc(exportSettings.exportBatchSize));
const rowLimit = exportSettings.exportRowLimitEnabled ? Math.max(0, Math.trunc(exportSettings.exportRowLimit)) : Number.POSITIVE_INFINITY;
const exportExecutionId = uuid();
const exportStartedAt = performance.now();
const exportedDocuments: JsonRecord[] = [];
let exportedCopyDocuments: JsonRecord[] | undefined = [];
let totalRows: number | null = null;
while (exportedDocuments.length < rowLimit) {
const requestLimit = Math.min(batchSize, rowLimit - exportedDocuments.length);
if (requestLimit <= 0) break;
const result = await api.documentFindDocuments(connectionId, database, collection, exportedDocuments.length, requestLimit, filter, undefined, sort, undefined, exportExecutionId);
const pageDocuments = result.documents.slice(0, requestLimit).map(asRecord);
exportedDocuments.push(...pageDocuments);
if (exportedCopyDocuments) {
if (result.extended_documents?.length === result.documents.length) {
exportedCopyDocuments.push(...result.extended_documents.slice(0, pageDocuments.length).map(asRecord));
} else {
exportedCopyDocuments = undefined;
}
}
if (result.total_is_exact !== false) totalRows = Math.min(result.total, rowLimit);
onProgress?.({ rowsExported: exportedDocuments.length, totalRows });
const reachedExactTotal = result.total_is_exact !== false && exportedDocuments.length >= result.total;
if (pageDocuments.length === 0 || pageDocuments.length < requestLimit || reachedExactTotal) break;
}
const result = mongoDocumentsToQueryResult(exportedDocuments, performance.now() - exportStartedAt, totalRows ?? exportedDocuments.length, exportedCopyDocuments, totalRows !== null);
if (result.columns.length === 0) result.columns = gridResult.value.columns;
result.column_types = mongoDocumentGridColumnTypes(exportedDocuments, result.columns);
result.affected_rows = exportedDocuments.length;
result.truncated = false;
result.has_more = false;
return result;
}
const expandedDocumentFilterFieldPaths = ref<Set<string>>(new Set());
const elasticsearchFieldTypes = computed(() => new Map(elasticsearchMappingFields.value.map((field) => [field.name, field.data_type])));
const elasticsearchFilterFieldNames = computed(() => {
@@ -1871,6 +1920,7 @@ defineExpose({ focusSearch });
:inexact-total-row-count-mode="documentStoreProvider.kind === 'mongodb' ? 'estimated' : 'at-least'"
:pagination-total-row-count="pageTotal"
:count-total-rows="countExactDocumentTotal"
:full-export-result="documentStoreProvider.kind === 'mongodb' ? exportAllMongoDocuments : undefined"
@sort="onSort"
@reload="refreshDocuments"
@paginate="(offset: number, limit: number) => paginate(offset, limit)"
@@ -15,6 +15,17 @@ const documentJsonEditor = vi.hoisted(() => ({
openSearch: vi.fn().mockReturnValue(true),
}));
const dataGrid = vi.hoisted(() => ({
fullExportResult: undefined as
| ((onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => Promise<{
columns: string[];
column_types?: string[];
rows: Array<Array<string | number | boolean | null>>;
mongo_copy_documents?: unknown[];
} | undefined>)
| undefined,
}));
const settings = vi.hoisted(() => ({
editorSettings: {
pageSize: 100,
@@ -25,6 +36,9 @@ const settings = vi.hoisted(() => ({
tableFontSize: 12,
numericColumnRightAlign: true,
confirmDangerousSqlExecution: true,
exportBatchSize: 2,
exportRowLimitEnabled: false,
exportRowLimit: 100_000,
},
updateEditorSettings: vi.fn(),
}));
@@ -64,8 +78,10 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
connectionId: { type: String, default: "" },
database: { type: String, default: "" },
columnLayoutScopeKey: { type: String, default: "" },
fullExportResult: { type: Function, default: undefined },
},
setup(props, { expose, slots }) {
dataGrid.fullExportResult = props.fullExportResult as typeof dataGrid.fullExportResult;
expose({
visibleColumnCount: 2,
displayableColumnCount: 2,
@@ -95,6 +111,7 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
"data-column-layout-scope-key": props.columnLayoutScopeKey,
"data-result-hidden-column-keys": JSON.stringify((props.result as { local_hidden_column_keys?: string[] }).local_hidden_column_keys ?? []),
"data-result-column-types": JSON.stringify((props.result as { column_types?: string[] }).column_types ?? []),
"data-result-rows": JSON.stringify((props.result as { rows?: unknown[] }).rows ?? []),
},
[
slots["search-bar"]?.({
@@ -258,6 +275,7 @@ beforeEach(async () => {
backend.cancelQuery.mockReset();
backend.ensureConnected.mockReset();
backend.documentDeleteDocument.mockReset();
dataGrid.fullExportResult = undefined;
documentJsonEditor.openSearch.mockClear();
backend.documentDeleteDocument.mockResolvedValue(undefined);
settings.editorSettings.mongoViewMode = "table";
@@ -267,6 +285,9 @@ beforeEach(async () => {
settings.editorSettings.tableFontSize = 12;
settings.editorSettings.numericColumnRightAlign = true;
settings.editorSettings.confirmDangerousSqlExecution = true;
settings.editorSettings.exportBatchSize = 2;
settings.editorSettings.exportRowLimitEnabled = false;
settings.editorSettings.exportRowLimit = 100_000;
settings.updateEditorSettings.mockReset();
settings.updateEditorSettings.mockImplementation((partial: Partial<typeof settings.editorSettings>) => Object.assign(settings.editorSettings, partial));
backend.ensureConnected.mockResolvedValue(undefined);
@@ -407,6 +428,107 @@ describe("DocumentBrowser Elasticsearch field search", () => {
});
describe("DocumentBrowser MongoDB filter value types", () => {
it("exports all matching MongoDB documents without changing the visible page", async () => {
app?.unmount();
settings.editorSettings.exportBatchSize = 2;
settings.editorSettings.exportRowLimitEnabled = true;
settings.editorSettings.exportRowLimit = 3;
backend.documentFindDocuments.mockReset();
backend.documentFindDocuments
.mockResolvedValueOnce({
documents: [{ _id: "visible", name: "Visible" }],
extended_documents: [{ _id: { $oid: "000000000000000000000001" }, name: "Visible" }],
total: 5,
total_is_exact: true,
})
.mockResolvedValueOnce({
documents: [
{ _id: "one", name: "First" },
{ _id: "two", name: "Second" },
],
extended_documents: [
{ _id: { $oid: "000000000000000000000002" }, name: "First" },
{ _id: { $oid: "000000000000000000000003" }, name: "Second" },
],
total: 5,
total_is_exact: true,
})
.mockResolvedValueOnce({
documents: [{ _id: "three", later: { $numberLong: "9007199254740993" } }],
extended_documents: [{ _id: { $oid: "000000000000000000000004" }, later: { $numberLong: "9007199254740993" } }],
total: 5,
total_is_exact: true,
});
app = createApp(DocumentBrowser, {
connectionId: "mongo-1",
database: "test",
collection: "orders",
databaseType: "mongodb",
});
app.mount(root!);
await flushUi();
const inputs = root!.querySelectorAll<HTMLTextAreaElement>("textarea");
inputs[0]!.value = '{"active":true}';
inputs[0]!.dispatchEvent(new Event("input", { bubbles: true }));
inputs[1]!.value = '{"createdAt":-1}';
inputs[1]!.dispatchEvent(new Event("input", { bubbles: true }));
await flushUi();
expect(dataGrid.fullExportResult).toBeTypeOf("function");
const progress = vi.fn();
const result = await dataGrid.fullExportResult!(progress);
expect(backend.documentFindDocuments.mock.calls.slice(1).map((call) => call.slice(0, 9))).toEqual([
["mongo-1", "test", "orders", 0, 2, '{"active":true}', undefined, '{"createdAt":-1}', undefined],
["mongo-1", "test", "orders", 2, 1, '{"active":true}', undefined, '{"createdAt":-1}', undefined],
]);
expect(result?.columns).toEqual(["_id", "name", "later"]);
expect(result?.rows).toEqual([
["one", "First", null],
["two", "Second", null],
["three", null, "9007199254740993"],
]);
expect(result?.mongo_copy_documents).toEqual([
{ _id: { $oid: "000000000000000000000002" }, name: "First" },
{ _id: { $oid: "000000000000000000000003" }, name: "Second" },
{ _id: { $oid: "000000000000000000000004" }, later: { $numberLong: "9007199254740993" } },
]);
expect(progress).toHaveBeenLastCalledWith({ rowsExported: 3, totalRows: 3 });
const visibleGrid = root!.querySelector<HTMLElement>('[data-testid="data-grid"]')!;
expect(JSON.parse(visibleGrid.dataset.resultColumnTypes ?? "[]")).toEqual(["", ""]);
expect(JSON.parse(visibleGrid.dataset.resultRows ?? "[]")).toEqual([["visible", "Visible"]]);
expect(inputs[0]!.value).toBe('{"active":true}');
expect(inputs[1]!.value).toBe('{"createdAt":-1}');
});
it("stops MongoDB full export on a short page when the total is estimated", async () => {
app?.unmount();
backend.documentFindDocuments.mockReset();
backend.documentFindDocuments
.mockResolvedValueOnce({ documents: [{ _id: "visible" }], total: 500, total_is_exact: false })
.mockResolvedValueOnce({ documents: [{ _id: "only" }], total: 500, total_is_exact: false });
app = createApp(DocumentBrowser, {
connectionId: "mongo-1",
database: "test",
collection: "orders",
databaseType: "mongodb",
});
app.mount(root!);
await flushUi();
const progress = vi.fn();
const result = await dataGrid.fullExportResult!(progress);
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(2);
expect(result?.rows).toEqual([["only"]]);
expect(progress).toHaveBeenLastCalledWith({ rowsExported: 1, totalRows: null });
});
it("does not offer the MongoDB full export callback to Elasticsearch viewers", () => {
expect(dataGrid.fullExportResult).toBeUndefined();
});
it("identifies consistently numeric MongoDB columns for shared grid alignment", async () => {
app?.unmount();
backend.documentFindDocuments.mockReset();