fix(grid): project mongodb full export to visible columns

This commit is contained in:
二丫讲梵
2026-09-19 10:54:51 +08:00
committed by GitHub
parent 73178fe677
commit 4a068a4eeb
3 changed files with 115 additions and 16 deletions
@@ -75,6 +75,8 @@ function createMongoExportState(options: {
contextColumn?: number;
syntheticContext?: boolean;
fullExportResult?: UseDataGridExportOptions["fullExportResult"];
hasCompleteLocalResult?: UseDataGridExportOptions["hasCompleteLocalResult"];
completeLocalResult?: UseDataGridExportOptions["completeLocalResult"];
externalCellValue?: UseDataGridExportOptions["externalCellValue"];
}) {
const items = options.items ?? [options.item];
@@ -106,6 +108,8 @@ function createMongoExportState(options: {
selectedRowIds: ref(selectedRowIds),
hasRowSelection: computed(() => selectedRowIds.size > 0),
fullExportResult: options.fullExportResult,
hasCompleteLocalResult: options.hasCompleteLocalResult,
completeLocalResult: options.completeLocalResult,
externalCellValue: options.externalCellValue,
};
return useDataGridExport(state);
@@ -1590,6 +1594,58 @@ describe("useDataGridExport prepared row statements", () => {
expect(exportQueryResultCsv).toHaveBeenLastCalledWith(expect.any(String), ["_id", "value"], [["1", reservedString]], expect.anything());
});
it("exports only visible Mongo columns from the full result set", async () => {
setActivePinia(createPinia());
const state = createMongoExportState({
columns: ["name"],
item: { ...row(["Visible"]), sourceIndex: 0 },
mongoDocuments: [{ _id: "1", name: "Visible", secret: "hidden" }],
fullExportResult: async () => ({
columns: ["_id", "name", "secret"],
column_types: ["", "varchar", "varchar"],
rows: [
["1", "Visible", "hidden"],
["2", "Other", "hidden-too"],
],
affected_rows: 2,
execution_time_ms: 1,
}),
});
await state.exportCsv();
expect(exportQueryResultCsv).toHaveBeenLastCalledWith(expect.any(String), ["name"], [["Visible"], ["Other"]], expect.anything());
});
it("applies visible Mongo columns when the complete result is already local", async () => {
setActivePinia(createPinia());
const completeLocalResult = {
columns: ["_id", "name", "secret"],
column_types: ["", "varchar", "varchar"],
rows: [
["1", "Visible", "hidden"],
["2", "Other", "hidden-too"],
],
mongo_copy_documents: [
{ _id: "1", name: "Visible", secret: "hidden" },
{ _id: "2", name: "Other", secret: "hidden-too" },
],
affected_rows: 2,
execution_time_ms: 1,
};
const state = createMongoExportState({
columns: ["name"],
item: { ...row(["Visible"]), sourceIndex: 0 },
mongoDocuments: [completeLocalResult.mongo_copy_documents[0]],
hasCompleteLocalResult: computed(() => true),
completeLocalResult: computed(() => completeLocalResult),
});
await state.exportCsv();
expect(exportQueryResultCsv).toHaveBeenLastCalledWith(expect.any(String), ["name"], [["Visible"], ["Other"]], expect.anything());
});
it("exports missing Mongo fields as null while retaining explicit empty strings", async () => {
const columns = ["_id", "missing", "nullable", "empty"];
const document = { _id: "1", nullable: null, empty: "" };
@@ -339,10 +339,22 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
function normalizeCompleteLocalResult(result: QueryResult): { columns: string[]; columnTypes: string[]; columnComments: Array<string | undefined>; rows: CellValue[][]; mongoCopyDocuments?: unknown[] } {
const editorSettings = useSettingsStore().editorSettings;
if (databaseType.value === "mongodb") {
const projected = projectResultColumns(result, columns.value);
const rows = editorSettings.exportRowLimitEnabled ? projected.rows.slice(0, editorSettings.exportRowLimit) : projected.rows;
return {
columns: projected.columns,
columnTypes: projected.columnTypes,
columnComments: commentsForExportColumns(projected.columns),
rows,
mongoCopyDocuments: result.mongo_copy_documents?.slice(0, rows.length),
};
}
const hiddenColumnIndexes = new Set(result.hidden_column_indexes ?? []);
const exportedColumnIndexes = result.columns.map((_, index) => index).filter((index) => !hiddenColumnIndexes.has(index));
const hasHiddenColumns = exportedColumnIndexes.length !== result.columns.length;
const editorSettings = useSettingsStore().editorSettings;
const rows = editorSettings.exportRowLimitEnabled ? result.rows.slice(0, editorSettings.exportRowLimit) : result.rows;
// Internal key columns are query-only metadata. Keep every user column,
@@ -366,6 +378,33 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
});
}
function projectResultColumns(
result: QueryResult,
targetColumns: readonly string[],
): {
columns: string[];
columnTypes: string[];
rows: CellValue[][];
spatialColumns?: QueryResult["spatial_columns"];
spatialValues?: QueryResult["spatial_values"];
} {
const sourceIndexes = targetColumns.map((column) => result.columns.indexOf(column));
const targetIndexBySource = new Map<number, number>();
sourceIndexes.forEach((sourceIndex, targetIndex) => {
if (sourceIndex >= 0) targetIndexBySource.set(sourceIndex, targetIndex);
});
return {
columns: [...targetColumns],
columnTypes: sourceIndexes.map((sourceIndex) => (sourceIndex >= 0 ? (result.column_types?.[sourceIndex] ?? "") : "")),
rows: result.rows.map((row) => sourceIndexes.map((sourceIndex) => (sourceIndex >= 0 ? (row[sourceIndex] ?? null) : null))),
spatialColumns: result.spatial_columns?.flatMap((column) => {
const targetIndex = targetIndexBySource.get(column.column_index);
return targetIndex === undefined ? [] : [{ ...column, column_index: targetIndex }];
}),
spatialValues: result.spatial_values?.map((row) => sourceIndexes.map((sourceIndex) => (sourceIndex >= 0 ? (row[sourceIndex] ?? null) : null))),
};
}
function mongoLocalRowsForJson(items: RowItem[]): CellValue[][] {
return items.map((item) => {
const document = rowToJsonObject(item);
@@ -396,27 +435,34 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
if (useFullExport && rowIds === undefined && fullExportResult && !hasCompleteLocalResult?.value) {
const result = await fullExportResult(onProgress);
if (result) {
const columnComments = buildXlsxHeaderOverrides(result.columns, commentsForExportColumns(result.columns), headerMode);
const projected = databaseType.value === "mongodb" ? projectResultColumns(result, columns.value) : undefined;
const exportedColumns = projected?.columns ?? result.columns;
const exportedColumnTypes = projected?.columnTypes ?? result.column_types ?? [];
const exportedRows = projected?.rows ?? result.rows;
const exportedSpatialColumns = projected?.spatialColumns ?? result.spatial_columns;
const exportedSpatialValues = projected?.spatialValues ?? result.spatial_values;
const columnComments = buildXlsxHeaderOverrides(exportedColumns, commentsForExportColumns(exportedColumns), headerMode);
return {
...applyGlobalDateTimeExportFormat(
// fullExportResult returns source rows, not the collection grid's
// marker-encoded rows. Applying externalCellValue here would
// mistake a real BSON string in the reserved namespace for a grid
// marker and corrupt the exported value.
{ columns: result.columns, columnTypes: result.column_types ?? [], rows: preserveMongoExtendedJson ? mongoDocumentRowsForJson(result.columns, result.rows, result.mongo_copy_documents) : result.rows },
{ columns: exportedColumns, columnTypes: exportedColumnTypes, rows: preserveMongoExtendedJson ? mongoDocumentRowsForJson(exportedColumns, exportedRows, result.mongo_copy_documents) : exportedRows },
formatDateTime && !preserveMongoExtendedJson,
),
columnComments,
spatialColumns: result.spatial_columns,
spatialValues: result.spatial_values,
spatialColumns: exportedSpatialColumns,
spatialValues: exportedSpatialValues,
};
}
}
// The full result is already in memory — export the raw QueryResult (all
// rows, all columns, committed values) so "export all data" matches the
// original re-run-SQL semantics. displayItems only covers visible columns
// and reflects client-side filters/search and unsaved edits, which would
// silently change what the export contains.
// The full result is already in memory — export all rows with the source
// result's committed values. MongoDB applies the current visible-column
// projection above; other result types retain their existing all-column
// semantics. displayItems only covers visible columns and reflects
// client-side filters/search and unsaved edits, which would silently
// change what the export contains.
if (useFullExport && rowIds === undefined && hasCompleteLocalResult?.value && completeLocalResult?.value) {
const normalized = normalizeCompleteLocalResult(completeLocalResult.value);
const columnComments = buildXlsxHeaderOverrides(normalized.columns, normalized.columnComments, headerMode);
+3 -6
View File
@@ -4,10 +4,7 @@ import { beforeEach, test, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { useSettingsStore } from "../../apps/desktop/src/stores/settingsStore.ts";
import type { DataGridTableMeta } from "../../apps/desktop/src/lib/dataGrid/dataGridSql.ts";
import {
DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS,
type DataGridExtractorOptions,
} from "../../apps/desktop/src/lib/dataGrid/dataGridCopyExtractor.ts";
import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, type DataGridExtractorOptions } from "../../apps/desktop/src/lib/dataGrid/dataGridCopyExtractor.ts";
import type { DatabaseType, QueryResult } from "../../apps/desktop/src/types/database.ts";
const apiMock = vi.hoisted(() => ({
@@ -409,7 +406,7 @@ test("full query result CSV export streams through the backend without loading a
});
test("MongoDB full query result CSV export uses the full-result fallback", async () => {
const { composable, fullExportResult, queryResultExportRequest } = buildExportHarness({ databaseType: "mongodb" });
const { composable, fullExportResult, queryResultExportRequest } = buildExportHarness({ databaseType: "mongodb", columns: ["_id", "name"] });
fullExportResult.mockResolvedValueOnce({
columns: ["_id", "name"],
rows: [["1", "Ada"]],
@@ -719,7 +716,7 @@ test("local SQL export asks the backend to drop primary key columns", async () =
truncated: false,
has_more: false,
};
apiMock.buildExportSqlInsert.mockResolvedValueOnce("INSERT INTO \"users\" (\"name\") VALUES ('Ada');");
apiMock.buildExportSqlInsert.mockResolvedValueOnce('INSERT INTO "users" ("name") VALUES (\'Ada\');');
try {
const { composable } = buildExportHarness({