fix(export): preserve datetime precision across CSV and XLSX exports

This commit is contained in:
Melody Ruch authored and GitHub committed 2026-09-05 09:22:08 +08:00
1 parent ffe1e58fbb
commit ab6a7645f6
14 files changed
+438 -43

No files matched your search

@@ -129,6 +129,7 @@ describe("useDataGridExport XLSX headers", () => {
expect.any(String),
expect.arrayContaining([expect.objectContaining({ sheetName: "Ids", columnComments: ["id (Identifier)"], autoFilter: false }), expect.objectContaining({ sheetName: "Names", columnComments: ["name (Display name)"], autoFilter: false })]),
false,
undefined,
);
});
@@ -141,6 +142,6 @@ describe("useDataGridExport XLSX headers", () => {
await state.exportAllResultsXlsx();
expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledWith(expect.any(String), expect.arrayContaining([expect.objectContaining({ sheetName: "Ids", columnComments: ["id (Joined identifier)"], autoFilter: false })]), false);
expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledWith(expect.any(String), expect.arrayContaining([expect.objectContaining({ sheetName: "Ids", columnComments: ["id (Joined identifier)"], autoFilter: false })]), false, undefined);
});
});
@@ -24,7 +24,7 @@ import type { DatabaseType, QueryResult } from "@/types/database";
import type { QueryResultExportRequest } from "@/lib/backend/api";
import { usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
import { buildXlsxSqlWorksheet } from "@/lib/export/xlsxSqlSheet";
import { formatTemporalRowsForExport } from "@/lib/dataGrid/columnFormatter";
import { forceCsvTextForTemporalColumns, formatTemporalRowsForExport } from "@/lib/dataGrid/columnFormatter";
import { translateBackendError } from "@/i18n/backend-errors";
import XlsxHeaderDialog from "@/components/export/XlsxHeaderDialog.vue";
import i18n from "@/i18n";
@@ -407,8 +407,12 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
async function writeXlsxResult(outputPath: string, result: { columns: string[]; columnTypes: string[]; columnComments?: (string | null)[]; rows: CellValue[][] }, includeSqlSheet: boolean, autoFilter: boolean) {
const sqlWorksheet = includeSqlSheet ? buildXlsxSqlWorksheet([{ sql: currentExportSql() || "" }]) : undefined;
const rightAlign = useSettingsStore().editorSettings.numericColumnRightAlign;
// The rows are already rendered with the global export pattern, but the
// workbook still needs the pattern itself so its numFmt matches; without it
// a `SSS` pattern silently displays as `yyyy-mm-dd hh:mm:ss`.
const dateTimeFormat = useSettingsStore().editorSettings.globalDateTimeExportFormat || undefined;
if (!sqlWorksheet) {
await api.exportQueryResultXlsx(outputPath, currentXlsxSheetName(), result.columns, result.columnTypes, result.columnComments, result.rows, rightAlign, autoFilter);
await api.exportQueryResultXlsx(outputPath, currentXlsxSheetName(), result.columns, result.columnTypes, result.columnComments, result.rows, rightAlign, autoFilter, dateTimeFormat);
return;
}
await api.exportQueryResultsXlsx(
@@ -426,6 +430,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
{ ...sqlWorksheet, autoFilter },
],
autoFilter,
dateTimeFormat,
);
}
@@ -733,8 +738,11 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
});
// Hand the raw rows straight to the Rust command. Formatting (NULL→"",
// bool/number→text, etc.) happens there on a spawn_blocking thread, so
// we avoid mapping every cell synchronously on the UI thread.
const rows = result.rows;
// we avoid mapping every cell synchronously on the UI thread. Temporal
// columns are the one exception: force-text them here (see
// forceCsvTextForTemporalColumns) so WPS/Excel-style CSV import can't
// re-guess and truncate/reformat a date-looking string.
const rows = forceCsvTextForTemporalColumns(result.rows, result.columnTypes);
if (needsFullExport && exportProgressState) {
exportProgressState.value = {
...exportProgressState.value,
@@ -795,7 +803,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
outputPath = path as string;
}
const result = await resultToExport(undefined, undefined, false);
await api.exportQueryResultCsv(outputPath, result.columns, result.rows, useSettingsStore().editorSettings.csvQuoteMode);
const rows = forceCsvTextForTemporalColumns(result.rows, result.columnTypes);
await api.exportQueryResultCsv(outputPath, result.columns, rows, useSettingsStore().editorSettings.csvQuoteMode);
toast(t("grid.exported"));
} catch (e: any) {
toast(t("grid.exportFailed", { message: translateBackendError(t, e) }), 5000);
@@ -1079,7 +1088,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
autoFilter: exportOptions.autoFilter,
}));
const sqlWorksheet = includeSqlSheet ? buildXlsxSqlWorksheet(sheets.map((sheet) => ({ resultName: sheet.sheetName, sql: sheet.sql || sheet.result.sourceStatement || "" }))) : undefined;
await api.exportQueryResultsXlsx(outputPath, sqlWorksheet ? [...worksheets, { ...sqlWorksheet, autoFilter: exportOptions.autoFilter }] : worksheets, exportOptions.autoFilter);
await api.exportQueryResultsXlsx(outputPath, sqlWorksheet ? [...worksheets, { ...sqlWorksheet, autoFilter: exportOptions.autoFilter }] : worksheets, exportOptions.autoFilter, exportPattern || undefined);
toast(t("grid.exported"));
} catch (e: any) {
toast(t("grid.exportFailed", { message: translateBackendError(t, e) }), 5000);
@@ -15,6 +15,7 @@ import { joinExportedDdls } from "@/lib/export/ddlExport";
import { translateBackendError } from "@/i18n/backend-errors";
import { sidebarStructureExportTargets, sidebarTableDataExportTargets } from "@/lib/sidebar/sidebarExportRuntime";
import { fetchTableDataForExport } from "@/lib/table/tableDataExport";
import { forceCsvTextForTemporalColumns } from "@/lib/dataGrid/columnFormatter";
import XlsxHeaderDialog from "@/components/export/XlsxHeaderDialog.vue";
import { buildXlsxHeaderOverrides, hasXlsxHeaderComments, type XlsxExportOptions, type XlsxHeaderMode } from "@/lib/export/xlsxHeader";
import { isLoadingStructurePreview, showStructureDocCopyDialog, showStructurePreviewDialog, structureDocCopyText, structureDocCopyTitle, structurePreviewDefaultFileName, structurePreviewError, structurePreviewSql, structurePreviewTitle } from "@/components/sidebar/sidebarTreeDialogState";
@@ -398,11 +399,11 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
executePage: (sql) => api.executeQuery(connectionId, database, sql),
});
if (format === "csv") {
await api.exportQueryResultCsv(outputPath, result.columns, result.rows, target.csvQuoteMode);
await api.exportQueryResultCsv(outputPath, result.columns, forceCsvTextForTemporalColumns(result.rows, result.column_types ?? []), target.csvQuoteMode);
} else {
const comments = result.columns.map((name) => exportColumnInfos?.find((column) => column.name.toLocaleLowerCase() === name.toLocaleLowerCase())?.comment);
const headerOverrides = buildXlsxHeaderOverrides(result.columns, comments, headerMode);
await api.exportQueryResultXlsx(outputPath, target.tableName, result.columns, result.column_types ?? result.columns.map(() => ""), headerOverrides, result.rows, undefined, autoFilter);
await api.exportQueryResultXlsx(outputPath, target.tableName, result.columns, result.column_types ?? result.columns.map(() => ""), headerOverrides, result.rows, undefined, autoFilter, settingsStore.editorSettings.globalDateTimeExportFormat || undefined);
}
currentTask.status = "Done";
currentTask.rowsExported = result.rows.length;