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

@@ -107,6 +107,7 @@ import {
} from "@/lib/table/tableClipboard";
import { buildSingleDdlExportFileContent } from "@/lib/export/ddlExport";
import { fetchTableDataForExport } from "@/lib/table/tableDataExport";
import { forceCsvTextForTemporalColumns } from "@/lib/dataGrid/columnFormatter";
import { useConnectionStore } from "@/stores/connectionStore";
import { treeNodePinIdentity, type PinnedTreeNodeIdentity } from "@/lib/app/pinnedItems";
import { useExportTracker, type ExportTask } from "@/composables/useExportTracker";
@@ -2171,11 +2172,11 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "
executePage: (sql) => api.executeQuery(props.connection.id, props.database, sql),
});
if (format === "csv") {
await api.exportQueryResultCsv(filePath, result.columns, result.rows, settingsStore.editorSettings.csvQuoteMode);
await api.exportQueryResultCsv(filePath, result.columns, forceCsvTextForTemporalColumns(result.rows, result.column_types ?? []), settingsStore.editorSettings.csvQuoteMode);
} else {
const comments = result.columns.map((name) => columnInfos?.find((column) => column.name.toLocaleLowerCase() === name.toLocaleLowerCase())?.comment);
const headerOverrides = buildXlsxHeaderOverrides(result.columns, comments, headerMode);
await api.exportQueryResultXlsx(filePath, row.name, result.columns, result.column_types ?? result.columns.map(() => ""), headerOverrides, result.rows, undefined, autoFilter);
await api.exportQueryResultXlsx(filePath, row.name, result.columns, result.column_types ?? result.columns.map(() => ""), headerOverrides, result.rows, undefined, autoFilter, settingsStore.editorSettings.globalDateTimeExportFormat || undefined);
}
toast(t("grid.exported"));
return;
@@ -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;
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { applyColumnFormatter, columnFormatterKeys, defaultIoTDBTimestampFormatter, formatIoTDBTimestampEditorValue, normalizeColumnFormatter, normalizeSupportedDateTimePattern, parseIoTDBTimestampEditorValue } from "@/lib/dataGrid/columnFormatter";
import { applyColumnFormatter, columnFormatterKeys, defaultIoTDBTimestampFormatter, forceCsvTextForTemporalColumns, formatIoTDBTimestampEditorValue, normalizeColumnFormatter, normalizeSupportedDateTimePattern, parseIoTDBTimestampEditorValue } from "@/lib/dataGrid/columnFormatter";
describe("columnFormatterKeys", () => {
// MySQL query metadata mirrors the database into the schema slot, while the
@@ -199,3 +199,25 @@ describe("defaultIoTDBTimestampFormatter", () => {
expect(parseIoTDBTimestampEditorValue("2026-08-17 10:00:00", "iotdb", "TIMESTAMP", "time_zone=Asia%2FShanghai")).toBeUndefined();
});
});
describe("forceCsvTextForTemporalColumns", () => {
it("wraps only string values in temporal columns as force-text formulas", () => {
const rows = [["2026-07-25 00:00:00.000", 48.962002, "root.a.b"]];
const columnTypes = ["TIMESTAMP", "DOUBLE", "TEXT"];
expect(forceCsvTextForTemporalColumns(rows, columnTypes)).toEqual([['="2026-07-25 00:00:00.000"', 48.962002, "root.a.b"]]);
});
it("leaves null and non-string temporal cells untouched", () => {
const rows = [[null, 1700000000000]];
const columnTypes = ["DATETIME", "DATETIME"];
expect(forceCsvTextForTemporalColumns(rows, columnTypes)).toEqual([[null, 1700000000000]]);
});
it("returns shallow copies of rows when no column is temporal", () => {
const rows = [["plain", 1]];
const columnTypes = ["VARCHAR", "INT"];
const result = forceCsvTextForTemporalColumns(rows, columnTypes);
expect(result).toEqual(rows);
expect(result[0]).not.toBe(rows[0]);
});
});
+6
View File
@@ -2637,6 +2637,11 @@ export async function exportQueryResultXlsx(
rows: readonly (readonly XlsxCellValue[])[],
numericColumnRightAlign?: boolean,
autoFilter?: boolean,
// Accepted for signature parity with the Tauri backend but unused: the web
// builder writes every cell as `inlineStr`, so a temporal value keeps the
// text the grid already formatted (milliseconds included) and never goes
// through a `numFmt` display pattern.
_dateTimeFormat?: string,
): Promise<void> {
const { buildXlsxWorkbook } = await import("@/lib/export/xlsxExport");
const workbook = buildXlsxWorkbook({
@@ -2672,6 +2677,7 @@ export async function exportQueryResultsXlsx(
autoFilter?: boolean;
}[],
autoFilter?: boolean,
_dateTimeFormat?: string,
): Promise<void> {
const { buildXlsxWorkbookMulti } = await import("@/lib/export/xlsxExport");
const workbook = buildXlsxWorkbookMulti(autoFilter === undefined ? worksheets : worksheets.map((worksheet) => ({ ...worksheet, autoFilter })));
+4
View File
@@ -5056,6 +5056,7 @@ export async function exportQueryResultXlsx(
rows: readonly (readonly XlsxCellValue[])[],
numericColumnRightAlign?: boolean,
autoFilter?: boolean,
dateTimeFormat?: string,
): Promise<void> {
return invoke("export_query_result_xlsx", {
request: {
@@ -5067,6 +5068,7 @@ export async function exportQueryResultXlsx(
rows,
numericColumnRightAlign,
autoFilter,
dateTimeFormat,
},
});
}
@@ -5083,12 +5085,14 @@ export async function exportQueryResultsXlsx(
autoFilter?: boolean;
}[],
autoFilter?: boolean,
dateTimeFormat?: string,
): Promise<void> {
return invoke("export_query_results_xlsx", {
request: {
filePath,
worksheets,
autoFilter,
dateTimeFormat,
},
});
}
@@ -452,6 +452,24 @@ export function formatTemporalRowsForExport<T extends CellValue>(rows: readonly
);
}
// CSV is untyped text: spreadsheet apps (WPS/Excel/OnlyOffice) re-guess a
// quoted date-looking cell's type on open regardless of RFC4180 quoting,
// which silently truncates/reformats it and can drop the milliseconds or
// the date part entirely. Wrapping the value as an `="..."` formula is the
// standard cross-app way to force text interpretation; the surrounding CSV
// quoting/escaping (which already doubles embedded `"`) makes it round-trip
// correctly. Only applied to already-string temporal cells so numeric/plain
// columns keep exporting as-is.
export function forceCsvTextForTemporalColumns<T extends CellValue>(rows: readonly (readonly T[])[], columnTypes: readonly (string | null | undefined)[]): T[][] {
if (!columnTypes.some((type) => isTemporalColumnType(type))) return rows.map((row) => [...row]);
return rows.map((row) =>
row.map((value, index) => {
if (typeof value !== "string" || !isTemporalColumnType(columnTypes[index])) return value;
return `="${value}"` as T;
}),
);
}
function formatTemporalValueForExport(value: CellValue, pattern: string): string {
if (typeof value === "string") {
const match = value.match(ISO_OFFSET_DATETIME_PATTERN);