fix(export): preserve datetime precision across CSV and XLSX exports
This commit is contained in:
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]);
|
||||
});
|
||||
});
|
||||
@@ -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 })));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user