diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue
index eee23d31c..40127bec1 100644
--- a/apps/desktop/src/components/grid/DataGrid.vue
+++ b/apps/desktop/src/components/grid/DataGrid.vue
@@ -963,7 +963,10 @@ function columnFormatter(columnIndex: number): ColumnFormatterConfig | undefined
const column = props.result.columns[columnIndex];
if (!column) return undefined;
const key = formatterKeyForColumn(column);
- return key ? resolveColumnFormatter(settingsStore.editorSettings.columnFormatters[key], settingsStore.editorSettings.customColumnFormatters) : undefined;
+ return resolveColumnFormatter(key ? settingsStore.editorSettings.columnFormatters[key] : undefined, settingsStore.editorSettings.customColumnFormatters, {
+ pattern: settingsStore.editorSettings.globalDateTimeDisplayFormat,
+ columnType: props.result.column_types?.[columnIndex] ?? tableColumnForGridColumn(columnIndex)?.data_type,
+ });
}
function savedColumnFormatter(columnIndex: number): ColumnFormatterConfig | undefined {
diff --git a/apps/desktop/src/components/import/TableImportDialog.vue b/apps/desktop/src/components/import/TableImportDialog.vue
index eaa39ee39..95bf8f433 100644
--- a/apps/desktop/src/components/import/TableImportDialog.vue
+++ b/apps/desktop/src/components/import/TableImportDialog.vue
@@ -11,6 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { SearchableSelect } from "@/components/ui/searchable-select";
import { AlertTriangle, ArrowLeft, ArrowRight, Check, CheckCircle2, FileJson, FileSpreadsheet, FileText, FileUp, Loader2, RefreshCw, Square, Upload, X } from "@lucide/vue";
import { useConnectionStore } from "@/stores/connectionStore";
+import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import { autoMapImportColumns, nextTableImportWizardStep, previousTableImportWizardStep, requiredImportTargetColumns, suggestImportTargetDataTypes, validateImportMappings, type TableImportWizardStep } from "@/lib/table/tableImport";
import { getDataTypeOptions } from "@/lib/table/tableStructureEditorState";
@@ -20,6 +21,7 @@ import * as api from "@/lib/backend/api";
const { t } = useI18n();
const store = useConnectionStore();
+const settingsStore = useSettingsStore();
const { toast } = useToast();
const open = defineModel
("open", { default: false });
@@ -625,6 +627,7 @@ async function startImport() {
mode: targetMode.value === "create" ? "append" : importMode.value,
createTable: targetMode.value === "create",
batchSize: Math.max(1, Number(batchSize.value) || 500),
+ dateTimeFormat: settingsStore.editorSettings.globalDateTimeImportFormat || undefined,
},
(nextProgress) => {
progress.value = nextProgress;
@@ -700,6 +703,7 @@ async function startBatchImport() {
mode: "append",
createTable: true,
batchSize: Math.max(1, Number(batchSize.value) || 500),
+ dateTimeFormat: settingsStore.editorSettings.globalDateTimeImportFormat || undefined,
},
(nextProgress) => {
task.rowsImported = nextProgress.rowsImported;
diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts
index f6cc3d157..cd5a7778c 100644
--- a/apps/desktop/src/composables/useDataGridExport.ts
+++ b/apps/desktop/src/composables/useDataGridExport.ts
@@ -18,6 +18,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";
/**
* Format metadata for backend table exports. Each entry maps a format key
@@ -202,10 +203,15 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
return displayItems.value.filter((item) => rowIdSet.has(item.id) && !item.isDraft);
}
- async function resultToExport(rowIds?: number[], onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void, useFullExport = true): Promise<{ columns: string[]; columnTypes: string[]; rows: CellValue[][] }> {
+ function applyGlobalDateTimeExportFormat(result: { columns: string[]; columnTypes: string[]; rows: CellValue[][] }, enabled: boolean) {
+ const pattern = enabled ? useSettingsStore().editorSettings.globalDateTimeExportFormat : "";
+ return pattern ? { ...result, rows: formatTemporalRowsForExport(result.rows, result.columnTypes, pattern) } : result;
+ }
+
+ async function resultToExport(rowIds?: number[], onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void, useFullExport = true, formatDateTime = true): Promise<{ columns: string[]; columnTypes: string[]; rows: CellValue[][] }> {
if (useFullExport && rowIds === undefined && fullExportResult && !hasCompleteLocalResult?.value) {
const result = await fullExportResult(onProgress);
- if (result) return { columns: result.columns, columnTypes: result.column_types ?? [], rows: result.rows };
+ if (result) return applyGlobalDateTimeExportFormat({ columns: result.columns, columnTypes: result.column_types ?? [], rows: result.rows }, formatDateTime);
}
// The full result is already in memory — export the raw QueryResult (all
// rows, all columns, committed values) so "export all data" matches the
@@ -213,13 +219,16 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
// 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) {
- return { columns: completeLocalResult.value.columns, columnTypes: completeLocalResult.value.column_types ?? [], rows: completeLocalResult.value.rows };
+ return applyGlobalDateTimeExportFormat({ columns: completeLocalResult.value.columns, columnTypes: completeLocalResult.value.column_types ?? [], rows: completeLocalResult.value.rows }, formatDateTime);
}
- return {
- columns: columns.value,
- columnTypes: (columnTypes.value ?? []).map((type) => type ?? ""),
- rows: rowsToExport(rowIds).map((item) => item.data),
- };
+ return applyGlobalDateTimeExportFormat(
+ {
+ columns: columns.value,
+ columnTypes: (columnTypes.value ?? []).map((type) => type ?? ""),
+ rows: rowsToExport(rowIds).map((item) => item.data),
+ },
+ formatDateTime,
+ );
}
function currentXlsxSheetName(): string {
@@ -1026,11 +1035,12 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
outputPath = path as string;
}
+ const exportPattern = useSettingsStore().editorSettings.globalDateTimeExportFormat;
const worksheets = sheets.map((sheet) => ({
sheetName: sheet.sheetName,
columns: sheet.result.columns,
columnTypes: sheet.result.column_types ?? [],
- rows: sheet.result.rows,
+ rows: formatTemporalRowsForExport(sheet.result.rows, sheet.result.column_types ?? [], exportPattern),
}));
const sqlWorksheet = includeSqlSheet ? buildXlsxSqlWorksheet(sheets.map((sheet) => ({ resultName: sheet.sheetName, sql: sheet.sql || sheet.result.sourceStatement || "" }))) : undefined;
await api.exportQueryResultsXlsx(outputPath, sqlWorksheet ? [...worksheets, sqlWorksheet] : worksheets);
@@ -1110,6 +1120,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
skipCount: false,
batchSize: exportBatchSize.value,
rowLimit,
+ dateTimeFormat: editorSettings.globalDateTimeExportFormat || undefined,
},
(progress) => {
if (exportProgressState) {
@@ -1156,7 +1167,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
const exportId = uuid();
- const request = await queryResultExportRequest({ exportId, filePath: outputPath, format, includeSqlSheet });
+ const baseRequest = await queryResultExportRequest({ exportId, filePath: outputPath, format, includeSqlSheet });
+ const request = baseRequest ? { ...baseRequest, dateTimeFormat: useSettingsStore().editorSettings.globalDateTimeExportFormat || undefined } : undefined;
if (!request) throw new Error("Unable to build query result export request");
if (exportProgressState) {
@@ -1204,7 +1216,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
try {
if (await exportFullTableDataViaBackend("sql", rowIds)) return;
- const result = await resultToExport(rowIds);
+ const result = await resultToExport(rowIds, undefined, true, false);
const exportData = sqlInsertExportData(result);
const content = await formatSqlInsert({
databaseType: databaseType.value,
@@ -1225,7 +1237,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
async function exportCurrentPageSql() {
await runExclusiveExport(async () => {
try {
- const result = await resultToExport(undefined, undefined, false);
+ const result = await resultToExport(undefined, undefined, false, false);
const exportData = sqlInsertExportData(result);
const content = await formatSqlInsert({
databaseType: databaseType.value,
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index b651c27d8..54054c397 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -3498,6 +3498,17 @@ export default {
redisScanPageSizeDescription: "Keys requested per Redis SCAN page when browsing keys.",
redisScanPageSizeOption: "{count} keys",
exportBatchSize: "Export batch size",
+ dateTimeSection: "Date and time",
+ globalDateTimeDisplayFormat: "Global display format",
+ globalDateTimeDisplayFormatDescription: "Applies to temporal columns without a column-specific formatter in every database.",
+ globalDateTimeExportFormat: "Global export format",
+ globalDateTimeExportFormatDescription: "Formats temporal columns consistently in CSV, Excel, JSON, Markdown, and text exports.",
+ globalDateTimeImportFormat: "Global import format",
+ globalDateTimeImportFormatDescription: "Parses imported temporal values with this format. Leave empty to detect common formats automatically.",
+ dateTimeFormatRaw: "Keep database value",
+ dateTimeFormatAuto: "Auto detect",
+ dateTimeFormatSearchPlaceholder: "Select or enter a format",
+ dateTimeFormatEmpty: "Enter a custom datetime format",
exportBatchSizeDescription: "Rows fetched per batch when exporting data (100-100000).",
exportRowLimitEnabled: "Limit exported rows",
exportRowLimitEnabledDescription: "When on, query result and table data exports stop at the row limit below.",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index 8553d29e8..97682b360 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -3474,6 +3474,17 @@ export default withEnglishFallback({
shortcutUppercaseSelection: "Convertir selección a mayúsculas",
shortcutLowercaseSelection: "Convertir selección a minúsculas",
shortcutExPasteSqlInCondition: "ExPaste: pegar como condición IN",
+ dateTimeSection: "Fecha y hora",
+ globalDateTimeDisplayFormat: "Formato de visualización global",
+ globalDateTimeDisplayFormatDescription: "Se aplica a todos los campos de fecha y hora que no tienen un formato de columna configurado individualmente en las bases de datos.",
+ globalDateTimeExportFormat: "Formato de exportación global",
+ globalDateTimeExportFormatDescription: "Formatea uniformemente los campos de fecha y hora al exportar a CSV, Excel, JSON, Markdown y texto.",
+ globalDateTimeImportFormat: "Formato de importación global",
+ globalDateTimeImportFormatDescription: "Analiza las fechas y horas en los archivos importados según este formato; si se deja vacío, se reconocen automáticamente los formatos comunes.",
+ dateTimeFormatRaw: "Mantener el valor original de la base de datos",
+ dateTimeFormatAuto: "Reconocimiento automático",
+ dateTimeFormatSearchPlaceholder: "Seleccionar o ingresar formato",
+ dateTimeFormatEmpty: "Ingresar formato de fecha y hora personalizado",
},
driverStore: {
progressJreExtract: "Extrayendo JRE...",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index e900486b8..071321044 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -3472,6 +3472,17 @@ export default withEnglishFallback({
shortcutUppercaseSelection: "Converti selezione in maiuscolo",
shortcutLowercaseSelection: "Converti selezione in minuscolo",
shortcutExPasteSqlInCondition: "ExPaste: incolla come condizione IN",
+ dateTimeSection: "Data e Ora",
+ globalDateTimeDisplayFormat: "Formato di visualizzazione globale",
+ globalDateTimeDisplayFormatDescription: "Applicato a tutti i campi data/ora nei database che non hanno una configurazione di formato colonna individuale.",
+ globalDateTimeExportFormat: "Formato di esportazione globale",
+ globalDateTimeExportFormatDescription: "Formatta uniformemente i campi data/ora durante l'esportazione in CSV, Excel, JSON, Markdown e testo.",
+ globalDateTimeImportFormat: "Formato di importazione globale",
+ globalDateTimeImportFormatDescription: "Analizza i campi data/ora nei file importati secondo questo formato; lascia vuoto per rilevare automaticamente i formati comuni.",
+ dateTimeFormatRaw: "Mantieni il valore originale del database",
+ dateTimeFormatAuto: "Riconoscimento automatico",
+ dateTimeFormatSearchPlaceholder: "Seleziona o inserisci il formato",
+ dateTimeFormatEmpty: "Inserisci un formato data/ora personalizzato",
},
driverStore: {
progressJreExtract: "Estrazione JRE...",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index 01efaddd1..69fa74887 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -3473,6 +3473,17 @@ export default withEnglishFallback({
shortcutUppercaseSelection: "選択範囲を大文字に変換",
shortcutLowercaseSelection: "選択範囲を小文字に変換",
shortcutExPasteSqlInCondition: "ExPaste: IN条件として貼り付け",
+ dateTimeSection: "日付と時刻",
+ globalDateTimeDisplayFormat: "グローバル表示形式",
+ globalDateTimeDisplayFormatDescription: "すべてのデータベースで列形式が個別に設定されていない日時フィールドに適用されます。",
+ globalDateTimeExportFormat: "グローバルエクスポート形式",
+ globalDateTimeExportFormatDescription: "CSV、Excel、JSON、Markdown、テキストをエクスポートする際に日時フィールドを統一フォーマットします。",
+ globalDateTimeImportFormat: "グローバルインポート形式",
+ globalDateTimeImportFormatDescription: "この形式でインポートファイル内の日時を解析します。空欄の場合は一般的な形式を自動認識します。",
+ dateTimeFormatRaw: "データベースの元の値を保持",
+ dateTimeFormatAuto: "自動認識",
+ dateTimeFormatSearchPlaceholder: "形式を選択または入力",
+ dateTimeFormatEmpty: "カスタム日時形式を入力",
},
driverStore: {
progressJreExtract: "JREを展開中...",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index 952478c2c..bd4932aa5 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -3474,6 +3474,17 @@ export default withEnglishFallback({
shortcutUppercaseSelection: "Converter seleção em maiúsculas",
shortcutLowercaseSelection: "Converter seleção em minúsculas",
shortcutExPasteSqlInCondition: "ExPaste: colar como condição IN",
+ dateTimeSection: "Data e Hora",
+ globalDateTimeDisplayFormat: "Formato de exibição global",
+ globalDateTimeDisplayFormatDescription: "Aplicado a todos os campos de data e hora em todos os bancos de dados que não tenham formato de coluna configurado individualmente.",
+ globalDateTimeExportFormat: "Formato de exportação global",
+ globalDateTimeExportFormatDescription: "Formata campos de data e hora de forma uniforme ao exportar para CSV, Excel, JSON, Markdown e texto.",
+ globalDateTimeImportFormat: "Formato de importação global",
+ globalDateTimeImportFormatDescription: "Analisa datas e horas em arquivos importados de acordo com este formato; deixe em branco para detectar automaticamente formatos comuns.",
+ dateTimeFormatRaw: "Manter valor original do banco de dados",
+ dateTimeFormatAuto: "Detecção automática",
+ dateTimeFormatSearchPlaceholder: "Selecione ou digite um formato",
+ dateTimeFormatEmpty: "Insira um formato de data e hora personalizado",
},
driverStore: {
progressJreExtract: "Extraindo JRE...",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index c39f3b7d3..22f2d5501 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -3488,6 +3488,17 @@ export default withEnglishFallback({
redisScanPageSizeDescription: "浏览 Redis Key 时每次 SCAN 请求的 Key 数量。",
redisScanPageSizeOption: "{count} 个 Key",
exportBatchSize: "导出批次大小",
+ dateTimeSection: "日期与时间",
+ globalDateTimeDisplayFormat: "全局显示格式",
+ globalDateTimeDisplayFormatDescription: "应用于所有数据库中未单独配置列格式的日期时间字段。",
+ globalDateTimeExportFormat: "全局导出格式",
+ globalDateTimeExportFormatDescription: "导出 CSV、Excel、JSON、Markdown 和文本时统一格式化日期时间字段。",
+ globalDateTimeImportFormat: "全局导入格式",
+ globalDateTimeImportFormatDescription: "按此格式解析导入文件中的日期时间;留空时自动识别常见格式。",
+ dateTimeFormatRaw: "保持数据库原始值",
+ dateTimeFormatAuto: "自动识别",
+ dateTimeFormatSearchPlaceholder: "选择或输入格式",
+ dateTimeFormatEmpty: "输入自定义日期时间格式",
exportBatchSizeDescription: "导出数据时每批读取的行数(100-100000)。",
exportRowLimitEnabled: "限制导出行数",
exportRowLimitEnabledDescription: "开启时,查询结果和表数据导出最多为下方设置的行数。",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index fa5893aac..6471e3ca6 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -3319,6 +3319,17 @@ export default withEnglishFallback({
updateDownloadSourceAtomgit: "AtomGit",
exportSection: "匯出",
exportBatchSize: "匯出批次大小",
+ dateTimeSection: "日期與時間",
+ globalDateTimeDisplayFormat: "全域顯示格式",
+ globalDateTimeDisplayFormatDescription: "套用到所有資料庫中未單獨設定欄位格式的日期時間欄位。",
+ globalDateTimeExportFormat: "全域匯出格式",
+ globalDateTimeExportFormatDescription: "匯出 CSV、Excel、JSON、Markdown 和文字時統一格式化日期時間欄位。",
+ globalDateTimeImportFormat: "全域匯入格式",
+ globalDateTimeImportFormatDescription: "依此格式解析匯入檔案中的日期時間;留空時自動辨識常見格式。",
+ dateTimeFormatRaw: "保留資料庫原始值",
+ dateTimeFormatAuto: "自動辨識",
+ dateTimeFormatSearchPlaceholder: "選擇或輸入格式",
+ dateTimeFormatEmpty: "輸入自訂日期時間格式",
exportBatchSizeDescription: "匯出資料時每批次擷取的列數(100-100000)。",
exportRowLimitEnabled: "限制匯出行數",
exportRowLimitEnabledDescription: "開啟後,查詢結果和資料表資料匯出將在下方設定的行數上限處停止。",
diff --git a/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts
new file mode 100644
index 000000000..232e42a3f
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vitest";
+import { normalizeSupportedDateTimePattern } from "@/lib/dataGrid/columnFormatter";
+
+describe("normalizeSupportedDateTimePattern", () => {
+ it("accepts the format grammar shared by the frontend and backend", () => {
+ expect(normalizeSupportedDateTimePattern(" YYYY/M/D [at] HH:mm:ss.SSSZ ")).toBe("YYYY/M/D [at] HH:mm:ss.SSSZ");
+ });
+
+ it("rejects unsupported or malformed Day.js tokens", () => {
+ expect(normalizeSupportedDateTimePattern("MM/DD/YYYY hh:mm A")).toBe("");
+ expect(normalizeSupportedDateTimePattern("YYYY-MM-DD [at HH:mm:ss")).toBe("");
+ expect(normalizeSupportedDateTimePattern("%Y-%m-%d")).toBe("");
+ });
+});
diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts
index 18d92a238..21d676813 100644
--- a/apps/desktop/src/lib/backend/tauri.ts
+++ b/apps/desktop/src/lib/backend/tauri.ts
@@ -2201,6 +2201,7 @@ export interface TableImportRequest {
mode: TableImportMode;
createTable?: boolean;
batchSize: number;
+ dateTimeFormat?: string;
}
export interface TableImportSummary {
@@ -2291,6 +2292,7 @@ export interface TableExportRequest {
skipCount?: boolean;
batchSize?: number;
rowLimit?: number | null;
+ dateTimeFormat?: string;
}
export interface TableCsvExportOptions {
@@ -2332,6 +2334,7 @@ export interface QueryResultExportRequest {
keysetOptimizationEnabled: boolean;
clientSessionId?: string;
executionId?: string;
+ dateTimeFormat?: string;
}
export async function startTableExport(request: TableExportRequest, onProgress: (progress: TableExportProgress) => void): Promise {
diff --git a/apps/desktop/src/lib/dataGrid/columnFormatter.ts b/apps/desktop/src/lib/dataGrid/columnFormatter.ts
index 268346546..8b64911d4 100644
--- a/apps/desktop/src/lib/dataGrid/columnFormatter.ts
+++ b/apps/desktop/src/lib/dataGrid/columnFormatter.ts
@@ -10,9 +10,47 @@ dayjs.extend(timezone);
export type DateTimeFormatterUnit = "seconds" | "milliseconds" | "auto";
const DEFAULT_DATETIME_PATTERN = "YYYY-MM-DD HH:mm:ss";
-export const DateTimePatterns = ["HH:mm:ss", "HH:mm:ss.SSS", "YYYY-MM-DD HH:mm:ss", "YYYY-MM-DD HH:mm:ss.SSS", "YYYY/MM/DD HH:mm:ss", "YYYY/MM/DD HH:mm:ss.SSS", "YYYY-MM-DDTHH:mm:ssZ", "YYYY-MM-DDTHH:mm:ss.SSSZ", "YYYY/MM/DDTHH:mm:ssZ", "YYYY/MM/DDTHH:mm:ss.SSSZ"];
-const STRICT_LOCAL_DATETIME_INPUT_PATTERNS = ["YYYY-MM-DD", "YYYY/MM/DD", "YYYY-MM-DD HH:mm:ss", "YYYY-MM-DD HH:mm:ss.SSS", "YYYY/MM/DD HH:mm:ss", "YYYY/MM/DD HH:mm:ss.SSS", "YYYY-MM-DDTHH:mm:ss", "YYYY-MM-DDTHH:mm:ss.SSS", "YYYY/MM/DDTHH:mm:ss", "YYYY/MM/DDTHH:mm:ss.SSS"];
-const ISO_OFFSET_DATETIME_PATTERN = /^(\d{4})([-/])(\d{2})\2(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,3})?(Z|[+-]\d{2}:\d{2})$/;
+export const DateTimePatterns = [
+ "YYYY-MM-DD",
+ "YYYY/MM/DD",
+ "YYYY/M/D",
+ "HH:mm:ss",
+ "HH:mm:ss.SSS",
+ "YYYY-MM-DD HH:mm:ss",
+ "YYYY-MM-DD HH:mm:ss.SSS",
+ "YYYY/MM/DD HH:mm:ss",
+ "YYYY/MM/DD HH:mm:ss.SSS",
+ "YYYY/M/D HH:mm:ss",
+ "YYYY-MM-DDTHH:mm:ssZ",
+ "YYYY-MM-DDTHH:mm:ss.SSSZ",
+ "YYYY/MM/DDTHH:mm:ssZ",
+ "YYYY/MM/DDTHH:mm:ss.SSSZ",
+];
+const SUPPORTED_DATE_TIME_PATTERN_TOKENS = ["YYYY", "SSS", "ZZ", "MM", "DD", "HH", "mm", "ss", "M", "D", "H", "m", "s", "Z"];
+const STRICT_LOCAL_DATETIME_INPUT_PATTERNS = [
+ "YYYY-MM-DD",
+ "YYYY-M-D",
+ "YYYY/MM/DD",
+ "YYYY/M/D",
+ "YYYY-MM-DD HH:mm:ss",
+ "YYYY-M-D H:m:s",
+ "YYYY-MM-DD HH:mm:ss.SSS",
+ "YYYY-M-D H:m:s.SSS",
+ "YYYY/MM/DD HH:mm:ss",
+ "YYYY/M/D H:m:s",
+ "YYYY/MM/DD HH:mm:ss.SSS",
+ "YYYY/M/D H:m:s.SSS",
+ "YYYY-MM-DDTHH:mm:ss",
+ "YYYY-M-DTH:m:s",
+ "YYYY-MM-DDTHH:mm:ss.SSS",
+ "YYYY-M-DTH:m:s.SSS",
+ "YYYY/MM/DDTHH:mm:ss",
+ "YYYY/M/DTH:m:s",
+ "YYYY/MM/DDTHH:mm:ss.SSS",
+ "YYYY/M/DTH:m:s.SSS",
+];
+const ISO_OFFSET_DATETIME_PATTERN = /^(\d{4})([-/])(\d{2})\2(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
+const FRACTIONAL_LOCAL_DATETIME_PATTERN = /^(\d{4})([-/])(\d{1,2})\2(\d{1,2})([ T])(\d{1,2}):(\d{1,2}):(\d{1,2})\.(\d{1,9})$/;
export interface CustomColumnFormatterConfig {
id: string;
@@ -20,6 +58,33 @@ export interface CustomColumnFormatterConfig {
template: string;
}
+export function normalizeSupportedDateTimePattern(value: string): string {
+ const pattern = value.trim();
+ if (!pattern || pattern.length > 100 || pattern.includes("%")) return "";
+
+ let index = 0;
+ while (index < pattern.length) {
+ const remaining = pattern.slice(index);
+ if (remaining.startsWith("[")) {
+ const closeIndex = remaining.indexOf("]");
+ if (closeIndex < 0) return "";
+ index += closeIndex + 1;
+ continue;
+ }
+
+ const token = SUPPORTED_DATE_TIME_PATTERN_TOKENS.find((candidate) => remaining.startsWith(candidate));
+ if (token) {
+ index += token.length;
+ continue;
+ }
+
+ if (/[A-Za-z]/.test(pattern[index])) return "";
+ index += 1;
+ }
+
+ return pattern;
+}
+
export type ColumnFormatterConfig = { kind: "datetime"; unit: DateTimeFormatterUnit; pattern: string } | { kind: "json-path"; path: string } | { kind: "mask"; prefix: number; suffix: number } | { kind: "custom-template"; template: string } | { kind: "custom-ref"; formatterId: string };
export interface ColumnFormatterKeyParts {
@@ -82,13 +147,69 @@ export function normalizeCustomColumnFormatter(value: unknown): CustomColumnForm
};
}
-export function resolveColumnFormatter(formatter: ColumnFormatterConfig | undefined, customFormatters: Record): ColumnFormatterConfig | undefined {
- if (!formatter) return undefined;
+export function normalizeGlobalDateTimePattern(value: unknown): string {
+ return typeof value === "string" ? value.trim().slice(0, 100) : "";
+}
+
+export function isTemporalColumnType(dataType: string | null | undefined): boolean {
+ const normalized = String(dataType || "")
+ .trim()
+ .toLowerCase()
+ .replace(/\s+/g, " ");
+ if (!normalized) return false;
+ const base = normalized.split(/[(:]/)[0]?.trim() ?? "";
+ return (
+ ["date", "date32", "daten", "time", "time64", "timen", "timetz", "datetime", "datetime2", "datetime4", "datetime64", "datetimen", "datetimeoffset", "datetimeoffsetn", "smalldatetime", "timestamp", "timestampdty", "timestamptz"].includes(base) ||
+ base.startsWith("timestamp_") ||
+ normalized.startsWith("timestamp with ") ||
+ normalized.startsWith("timestamp without ") ||
+ normalized.startsWith("time with ") ||
+ normalized.startsWith("time without ")
+ );
+}
+
+export function resolveColumnFormatter(formatter: ColumnFormatterConfig | undefined, customFormatters: Record, globalDateTime?: { pattern?: string; columnType?: string | null }): ColumnFormatterConfig | undefined {
+ if (!formatter) {
+ const pattern = normalizeGlobalDateTimePattern(globalDateTime?.pattern);
+ return pattern && isTemporalColumnType(globalDateTime?.columnType) ? { kind: "datetime", unit: "auto", pattern } : undefined;
+ }
if (formatter.kind !== "custom-ref") return formatter;
const customFormatter = customFormatters[formatter.formatterId];
return customFormatter ? { kind: "custom-template", template: customFormatter.template } : undefined;
}
+export function formatTemporalRowsForExport(rows: readonly (readonly T[])[], columnTypes: readonly (string | null | undefined)[], pattern: string): T[][] {
+ const normalizedPattern = normalizeGlobalDateTimePattern(pattern);
+ if (!normalizedPattern) return rows.map((row) => [...row]);
+ return rows.map((row) =>
+ row.map((value, index) => {
+ if (!isTemporalColumnType(columnTypes[index])) return value;
+ return formatTemporalValueForExport(value, normalizedPattern) as T;
+ }),
+ );
+}
+
+function formatTemporalValueForExport(value: CellValue, pattern: string): string {
+ if (typeof value === "string") {
+ const match = value.match(ISO_OFFSET_DATETIME_PATTERN);
+ if (match) {
+ const [, year, separator, month, day, hour, minute, second, fraction = "", zone] = match;
+ if (!isValidDateTimeParts(year, month, day, hour, minute, second) || !isValidOffset(zone)) {
+ return displayCellValue(value);
+ }
+ const normalizedFraction = fraction ? `.${fraction.slice(1, 4).padEnd(3, "0")}` : "";
+ const localValue = `${year}${separator}${month}${separator}${day}T${hour}:${minute}:${second}${normalizedFraction}`;
+ const inputPattern = `${separator === "-" ? "YYYY-MM-DD" : "YYYY/MM/DD"}THH:mm:ss${fraction ? ".SSS" : ""}`;
+ const parsed = dayjs(localValue, inputPattern, true);
+ if (parsed.isValid()) {
+ const offsetMinutes = zone === "Z" ? 0 : (zone.startsWith("-") ? -1 : 1) * (Number(zone.slice(1, 3)) * 60 + Number(zone.slice(4, 6)));
+ return parsed.utcOffset(offsetMinutes, true).format(pattern);
+ }
+ }
+ }
+ return applyColumnFormatter(value, { kind: "datetime", unit: "auto", pattern });
+}
+
export function applyColumnFormatter(value: CellValue, formatter: ColumnFormatterConfig | undefined): string {
if (!formatter) return displayCellValue(value);
@@ -142,6 +263,16 @@ function parseStrictDateTimeString(value: string): Dayjs | undefined {
const parsedIsoOffset = parseIsoOffsetDateTimeString(value);
if (parsedIsoOffset) return parsedIsoOffset;
+ const fractionalLocalMatch = value.match(FRACTIONAL_LOCAL_DATETIME_PATTERN);
+ if (fractionalLocalMatch) {
+ const [, yearText, , monthText, dayText, , hourText, minuteText, secondText, fractionText] = fractionalLocalMatch;
+ if (isValidDateTimeParts(yearText, monthText, dayText, hourText, minuteText, secondText)) {
+ const normalized = `${yearText}-${monthText.padStart(2, "0")}-${dayText.padStart(2, "0")}T${hourText.padStart(2, "0")}:${minuteText.padStart(2, "0")}:${secondText.padStart(2, "0")}.${fractionText.slice(0, 3).padEnd(3, "0")}`;
+ const parsed = dayjs(normalized, "YYYY-MM-DDTHH:mm:ss.SSS", true);
+ if (parsed.isValid()) return parsed;
+ }
+ }
+
for (const pattern of STRICT_LOCAL_DATETIME_INPUT_PATTERNS) {
// Day.js non-strict parsing normalizes overflow dates such as 2022-01-33.
// Keep cell text strict so invalid values fall back unchanged.
@@ -159,7 +290,8 @@ function parseIsoOffsetDateTimeString(value: string): Dayjs | undefined {
if (!isValidDateTimeParts(yearText, monthText, dayText, hourText, minuteText, secondText)) return undefined;
if (!isValidOffset(zoneText)) return undefined;
- const normalized = `${yearText}-${monthText}-${dayText}T${hourText}:${minuteText}:${secondText}${fractionText}${zoneText}`;
+ const normalizedFraction = fractionText ? `.${fractionText.slice(1, 4).padEnd(3, "0")}` : "";
+ const normalized = `${yearText}-${monthText}-${dayText}T${hourText}:${minuteText}:${secondText}${normalizedFraction}${zoneText}`;
const parsed = dayjs(normalized);
return parsed.isValid() ? parsed : undefined;
}
diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts
index 8de49aecf..98273dd82 100644
--- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts
+++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts
@@ -47,6 +47,9 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"exportRowLimitEnabled",
"exportRowLimit",
"queryExportKeysetOptimizationEnabled",
+ "globalDateTimeDisplayFormat",
+ "globalDateTimeExportFormat",
+ "globalDateTimeImportFormat",
"updateDownloadSource",
"toolbarItems",
"snippets",
diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts
index ceefcde61..8afa60521 100644
--- a/apps/desktop/src/stores/settingsStore.ts
+++ b/apps/desktop/src/stores/settingsStore.ts
@@ -1,7 +1,7 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import * as api from "@/lib/backend/api";
-import { normalizeColumnFormatter, normalizeCustomColumnFormatter, type ColumnFormatterConfig, type CustomColumnFormatterConfig } from "@/lib/dataGrid/columnFormatter";
+import { normalizeColumnFormatter, normalizeCustomColumnFormatter, normalizeGlobalDateTimePattern, type ColumnFormatterConfig, type CustomColumnFormatterConfig } from "@/lib/dataGrid/columnFormatter";
import { normalizeShortcutSettings, type ShortcutSettings } from "@/lib/editor/shortcutRegistry";
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay";
@@ -421,6 +421,9 @@ export interface EditorSettings {
sidebarAllowHorizontalScroll: boolean;
columnFormatters: Record;
customColumnFormatters: Record;
+ globalDateTimeDisplayFormat: string;
+ globalDateTimeExportFormat: string;
+ globalDateTimeImportFormat: string;
snippets: SqlSnippet[];
tableColumnTemplateFields: string[];
exportBatchSize: number;
@@ -559,6 +562,9 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
sidebarAllowHorizontalScroll: false,
columnFormatters: {},
customColumnFormatters: {},
+ globalDateTimeDisplayFormat: "",
+ globalDateTimeExportFormat: "",
+ globalDateTimeImportFormat: "",
snippets: DEFAULT_SQL_SNIPPETS,
tableColumnTemplateFields: [...DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS],
exportBatchSize: 2000,
@@ -797,6 +803,9 @@ export function normalizeEditorSettings(settings: Partial, exist
sidebarAllowHorizontalScroll: settings.sidebarAllowHorizontalScroll ?? DEFAULT_EDITOR_SETTINGS.sidebarAllowHorizontalScroll,
columnFormatters: normalizeColumnFormatters(settings.columnFormatters),
customColumnFormatters: normalizeCustomColumnFormatters(settings.customColumnFormatters),
+ globalDateTimeDisplayFormat: normalizeGlobalDateTimePattern(settings.globalDateTimeDisplayFormat),
+ globalDateTimeExportFormat: normalizeGlobalDateTimePattern(settings.globalDateTimeExportFormat),
+ globalDateTimeImportFormat: normalizeGlobalDateTimePattern(settings.globalDateTimeImportFormat),
snippets: normalizeSqlSnippets(settings.snippets, existing?.snippets),
tableColumnTemplateFields: normalizeTableColumnTemplateFields(settings.tableColumnTemplateFields),
exportBatchSize: typeof settings.exportBatchSize === "number" && settings.exportBatchSize >= 100 && settings.exportBatchSize <= 100000 ? Math.round(settings.exportBatchSize) : DEFAULT_EDITOR_SETTINGS.exportBatchSize,
@@ -1047,6 +1056,9 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.sidebarAllowHorizontalScroll !== undefined) editorSettings.value.sidebarAllowHorizontalScroll = partial.sidebarAllowHorizontalScroll;
if (partial.columnFormatters !== undefined) editorSettings.value.columnFormatters = partial.columnFormatters;
if (partial.customColumnFormatters !== undefined) editorSettings.value.customColumnFormatters = partial.customColumnFormatters;
+ if (partial.globalDateTimeDisplayFormat !== undefined) editorSettings.value.globalDateTimeDisplayFormat = normalizeGlobalDateTimePattern(partial.globalDateTimeDisplayFormat);
+ if (partial.globalDateTimeExportFormat !== undefined) editorSettings.value.globalDateTimeExportFormat = normalizeGlobalDateTimePattern(partial.globalDateTimeExportFormat);
+ if (partial.globalDateTimeImportFormat !== undefined) editorSettings.value.globalDateTimeImportFormat = normalizeGlobalDateTimePattern(partial.globalDateTimeImportFormat);
if (partial.snippets !== undefined) editorSettings.value.snippets = normalizeSqlSnippets(partial.snippets);
if (partial.tableColumnTemplateFields !== undefined) editorSettings.value.tableColumnTemplateFields = normalizeTableColumnTemplateFields(partial.tableColumnTemplateFields);
if (partial.exportBatchSize !== undefined) editorSettings.value.exportBatchSize = Math.min(100000, Math.max(100, Math.round(partial.exportBatchSize)));
diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs
index 823f69e36..bdcf91745 100644
--- a/crates/dbx-core/src/db/sqlserver.rs
+++ b/crates/dbx-core/src/db/sqlserver.rs
@@ -256,7 +256,7 @@ pub struct SqlServerStreamExportSummary {
}
pub enum SqlServerStreamItem<'a> {
- Columns(&'a [String]),
+ Columns { columns: &'a [String], column_types: &'a [String] },
Row(&'a [serde_json::Value]),
}
@@ -638,6 +638,7 @@ pub async fn stream_first_result_set(
let mut stream = sqlserver_driver_result(client.query(query_sql.as_str(), &[])).await?;
let mut active_result_index: Option = None;
let mut columns: Vec = Vec::new();
+ let mut column_types: Vec = Vec::new();
let mut columns_emitted = false;
let mut rows_exported = 0_u64;
@@ -663,7 +664,8 @@ pub async fn stream_first_result_set(
if active_result_index.is_none() {
active_result_index = Some(metadata.result_index());
columns = columns_from_metadata(&metadata);
- on_item(SqlServerStreamItem::Columns(&columns))?;
+ column_types = column_types_from_metadata(&metadata);
+ on_item(SqlServerStreamItem::Columns { columns: &columns, column_types: &column_types })?;
columns_emitted = true;
}
}
@@ -671,7 +673,8 @@ pub async fn stream_first_result_set(
if active_result_index.is_none() {
active_result_index = Some(row.result_index());
columns = row.columns().iter().map(|c| c.name().to_string()).collect();
- on_item(SqlServerStreamItem::Columns(&columns))?;
+ column_types = row.columns().iter().map(sqlserver_column_type_name).collect();
+ on_item(SqlServerStreamItem::Columns { columns: &columns, column_types: &column_types })?;
columns_emitted = true;
}
if Some(row.result_index()) != active_result_index {
@@ -688,7 +691,7 @@ pub async fn stream_first_result_set(
}
if !columns_emitted {
- on_item(SqlServerStreamItem::Columns(&columns))?;
+ on_item(SqlServerStreamItem::Columns { columns: &columns, column_types: &column_types })?;
}
Ok(SqlServerStreamExportSummary { columns, rows_exported })
}
diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs
index 3a42d3655..98f075233 100644
--- a/crates/dbx-core/src/lib.rs
+++ b/crates/dbx-core/src/lib.rs
@@ -61,6 +61,7 @@ pub mod table_export;
pub mod table_import;
pub mod table_structure_sql;
pub mod task_supervisor;
+pub mod temporal_format;
pub mod text_export;
pub mod token_usage;
pub mod transfer;
diff --git a/crates/dbx-core/src/query_result_export.rs b/crates/dbx-core/src/query_result_export.rs
index cd168acf2..cc75be08e 100644
--- a/crates/dbx-core/src/query_result_export.rs
+++ b/crates/dbx-core/src/query_result_export.rs
@@ -81,6 +81,8 @@ pub struct QueryResultExportRequest {
pub client_session_id: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution_id: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub date_time_format: Option,
}
fn split_excel_cell_text(value: &str) -> Vec {
@@ -563,6 +565,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(
+ &result.rows,
+ &column_types,
+ request.date_time_format.as_deref(),
+ );
if format == "csv" || format == "txt" {
if let Some(file) = text_file.as_mut() {
@@ -570,12 +577,12 @@ async fn export_query_result_core_inner(
let header = format_text_export_header(&format, &columns);
file.write_all(header.as_bytes()).map_err(|e| format!("Failed to write export header: {e}"))?;
if row_count > 0 {
- let rows = format_text_export_rows(&format, &result.rows);
+ let rows = format_text_export_rows(&format, &formatted_rows);
write!(file, "\n{rows}").map_err(|e| format!("Failed to write export rows: {e}"))?;
}
wrote_text_header = true;
} else if row_count > 0 {
- let rows = format_text_export_rows(&format, &result.rows);
+ let rows = format_text_export_rows(&format, &formatted_rows);
write!(file, "\n{rows}").map_err(|e| format!("Failed to write export rows: {e}"))?;
}
}
@@ -591,7 +598,7 @@ async fn export_query_result_core_inner(
)?);
}
if let Some(writer) = xlsx.as_mut() {
- for row in &result.rows {
+ for row in &formatted_rows {
writer.write_row(row).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
}
}
@@ -708,6 +715,7 @@ async fn try_export_postgres_query_result_stream(
if xlsx_hard_limit_active { row_limit.map(|limit| limit.saturating_add(1)) } else { row_limit };
let progress_row_interval = request.page_size.max(1) as u64;
let mut columns: Vec = Vec::new();
+ let mut temporal_column_types: Vec = Vec::new();
let mut rows_exported = 0_u64;
let mut last_progress_rows = 0_u64;
let mut last_progress_at = Instant::now();
@@ -735,6 +743,7 @@ async fn try_export_postgres_query_result_stream(
match item {
crate::db::postgres::PostgresQueryStreamItem::Columns { columns: stream_columns, column_types } => {
columns = stream_columns;
+ temporal_column_types = column_types.clone();
if let Some(file) = text_file.as_mut() {
let header = format_text_export_header(format, &columns);
file.write_all(header.as_bytes()).map_err(|e| format!("Failed to write export header: {e}"))?;
@@ -753,18 +762,23 @@ async fn try_export_postgres_query_result_stream(
if xlsx_hard_limit_active && rows_exported as usize >= XLSX_MAX_DATA_ROWS {
return Err(XLSX_ROW_LIMIT_ERROR.to_string());
}
+ let formatted = crate::temporal_format::format_temporal_export_row_with_string_types(
+ &row,
+ &temporal_column_types,
+ request.date_time_format.as_deref(),
+ );
if let Some(file) = text_file.as_mut() {
- let rows = format_text_export_rows(format, std::slice::from_ref(&row));
+ let rows = format_text_export_rows(format, std::slice::from_ref(&formatted));
write!(file, "\n{rows}").map_err(|e| format!("Failed to write export rows: {e}"))?;
} else if let Some(writer) = xlsx.as_mut() {
- writer.write_row(&row).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
+ writer.write_row(&formatted).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
} else {
let xlsx_file =
File::create(&request.file_path).map_err(|e| format!("Failed to create XLSX file: {e}"))?;
xlsx =
Some(start_query_result_xlsx_workbook(BufWriter::new(xlsx_file), request, &columns, &[])?);
if let Some(writer) = xlsx.as_mut() {
- writer.write_row(&row).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
+ writer.write_row(&formatted).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
}
}
rows_exported += 1;
@@ -870,6 +884,7 @@ async fn try_export_mysql_query_result_stream(
if xlsx_hard_limit_active { row_limit.map(|limit| limit.saturating_add(1)) } else { row_limit };
let progress_row_interval = request.page_size.max(1) as u64;
let mut columns: Vec = Vec::new();
+ let mut temporal_column_types: Vec = Vec::new();
let mut rows_exported = 0_u64;
let mut last_progress_rows = 0_u64;
let mut last_progress_at = Instant::now();
@@ -946,6 +961,7 @@ async fn try_export_mysql_query_result_stream(
match item {
crate::db::mysql::MySqlQueryStreamItem::Columns { columns: stream_columns, column_types } => {
columns = stream_columns;
+ temporal_column_types = column_types.clone();
if let Some(file) = text_file.as_mut() {
let header = format_text_export_header(format, &columns);
file.write_all(header.as_bytes()).map_err(|e| format!("Failed to write export header: {e}"))?;
@@ -964,18 +980,23 @@ async fn try_export_mysql_query_result_stream(
if xlsx_hard_limit_active && rows_exported as usize >= XLSX_MAX_DATA_ROWS {
return Err(XLSX_ROW_LIMIT_ERROR.to_string());
}
+ let formatted = crate::temporal_format::format_temporal_export_row_with_string_types(
+ &row,
+ &temporal_column_types,
+ request.date_time_format.as_deref(),
+ );
if let Some(file) = text_file.as_mut() {
- let rows = format_text_export_rows(format, std::slice::from_ref(&row));
+ let rows = format_text_export_rows(format, std::slice::from_ref(&formatted));
write!(file, "\n{rows}").map_err(|e| format!("Failed to write export rows: {e}"))?;
} else if let Some(writer) = xlsx.as_mut() {
- writer.write_row(&row).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
+ writer.write_row(&formatted).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
} else {
let xlsx_file =
File::create(&request.file_path).map_err(|e| format!("Failed to create XLSX file: {e}"))?;
xlsx =
Some(start_query_result_xlsx_workbook(BufWriter::new(xlsx_file), request, &columns, &[])?);
if let Some(writer) = xlsx.as_mut() {
- writer.write_row(&row).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
+ writer.write_row(&formatted).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
}
}
rows_exported += 1;
@@ -1115,6 +1136,7 @@ async fn try_export_clickhouse_query_result_stream(
if xlsx_hard_limit_active { row_limit.map(|limit| limit.saturating_add(1)) } else { row_limit };
let progress_row_interval = request.page_size.max(1) as u64;
let mut columns: Vec = Vec::new();
+ let mut temporal_column_types: Vec = Vec::new();
let mut rows_exported = 0_u64;
let mut last_progress_rows = 0_u64;
let mut last_progress_at = Instant::now();
@@ -1142,6 +1164,7 @@ async fn try_export_clickhouse_query_result_stream(
column_types,
} => {
columns = stream_columns;
+ temporal_column_types = column_types.clone();
if let Some(file) = text_file.as_mut() {
let header = format_text_export_header(format, &columns);
file.write_all(header.as_bytes()).map_err(|e| format!("Failed to write export header: {e}"))?;
@@ -1160,18 +1183,23 @@ async fn try_export_clickhouse_query_result_stream(
if xlsx_hard_limit_active && rows_exported as usize >= XLSX_MAX_DATA_ROWS {
return Err(XLSX_ROW_LIMIT_ERROR.to_string());
}
+ let formatted = crate::temporal_format::format_temporal_export_row_with_string_types(
+ &row,
+ &temporal_column_types,
+ request.date_time_format.as_deref(),
+ );
if let Some(file) = text_file.as_mut() {
- let rows = format_text_export_rows(format, std::slice::from_ref(&row));
+ let rows = format_text_export_rows(format, std::slice::from_ref(&formatted));
write!(file, "\n{rows}").map_err(|e| format!("Failed to write export rows: {e}"))?;
} else if let Some(writer) = xlsx.as_mut() {
- writer.write_row(&row).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
+ writer.write_row(&formatted).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
} else {
let xlsx_file =
File::create(&request.file_path).map_err(|e| format!("Failed to create XLSX file: {e}"))?;
xlsx =
Some(start_query_result_xlsx_workbook(BufWriter::new(xlsx_file), request, &columns, &[])?);
if let Some(writer) = xlsx.as_mut() {
- writer.write_row(&row).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
+ writer.write_row(&formatted).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
}
}
rows_exported += 1;
@@ -1267,6 +1295,7 @@ async fn try_export_sqlserver_query_result_stream(
let stream_row_limit =
if xlsx_hard_limit_active { row_limit.map(|limit| limit.saturating_add(1)) } else { row_limit };
let mut columns: Vec = Vec::new();
+ let mut temporal_column_types: Vec = Vec::new();
let mut rows_exported = 0_u64;
let mut last_progress_rows = 0_u64;
let mut last_progress_at = Instant::now();
@@ -1299,8 +1328,9 @@ async fn try_export_sqlserver_query_result_stream(
cancel_token.clone(),
|item| {
match item {
- crate::db::sqlserver::SqlServerStreamItem::Columns(stream_columns) => {
+ crate::db::sqlserver::SqlServerStreamItem::Columns { columns: stream_columns, column_types } => {
columns = stream_columns.to_vec();
+ temporal_column_types = column_types.to_vec();
if let Some(file) = text_file.as_mut() {
let header = format_text_export_header(format, &columns);
file.write_all(header.as_bytes()).map_err(|e| format!("Failed to write export header: {e}"))?;
@@ -1315,18 +1345,23 @@ async fn try_export_sqlserver_query_result_stream(
if xlsx_hard_limit_active && rows_exported as usize >= XLSX_MAX_DATA_ROWS {
return Err(XLSX_ROW_LIMIT_ERROR.to_string());
}
+ let formatted = crate::temporal_format::format_temporal_export_row_with_string_types(
+ row,
+ &temporal_column_types,
+ request.date_time_format.as_deref(),
+ );
if let Some(file) = text_file.as_mut() {
- let rows = format_text_export_rows(format, &[row.to_vec()]);
+ let rows = format_text_export_rows(format, std::slice::from_ref(&formatted));
write!(file, "\n{rows}").map_err(|e| format!("Failed to write export rows: {e}"))?;
} else if let Some(writer) = xlsx.as_mut() {
- writer.write_row(row).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
+ writer.write_row(&formatted).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
} else {
let xlsx_file =
File::create(&request.file_path).map_err(|e| format!("Failed to create XLSX file: {e}"))?;
xlsx =
Some(start_query_result_xlsx_workbook(BufWriter::new(xlsx_file), request, &columns, &[])?);
if let Some(writer) = xlsx.as_mut() {
- writer.write_row(row).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
+ writer.write_row(&formatted).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
}
}
rows_exported += 1;
@@ -1394,6 +1429,7 @@ mod tests {
keyset_optimization_enabled: true,
client_session_id: None,
execution_id: None,
+ date_time_format: None,
}
}
diff --git a/crates/dbx-core/src/table_export.rs b/crates/dbx-core/src/table_export.rs
index 9a399e650..a00064601 100644
--- a/crates/dbx-core/src/table_export.rs
+++ b/crates/dbx-core/src/table_export.rs
@@ -56,6 +56,8 @@ pub struct TableExportRequest {
pub batch_size: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub row_limit: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub date_time_format: Option,
}
#[derive(Debug, Clone, Serialize)]
@@ -516,7 +518,12 @@ async fn try_export_native_table_stream(
&cancelled,
cancel_token.clone(),
|row| {
- let row_csv = format_csv_rows(&[row.to_vec()]);
+ let formatted = crate::temporal_format::format_temporal_export_row(
+ row,
+ column_types,
+ request.date_time_format.as_deref(),
+ );
+ let row_csv = format_csv_rows(&[formatted]);
write!(file, "\n{row_csv}").map_err(|e| format!("Failed to write CSV rows: {e}"))?;
rows_exported += 1;
if rows_exported % progress_interval == 0 {
@@ -555,7 +562,12 @@ async fn try_export_native_table_stream(
&cancelled,
cancel_token.clone(),
|row| {
- let row_tsv = format_tsv_rows(&[row.to_vec()]);
+ let formatted = crate::temporal_format::format_temporal_export_row(
+ row,
+ column_types,
+ request.date_time_format.as_deref(),
+ );
+ let row_tsv = format_tsv_rows(&[formatted]);
write!(file, "\n{row_tsv}").map_err(|e| format!("Failed to write TXT rows: {e}"))?;
rows_exported += 1;
if rows_exported % progress_interval == 0 {
@@ -578,14 +590,14 @@ async fn try_export_native_table_stream(
result
}
"xlsx" => {
- let column_types = export_column_types(request);
+ let xlsx_column_types = export_column_types(request);
let xlsx_file =
std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to create XLSX file: {e}"))?;
let mut writer = start_streaming_xlsx_workbook(
BufWriter::new(xlsx_file),
Some(&request.table_name),
col_names,
- &column_types,
+ &xlsx_column_types,
)?;
let result = stream_native_table_rows(
state,
@@ -596,7 +608,12 @@ async fn try_export_native_table_stream(
&cancelled,
cancel_token.clone(),
|row| {
- writer.write_row(row).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
+ let formatted = crate::temporal_format::format_temporal_export_row(
+ row,
+ column_types,
+ request.date_time_format.as_deref(),
+ );
+ writer.write_row(&formatted).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
rows_exported += 1;
if rows_exported % progress_interval == 0 {
on_progress(TableExportProgress {
@@ -645,7 +662,12 @@ async fn try_export_native_table_stream(
if !is_first_row {
file.write_all(b",\n").map_err(|e| format!("Failed to write JSON: {e}"))?;
}
- write_json_row_object(&mut file, col_names, row)?;
+ let formatted = crate::temporal_format::format_temporal_export_row(
+ row,
+ column_types,
+ request.date_time_format.as_deref(),
+ );
+ write_json_row_object(&mut file, col_names, &formatted)?;
is_first_row = false;
rows_exported += 1;
if rows_exported % progress_interval == 0 {
@@ -684,7 +706,12 @@ async fn try_export_native_table_stream(
&cancelled,
cancel_token.clone(),
|row| {
- let rows_markdown = format_markdown_rows(&[row.to_vec()]);
+ let formatted = crate::temporal_format::format_temporal_export_row(
+ row,
+ column_types,
+ request.date_time_format.as_deref(),
+ );
+ let rows_markdown = format_markdown_rows(&[formatted]);
if !rows_markdown.is_empty() {
if wrote_rows {
file.write_all(b"\n").map_err(|e| format!("Failed to write Markdown: {e}"))?;
@@ -1016,15 +1043,20 @@ pub async fn export_table_data_core(
if row_count == 0 {
break;
}
+ let formatted_rows = crate::temporal_format::format_temporal_export_rows(
+ &result.rows,
+ &column_types,
+ request.date_time_format.as_deref(),
+ );
if is_first_batch {
// First batch: write header + rows via format_csv
- let csv_content = format_csv(&col_names, &result.rows);
+ let csv_content = format_csv(&col_names, &formatted_rows);
file.write_all(csv_content.as_bytes()).map_err(|e| format!("Failed to write CSV: {e}"))?;
is_first_batch = false;
} else {
// Subsequent batches: write rows only (prepend newline for separation)
- let rows_csv = format_csv_rows(&result.rows);
+ let rows_csv = format_csv_rows(&formatted_rows);
if !rows_csv.is_empty() {
write!(file, "\n{rows_csv}").map_err(|e| format!("Failed to write CSV rows: {e}"))?;
}
@@ -1098,13 +1130,18 @@ pub async fn export_table_data_core(
if row_count == 0 {
break;
}
+ let formatted_rows = crate::temporal_format::format_temporal_export_rows(
+ &result.rows,
+ &column_types,
+ request.date_time_format.as_deref(),
+ );
if is_first_batch {
- let rows_tsv = format_tsv_rows(&result.rows);
+ let rows_tsv = format_tsv_rows(&formatted_rows);
write!(file, "\n{rows_tsv}").map_err(|e| format!("Failed to write TXT rows: {e}"))?;
is_first_batch = false;
} else {
- let rows_tsv = format_tsv_rows(&result.rows);
+ let rows_tsv = format_tsv_rows(&formatted_rows);
if !rows_tsv.is_empty() {
write!(file, "\n{rows_tsv}").map_err(|e| format!("Failed to write TXT rows: {e}"))?;
}
@@ -1135,7 +1172,7 @@ pub async fn export_table_data_core(
}
}
"xlsx" => {
- let column_types = export_column_types(request);
+ let xlsx_column_types = export_column_types(request);
// Create a dedicated file handle for the streaming XLSX writer
// instead of cloning the outer BufWriter's handle. This avoids
// sharing a file descriptor between two independent buffers.
@@ -1145,7 +1182,7 @@ pub async fn export_table_data_core(
BufWriter::new(xlsx_file),
Some(&request.table_name),
&col_names,
- &column_types,
+ &xlsx_column_types,
)?;
loop {
@@ -1188,7 +1225,12 @@ pub async fn export_table_data_core(
}
for row in &result.rows {
- writer.write_row(row).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
+ let formatted = crate::temporal_format::format_temporal_export_row(
+ row,
+ &column_types,
+ request.date_time_format.as_deref(),
+ );
+ writer.write_row(&formatted).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
}
rows_exported += row_count as u64;
@@ -1278,7 +1320,12 @@ pub async fn export_table_data_core(
if !is_first_row {
file.write_all(b",\n").map_err(|e| format!("Failed to write JSON: {e}"))?;
}
- write_json_row_object(&mut file, &col_names, row)?;
+ let formatted = crate::temporal_format::format_temporal_export_row(
+ row,
+ &column_types,
+ request.date_time_format.as_deref(),
+ );
+ write_json_row_object(&mut file, &col_names, &formatted)?;
is_first_row = false;
}
@@ -1350,7 +1397,12 @@ pub async fn export_table_data_core(
break;
}
- let rows_markdown = format_markdown_rows(&result.rows);
+ let formatted_rows = crate::temporal_format::format_temporal_export_rows(
+ &result.rows,
+ &column_types,
+ request.date_time_format.as_deref(),
+ );
+ let rows_markdown = format_markdown_rows(&formatted_rows);
if !rows_markdown.is_empty() {
if wrote_rows {
file.write_all(b"\n").map_err(|e| format!("Failed to write Markdown: {e}"))?;
@@ -1622,6 +1674,7 @@ mod tests {
skip_count: false,
batch_size: Some(500),
row_limit: Some(1000),
+ date_time_format: None,
};
let sql = table_cursor_sql(
@@ -1669,6 +1722,7 @@ mod tests {
skip_count: false,
batch_size: Some(100),
row_limit: None,
+ date_time_format: None,
};
let sql = table_cursor_sql(&request, &DatabaseType::Oracle, &columns, &primary_keys);
assert_eq!(sql, "SELECT \"ID\", \"NAME\" FROM \"APP\".\"USERS\"");
diff --git a/crates/dbx-core/src/table_import.rs b/crates/dbx-core/src/table_import.rs
index 0e5fe65a2..7b940ea96 100644
--- a/crates/dbx-core/src/table_import.rs
+++ b/crates/dbx-core/src/table_import.rs
@@ -200,6 +200,8 @@ pub struct TableImportRequest {
#[serde(default)]
pub create_table: bool,
pub batch_size: usize,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub date_time_format: Option,
}
#[derive(Debug, Clone, Serialize)]
@@ -1334,6 +1336,29 @@ pub fn build_import_insert_batch_from_rows(
table: &str,
schema: &str,
db_type: &DatabaseType,
+) -> Result