diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index d17a9d6b63..24e8c039b5 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -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; diff --git a/apps/desktop/src/composables/__tests__/useDataGridExport.xlsx.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridExport.xlsx.spec.ts index acb79e5f92..393fafb929 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridExport.xlsx.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridExport.xlsx.spec.ts @@ -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); }); }); diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index 337baec568..5878952fb1 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -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); diff --git a/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts b/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts index b5a064c7dd..973a2d4bd1 100644 --- a/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts +++ b/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts @@ -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; diff --git a/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts index ee9ba3d7dc..0abcfa274e 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts @@ -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]); + }); +}); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index d3cd88f120..fde9ecf533 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -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 { 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 { const { buildXlsxWorkbookMulti } = await import("@/lib/export/xlsxExport"); const workbook = buildXlsxWorkbookMulti(autoFilter === undefined ? worksheets : worksheets.map((worksheet) => ({ ...worksheet, autoFilter }))); diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 9228afd8d1..498d9a052a 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -5056,6 +5056,7 @@ export async function exportQueryResultXlsx( rows: readonly (readonly XlsxCellValue[])[], numericColumnRightAlign?: boolean, autoFilter?: boolean, + dateTimeFormat?: string, ): Promise { 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 { return invoke("export_query_results_xlsx", { request: { filePath, worksheets, autoFilter, + dateTimeFormat, }, }); } diff --git a/apps/desktop/src/lib/dataGrid/columnFormatter.ts b/apps/desktop/src/lib/dataGrid/columnFormatter.ts index 295065d0fc..2ce9e0d0d7 100644 --- a/apps/desktop/src/lib/dataGrid/columnFormatter.ts +++ b/apps/desktop/src/lib/dataGrid/columnFormatter.ts @@ -452,6 +452,24 @@ export function formatTemporalRowsForExport(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(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); diff --git a/crates/dbx-core/src/query_result_export.rs b/crates/dbx-core/src/query_result_export.rs index 05340759f7..a97b50f184 100644 --- a/crates/dbx-core/src/query_result_export.rs +++ b/crates/dbx-core/src/query_result_export.rs @@ -907,10 +907,11 @@ async fn export_query_result_core_inner( result.rows.truncate(this_page); } let row_count = result.rows.len(); - let formatted_rows = crate::temporal_format::format_temporal_export_rows_with_string_types_cow( + let formatted_rows = crate::temporal_format::format_temporal_export_rows_with_string_types_for_csv_cow( &result.rows, &column_types, request.date_time_format.as_deref(), + format == "csv", ); if format == "csv" || format == "txt" { @@ -1129,10 +1130,11 @@ async fn try_export_postgres_query_result_stream( } } crate::db::postgres::PostgresQueryStreamItem::Row(row) => { - let formatted = crate::temporal_format::format_temporal_export_row_with_string_types_cow( + let formatted = crate::temporal_format::format_temporal_export_row_with_string_types_for_csv_cow( &row, &temporal_column_types, request.date_time_format.as_deref(), + format == "csv", ); if let Some(writer) = sql_writer.as_mut() { writer.write_row(formatted.into_owned(), None)?; @@ -1370,10 +1372,11 @@ async fn try_export_mysql_query_result_stream( } } crate::db::mysql::MySqlQueryStreamItem::Row(row) => { - let formatted = crate::temporal_format::format_temporal_export_row_with_string_types_cow( + let formatted = crate::temporal_format::format_temporal_export_row_with_string_types_for_csv_cow( &row, &temporal_column_types, request.date_time_format.as_deref(), + format == "csv", ); if let Some(writer) = sql_writer.as_mut() { writer.write_row(formatted.into_owned(), None)?; @@ -1590,10 +1593,11 @@ async fn try_export_clickhouse_query_result_stream( } } crate::db::clickhouse_driver::ClickHouseQueryStreamItem::Row(row) => { - let formatted = crate::temporal_format::format_temporal_export_row_with_string_types_cow( + let formatted = crate::temporal_format::format_temporal_export_row_with_string_types_for_csv_cow( &row, &temporal_column_types, request.date_time_format.as_deref(), + format == "csv", ); if let Some(writer) = sql_writer.as_mut() { writer.write_row(formatted.into_owned(), None)?; @@ -1775,10 +1779,11 @@ async fn try_export_sqlserver_query_result_stream( } } crate::db::sqlserver::SqlServerStreamItem::Row(row) => { - let formatted = crate::temporal_format::format_temporal_export_row_with_string_types_cow( + let formatted = crate::temporal_format::format_temporal_export_row_with_string_types_for_csv_cow( row, &temporal_column_types, request.date_time_format.as_deref(), + format == "csv", ); if let Some(writer) = sql_writer.as_mut() { writer.write_row(formatted.into_owned(), None)?; diff --git a/crates/dbx-core/src/table_export.rs b/crates/dbx-core/src/table_export.rs index 6e0cbf9b5d..154ceec8b0 100644 --- a/crates/dbx-core/src/table_export.rs +++ b/crates/dbx-core/src/table_export.rs @@ -1010,10 +1010,11 @@ async fn try_export_native_table_stream( &cancelled, cancel_token.clone(), |row| { - let formatted = crate::temporal_format::format_temporal_export_row_cow( + let formatted = crate::temporal_format::format_temporal_export_row_for_csv_cow( row, column_types, request.date_time_format.as_deref(), + request.format.eq_ignore_ascii_case("csv"), ); write_table_text_row(&mut file, true, formatted.as_ref(), &mut row_buffer, request.csv_quote_mode)?; rows_exported += 1; @@ -1616,10 +1617,11 @@ async fn export_table_data_core_inner( if row_count == 0 { break; } - let formatted_rows = crate::temporal_format::format_temporal_export_rows_cow( + let formatted_rows = crate::temporal_format::format_temporal_export_rows_for_csv_cow( &result.rows, &column_types, request.date_time_format.as_deref(), + request.format.eq_ignore_ascii_case("csv"), ); if is_first_batch { diff --git a/crates/dbx-core/src/temporal_format.rs b/crates/dbx-core/src/temporal_format.rs index 898f04d233..acc1be2715 100644 --- a/crates/dbx-core/src/temporal_format.rs +++ b/crates/dbx-core/src/temporal_format.rs @@ -1,4 +1,4 @@ -use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, Timelike}; +use chrono::{DateTime, Datelike, FixedOffset, Local, NaiveDate, NaiveDateTime, NaiveTime, Timelike}; use serde_json::Value; use std::borrow::Cow; use std::fmt::Write as _; @@ -122,6 +122,31 @@ fn parse_known_temporal(value: &str) -> Option { None } +/// A driver may hand back a bare epoch integer for a column it still reports as +/// a temporal type — IoTDB's `TIMESTAMP(ms)` does exactly that. The data grid's +/// export formatter already renders those (`columnFormatter.ts`, +/// `unit: "auto"`), which is why "export current page" shows a real date while +/// the Rust streaming exporters used to write the raw epoch. Mirror that +/// formatter's rule exactly so both paths agree: accept only 10 digits +/// (seconds) or 13 digits (milliseconds), require the result to land in +/// 1970..=2100, and render in the local zone the same way `dayjs(ms).format()` +/// does. Widening this (microseconds, nanoseconds, other digit counts) would +/// re-introduce the mismatch in the opposite direction. +fn parse_epoch_temporal(value: &str) -> Option { + let digits = value.strip_prefix('-').unwrap_or(value); + if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + let parsed = value.parse::().ok()?; + let milliseconds = match digits.len() { + 10 => parsed.checked_mul(1000)?, + 13 => parsed, + _ => return None, + }; + let local = DateTime::from_timestamp_millis(milliseconds)?.with_timezone(&Local).naive_local(); + (1970..=2100).contains(&local.year()).then_some(local) +} + fn parse_temporal(value: &str, preferred_pattern: Option<&str>) -> Option { preferred_pattern .filter(|pattern| !pattern.trim().is_empty()) @@ -141,7 +166,8 @@ pub(crate) fn excel_temporal_serial( // timezone-bearing and time-only values as text. TemporalKind::DateTimeWithTimeZone | TemporalKind::Time => return None, }; - let parsed = parse_temporal(value.trim(), preferred_pattern)?; + let parsed = parse_temporal(value.trim(), preferred_pattern) + .or_else(|| parse_epoch_temporal(value.trim()).map(ParsedTemporal::DateTime))?; let (date, time) = match (kind, parsed) { (ExcelTemporalKind::Date, ParsedTemporal::Date(date)) => (date, NaiveTime::default()), (ExcelTemporalKind::Date, ParsedTemporal::DateTime(value)) => (value.date(), value.time()), @@ -188,6 +214,7 @@ pub fn format_temporal_export_value(value: &Value, data_type: Option<&str>, patt return value.clone(); }; parse_known_temporal(raw) + .or_else(|| parse_epoch_temporal(raw.trim()).map(ParsedTemporal::DateTime)) .and_then(|parsed| format_parsed(parsed, pattern)) .map(Value::String) .unwrap_or_else(|| value.clone()) @@ -301,6 +328,125 @@ pub fn format_temporal_export_rows_with_string_types( format_temporal_export_rows_with_string_types_cow(rows, column_types, pattern).into_owned() } +/// CSV is untyped text: spreadsheet apps (WPS/Excel/OnlyOffice) re-guess a +/// quoted date-looking cell's type on open regardless of RFC4180 quoting, +/// silently truncating/reformatting it (dropping the date part or the +/// milliseconds). Wrapping the value as an `="..."` formula is the standard +/// cross-app way to force text interpretation; the existing CSV +/// quoting/escaping (which doubles embedded `"`) turns it into valid CSV for +/// free. Applied whenever the column is a temporal type, independent of +/// whether `pattern` re-formats the value or the raw driver string passes +/// through unchanged (`保持数据库原始值`) — both shapes are equally prone to +/// spreadsheet mis-parsing. +fn wrap_csv_force_text(value: Value) -> Value { + match value { + Value::String(text) => Value::String(format!("=\"{text}\"")), + other => other, + } +} + +pub fn format_temporal_export_row_for_csv_cow<'a>( + row: &'a [Value], + column_types: &[Option], + pattern: Option<&str>, + force_csv_text: bool, +) -> Cow<'a, [Value]> { + if !force_csv_text { + return format_temporal_export_row_cow(row, column_types, pattern); + } + if !column_types.iter().any(|data_type| temporal_kind(data_type.as_deref()).is_some()) { + return Cow::Borrowed(row); + } + let active_pattern = active_temporal_pattern(pattern); + Cow::Owned( + row.iter() + .enumerate() + .map(|(index, value)| { + let data_type = column_types.get(index).and_then(|data_type| data_type.as_deref()); + if temporal_kind(data_type).is_none() { + return value.clone(); + } + let value = match active_pattern { + Some(pattern) => format_temporal_export_value(value, data_type, Some(pattern)), + None => value.clone(), + }; + wrap_csv_force_text(value) + }) + .collect(), + ) +} + +pub fn format_temporal_export_rows_for_csv_cow<'a>( + rows: &'a [Vec], + column_types: &[Option], + pattern: Option<&str>, + force_csv_text: bool, +) -> Cow<'a, [Vec]> { + if !force_csv_text { + return format_temporal_export_rows_cow(rows, column_types, pattern); + } + if !column_types.iter().any(|data_type| temporal_kind(data_type.as_deref()).is_some()) { + return Cow::Borrowed(rows); + } + Cow::Owned( + rows.iter() + .map(|row| format_temporal_export_row_for_csv_cow(row, column_types, pattern, true).into_owned()) + .collect(), + ) +} + +pub fn format_temporal_export_row_with_string_types_for_csv_cow<'a>( + row: &'a [Value], + column_types: &[String], + pattern: Option<&str>, + force_csv_text: bool, +) -> Cow<'a, [Value]> { + if !force_csv_text { + return format_temporal_export_row_with_string_types_cow(row, column_types, pattern); + } + if !column_types.iter().any(|data_type| temporal_kind(Some(data_type)).is_some()) { + return Cow::Borrowed(row); + } + let active_pattern = active_temporal_pattern(pattern); + Cow::Owned( + row.iter() + .enumerate() + .map(|(index, value)| { + let data_type = column_types.get(index).map(String::as_str); + if temporal_kind(data_type).is_none() { + return value.clone(); + } + let value = match active_pattern { + Some(pattern) => format_temporal_export_value(value, data_type, Some(pattern)), + None => value.clone(), + }; + wrap_csv_force_text(value) + }) + .collect(), + ) +} + +pub fn format_temporal_export_rows_with_string_types_for_csv_cow<'a>( + rows: &'a [Vec], + column_types: &[String], + pattern: Option<&str>, + force_csv_text: bool, +) -> Cow<'a, [Vec]> { + if !force_csv_text { + return format_temporal_export_rows_with_string_types_cow(rows, column_types, pattern); + } + if !column_types.iter().any(|data_type| temporal_kind(Some(data_type)).is_some()) { + return Cow::Borrowed(rows); + } + Cow::Owned( + rows.iter() + .map(|row| { + format_temporal_export_row_with_string_types_for_csv_cow(row, column_types, pattern, true).into_owned() + }) + .collect(), + ) +} + pub(crate) fn normalize_temporal_import_value_cow<'a>( value: &'a Value, data_type: Option<&str>, @@ -362,6 +508,49 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn formats_driver_epoch_timestamps_like_the_current_page_export() { + // IoTDB hands back `strconv.FormatInt(epoch, 10)` for a TIMESTAMP(ms) + // column, so the streaming exporters used to write the raw epoch while + // "export current page" (formatted in the frontend) showed a real date. + let pattern = Some("YYYY-MM-DD HH:mm:ss.SSS"); + let formatted = format_temporal_export_value(&json!("1785042135456"), Some("TIMESTAMP(ms)"), pattern); + let rendered = formatted.as_str().expect("formatted value stays a string"); + assert_ne!(rendered, "1785042135456", "epoch must not pass through unformatted"); + assert!(rendered.ends_with(":15.456"), "milliseconds must survive: {rendered}"); + + // Ten digits are seconds, matching columnFormatter.ts `unit: "auto"`. + let seconds = format_temporal_export_value(&json!("1785042135"), Some("TIMESTAMP(ms)"), pattern); + assert_eq!(seconds.as_str().expect("string").get(..4), rendered.get(..4)); + + // The CSV force-text wrapper now wraps a real date instead of an epoch. + let rows = vec![vec![json!("1785042135456"), json!("device-a")]]; + let types = vec!["TIMESTAMP(ms)".to_string(), "TEXT".to_string()]; + let wrapped = format_temporal_export_rows_with_string_types_for_csv_cow(&rows, &types, pattern, true); + let cell = wrapped[0][0].as_str().expect("string"); + assert!(cell.starts_with("=\"") && cell.ends_with(":15.456\""), "cell={cell}"); + } + + #[test] + fn leaves_non_auto_epoch_shapes_untouched() { + let pattern = Some("YYYY-MM-DD HH:mm:ss.SSS"); + // Microsecond/nanosecond epochs are outside the shapes the data grid + // formatter accepts; formatting them here would make the streaming + // export disagree with "export current page" in the other direction. + for raw in ["1785042135456789", "1785042135456789012", "17850421", "not-a-number"] { + assert_eq!( + format_temporal_export_value(&json!(raw), Some("TIMESTAMP(ms)"), pattern), + json!(raw), + "raw={raw}" + ); + } + // A plain integer column is not temporal, so it is never touched. + assert_eq!( + format_temporal_export_value(&json!("1785042135456"), Some("BIGINT"), pattern), + json!("1785042135456") + ); + } + #[test] fn normalizes_unpadded_slash_dates_for_import() { assert_eq!( @@ -391,6 +580,59 @@ mod tests { ); } + #[test] + fn csv_export_wraps_formatted_temporal_values_as_force_text_formulas() { + let row = vec![json!(1), json!("2024-02-25 13:02:15"), json!("plain text")]; + let column_types = [Some("NUMBER".into()), Some("TIMESTAMP".into()), Some("VARCHAR2".into())]; + assert_eq!( + format_temporal_export_row_for_csv_cow(&row, &column_types, Some("YYYY/M/D HH:mm:ss"), true).into_owned(), + vec![json!(1), json!("=\"2024/2/25 13:02:15\""), json!("plain text")] + ); + } + + #[test] + fn csv_export_wraps_raw_temporal_values_even_without_a_configured_pattern() { + // "保持数据库原始值": no export pattern configured, but the raw driver + // string is still date-shaped and equally prone to spreadsheet + // mis-parsing, so the force-text wrap must still apply. + let row = vec![json!("2024-02-25 13:02:15.000")]; + let column_types = [Some("DATETIME".into())]; + assert_eq!( + format_temporal_export_row_for_csv_cow(&row, &column_types, None, true).into_owned(), + vec![json!("=\"2024-02-25 13:02:15.000\"")] + ); + } + + #[test] + fn csv_export_does_not_wrap_null_temporal_values() { + let row = vec![json!(Value::Null)]; + let column_types = [Some("DATE".into())]; + assert_eq!( + format_temporal_export_row_for_csv_cow(&row, &column_types, None, true).into_owned(), + vec![json!(Value::Null)] + ); + } + + #[test] + fn csv_export_force_text_disabled_matches_plain_temporal_formatting() { + let row = vec![json!("2024-02-25 13:02:15")]; + let column_types = [Some("TIMESTAMP".into())]; + assert_eq!( + format_temporal_export_row_for_csv_cow(&row, &column_types, Some("YYYY/M/D HH:mm:ss"), false).into_owned(), + format_temporal_export_row(&row, &column_types, Some("YYYY/M/D HH:mm:ss")) + ); + } + + #[test] + fn csv_export_with_string_types_wraps_temporal_values() { + let row = vec![json!("2024-02-25"), json!(42)]; + let column_types = ["DATE".to_string(), "INT".to_string()]; + assert_eq!( + format_temporal_export_row_with_string_types_for_csv_cow(&row, &column_types, None, true).into_owned(), + vec![json!("=\"2024-02-25\""), json!(42)] + ); + } + #[test] fn preserves_raw_export_values_when_pattern_requires_missing_fields() { assert_eq!( diff --git a/crates/dbx-core/src/xlsx_export.rs b/crates/dbx-core/src/xlsx_export.rs index 859ac1e3b9..bed1a13f6a 100644 --- a/crates/dbx-core/src/xlsx_export.rs +++ b/crates/dbx-core/src/xlsx_export.rs @@ -451,7 +451,7 @@ impl StreamingXlsxWriter { rows: &sheet.rows, numeric_column_right_align: sheet.numeric_column_right_align, }; - write_worksheet_xml(&mut self.zip, &segment, self.auto_filter)?; + write_worksheet_xml(&mut self.zip, &segment, self.auto_filter, self.date_time_format.as_deref())?; } // 3. Write metadata files. These appear AFTER sheet data in the ZIP @@ -779,7 +779,12 @@ fn push_typed_cell_xml( push_cell_xml(output, value, row_index, col_index, style); } -fn write_worksheet_xml(writer: &mut W, segment: &WorksheetSegment, auto_filter: bool) -> Result<(), String> { +fn write_worksheet_xml( + writer: &mut W, + segment: &WorksheetSegment, + auto_filter: bool, + date_time_format: Option<&str>, +) -> Result<(), String> { let total_rows = segment.rows.len() + 1; let range = sheet_range(segment.columns.len(), total_rows); let widths = estimate_column_widths(segment.columns, segment.column_comments, segment.rows); @@ -816,7 +821,7 @@ fn write_worksheet_xml(writer: &mut W, segment: &WorksheetSegment, aut segment.columns, segment.column_types, row, - None, + date_time_format, segment.numeric_column_right_align, ); writer.write_all(row_buffer.as_bytes()).map_err(|err| err.to_string())?; @@ -1081,25 +1086,30 @@ fn split_sheets_for_max_rows<'a>( } pub fn build_xlsx_workbook(data: &XlsxWorksheetData) -> Result, String> { - build_xlsx_workbook_with_auto_filter(data, true) + build_xlsx_workbook_with_auto_filter(data, true, None) } pub fn build_xlsx_workbook_multi(sheets: &[XlsxWorksheetData]) -> Result, String> { - build_xlsx_workbook_multi_with_auto_filter(sheets, true) + build_xlsx_workbook_multi_with_auto_filter(sheets, true, None) } -pub fn build_xlsx_workbook_with_auto_filter(data: &XlsxWorksheetData, auto_filter: bool) -> Result, String> { - build_xlsx_workbook_multi_with_auto_filter(std::slice::from_ref(data), auto_filter) +pub fn build_xlsx_workbook_with_auto_filter( + data: &XlsxWorksheetData, + auto_filter: bool, + date_time_format: Option<&str>, +) -> Result, String> { + build_xlsx_workbook_multi_with_auto_filter(std::slice::from_ref(data), auto_filter, date_time_format) } pub fn build_xlsx_workbook_multi_with_auto_filter( sheets: &[XlsxWorksheetData], auto_filter: bool, + date_time_format: Option<&str>, ) -> Result, String> { if auto_filter { - return build_xlsx_workbook_multi_with_max_rows(sheets, XLSX_MAX_DATA_ROWS); + return build_xlsx_workbook_multi_with_max_rows(sheets, XLSX_MAX_DATA_ROWS, date_time_format); } - build_xlsx_workbook_multi_with_max_rows_and_auto_filter(sheets, XLSX_MAX_DATA_ROWS, auto_filter) + build_xlsx_workbook_multi_with_max_rows_and_auto_filter(sheets, XLSX_MAX_DATA_ROWS, auto_filter, date_time_format) } /// Build an in-memory XLSX workbook with an explicit per-sheet data-row limit. @@ -1109,14 +1119,16 @@ pub fn build_xlsx_workbook_multi_with_auto_filter( pub(crate) fn build_xlsx_workbook_multi_with_max_rows( sheets: &[XlsxWorksheetData], max_data_rows_per_sheet: usize, + date_time_format: Option<&str>, ) -> Result, String> { - build_xlsx_workbook_multi_with_max_rows_and_auto_filter(sheets, max_data_rows_per_sheet, true) + build_xlsx_workbook_multi_with_max_rows_and_auto_filter(sheets, max_data_rows_per_sheet, true, date_time_format) } fn build_xlsx_workbook_multi_with_max_rows_and_auto_filter( sheets: &[XlsxWorksheetData], max_data_rows_per_sheet: usize, auto_filter: bool, + date_time_format: Option<&str>, ) -> Result, String> { if sheets.is_empty() { return Err("At least one worksheet is required".to_string()); @@ -1128,7 +1140,7 @@ fn build_xlsx_workbook_multi_with_max_rows_and_auto_filter( ("_rels/.rels", root_rels_xml().to_string()), ("xl/workbook.xml", workbook_xml_for_sheets(&sheet_names)), ("xl/_rels/workbook.xml.rels", workbook_rels_xml_for_sheet_count(segments.len())), - ("xl/styles.xml", styles_xml(None)), + ("xl/styles.xml", styles_xml(date_time_format)), ]; let cursor = Cursor::new(Vec::::new()); @@ -1141,7 +1153,7 @@ fn build_xlsx_workbook_multi_with_max_rows_and_auto_filter( } for (index, segment) in segments.iter().enumerate() { zip.start_file(format!("xl/worksheets/sheet{}.xml", index + 1), options).map_err(|err| err.to_string())?; - write_worksheet_xml(&mut zip, segment, auto_filter)?; + write_worksheet_xml(&mut zip, segment, auto_filter, date_time_format)?; } let output = zip.finish().map_err(|err| err.to_string())?; @@ -1152,9 +1164,10 @@ fn build_xlsx_workbook_multi_with_max_rows_and_auto_filter( mod tests { use super::{ build_xlsx_workbook, build_xlsx_workbook_multi, build_xlsx_workbook_multi_with_auto_filter, - build_xlsx_workbook_multi_with_max_rows, is_numeric_column_type, start_streaming_xlsx_workbook, - start_streaming_xlsx_workbook_with_max_rows, start_streaming_xlsx_workbook_with_options, - start_streaming_xlsx_workbook_with_trailing_sheets, write_worksheet_xml, WorksheetSegment, XlsxWorksheetData, + build_xlsx_workbook_multi_with_max_rows, build_xlsx_workbook_with_auto_filter, is_numeric_column_type, + start_streaming_xlsx_workbook, start_streaming_xlsx_workbook_with_max_rows, + start_streaming_xlsx_workbook_with_options, start_streaming_xlsx_workbook_with_trailing_sheets, + write_worksheet_xml, WorksheetSegment, XlsxWorksheetData, }; use calamine::{open_workbook_auto, Reader}; use serde_json::{json, Value}; @@ -1241,7 +1254,7 @@ mod tests { rows: vec![vec![json!(1)]], numeric_column_right_align: false, }; - let workbook = build_xlsx_workbook_multi_with_auto_filter(&[worksheet], false).expect("build workbook"); + let workbook = build_xlsx_workbook_multi_with_auto_filter(&[worksheet], false, None).expect("build workbook"); assert!(!read_zip_entry(&workbook, "xl/worksheets/sheet1.xml").contains("46228.54323444444"), "sheet={sheet}"); + assert!(styles.contains("numFmtId=\"165\" formatCode=\"yyyy-mm-dd hh:mm:ss.000\""), "styles={styles}"); + + // No configured pattern keeps the historical default. + let workbook = build_xlsx_workbook_with_auto_filter(&data, true, None).expect("build workbook"); + let styles = read_zip_entry(&workbook, "xl/styles.xml"); + assert!(styles.contains("numFmtId=\"165\" formatCode=\"yyyy-mm-dd hh:mm:ss\""), "styles={styles}"); + } + #[test] fn writes_mysql_57_numeric_strings_as_numeric_cells() { let workbook = build_xlsx_workbook(&XlsxWorksheetData { @@ -1920,6 +1961,7 @@ mod tests { numeric_column_right_align: false, }], 2, + None, ) .expect("build workbook"); @@ -1991,7 +2033,7 @@ mod tests { }; let mut stats = WriteStats::default(); - write_worksheet_xml(&mut stats, &segment, true).expect("write large worksheet"); + write_worksheet_xml(&mut stats, &segment, true, None).expect("write large worksheet"); assert!(stats.bytes_written > 10_000_000, "expected realistic worksheet size, got {}", stats.bytes_written); assert!( @@ -2022,7 +2064,7 @@ mod tests { rows: (100..102).map(|i| vec![json!(i)]).collect(), numeric_column_right_align: false, }; - let data = build_xlsx_workbook_multi_with_max_rows(&[sheet_a, sheet_b], 3).expect("build workbook"); + let data = build_xlsx_workbook_multi_with_max_rows(&[sheet_a, sheet_b], 3, None).expect("build workbook"); let workbook_xml = read_zip_entry(&data, "xl/workbook.xml"); assert!(workbook_xml.contains("name=\"A\""), "workbook: {workbook_xml}"); @@ -2056,6 +2098,7 @@ mod tests { numeric_column_right_align: false, }], 100, + None, ) .expect("build workbook"); diff --git a/packages/app-tests/useDataGridExport.test.ts b/packages/app-tests/useDataGridExport.test.ts index d75b72e47c..be26932220 100644 --- a/packages/app-tests/useDataGridExport.test.ts +++ b/packages/app-tests/useDataGridExport.test.ts @@ -712,7 +712,8 @@ test("selected query result CSV export formats only typed temporal columns", asy await composable.exportCsv([1]); - assert.deepEqual(apiMock.exportQueryResultCsv.mock.calls[0][2], [["2024/2/25 13:02:15", rawDateTime]]); + // Temporal columns are wrapped as `="..."` so spreadsheet apps keep them as text; plain columns are untouched. + assert.deepEqual(apiMock.exportQueryResultCsv.mock.calls[0][2], [['="2024/2/25 13:02:15"', rawDateTime]]); }); test("selected query result XLSX export uses the current source label as the sheet name", async () => { @@ -747,6 +748,32 @@ test("selected query result XLSX export forwards the numericColumnRightAlign set assert.equal(apiMock.exportQueryResultXlsx.mock.calls[1][6], true); }); +test("selected query result XLSX export forwards the global export pattern so numFmt keeps milliseconds", async () => { + useSettingsStore().updateEditorSettings({ globalDateTimeExportFormat: "YYYY-MM-DD HH:mm:ss.SSS" }); + const { composable } = buildExportHarness({ + columns: ["id", "ts", "note"], + columnTypes: ["int", "datetime(3)", "varchar(32)"], + rows: [[1, "2026-07-25 13:02:15.456", "with-ms"]], + }); + + await composable.exportXlsx([1]); + + // The rows already carry the formatted value, but without argument 8 the + // workbook falls back to a `yyyy-mm-dd hh:mm:ss` numFmt and hides the + // milliseconds it just wrote. + assert.deepEqual(apiMock.exportQueryResultXlsx.mock.calls[0][5], [[1, "2026-07-25 13:02:15.456", "with-ms"]]); + assert.equal(apiMock.exportQueryResultXlsx.mock.calls[0][8], "YYYY-MM-DD HH:mm:ss.SSS"); +}); + +test("selected query result XLSX export omits the export pattern when none is configured", async () => { + useSettingsStore().updateEditorSettings({ globalDateTimeExportFormat: "" }); + const { composable } = buildExportHarness({ columnTypes: ["bigint(20)", "varchar(64)"] }); + + await composable.exportXlsx([1]); + + assert.equal(apiMock.exportQueryResultXlsx.mock.calls[0][8], undefined); +}); + test("streaming query result XLSX export carries numericColumnRightAlign in the backend request", async () => { const settingsStore = useSettingsStore(); settingsStore.updateEditorSettings({ numericColumnRightAlign: false }); diff --git a/src-tauri/src/commands/xlsx_export.rs b/src-tauri/src/commands/xlsx_export.rs index 9aad51b84f..b7fbf08888 100644 --- a/src-tauri/src/commands/xlsx_export.rs +++ b/src-tauri/src/commands/xlsx_export.rs @@ -19,6 +19,11 @@ pub struct QueryResultXlsxExportRequest { pub numeric_column_right_align: bool, #[serde(default)] pub auto_filter: Option, + /// Global export date/time pattern (Day.js syntax). Drives the workbook's + /// `numFmt`, so a pattern carrying `SSS` keeps milliseconds visible instead + /// of falling back to the `yyyy-mm-dd hh:mm:ss` default. + #[serde(default)] + pub date_time_format: Option, } #[derive(Debug, Clone, Deserialize)] @@ -28,6 +33,8 @@ pub struct QueryResultsXlsxExportRequest { pub worksheets: Vec, #[serde(default)] pub auto_filter: Option, + #[serde(default)] + pub date_time_format: Option, } #[tauri::command] @@ -45,7 +52,11 @@ pub async fn export_query_result_xlsx(request: QueryResultXlsxExportRequest) -> if !data.numeric_column_right_align { data.numeric_column_right_align = false; } - let workbook = build_xlsx_workbook_with_auto_filter(&data, request.auto_filter.unwrap_or(true))?; + let workbook = build_xlsx_workbook_with_auto_filter( + &data, + request.auto_filter.unwrap_or(true), + request.date_time_format.as_deref(), + )?; std::fs::write(&request.file_path, workbook).map_err(|err| err.to_string()) }) .await @@ -55,8 +66,11 @@ pub async fn export_query_result_xlsx(request: QueryResultXlsxExportRequest) -> #[tauri::command] pub async fn export_query_results_xlsx(request: QueryResultsXlsxExportRequest) -> Result<(), String> { tauri::async_runtime::spawn_blocking(move || { - let workbook = - build_xlsx_workbook_multi_with_auto_filter(&request.worksheets, request.auto_filter.unwrap_or(true))?; + let workbook = build_xlsx_workbook_multi_with_auto_filter( + &request.worksheets, + request.auto_filter.unwrap_or(true), + request.date_time_format.as_deref(), + )?; std::fs::write(&request.file_path, workbook).map_err(|err| err.to_string()) }) .await