mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
fix(grid): project mongodb full export to visible columns
This commit is contained in:
@@ -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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user