From 203eab3dec732ca9517f2fd0cd14585d2d03aa84 Mon Sep 17 00:00:00 2001 From: zipg Date: Thu, 3 Sep 2026 17:51:50 +0800 Subject: [PATCH] feat(export): configurable CSV field quoting mode --- .../editor/EditorSettingsDialog.vue | 23 +++ .../src/components/objects/ObjectBrowser.vue | 3 +- .../src/composables/useDataGridExport.ts | 14 +- .../useSidebarTreeExportRuntime.ts | 6 +- apps/desktop/src/i18n/locales/en.ts | 4 + apps/desktop/src/i18n/locales/es.ts | 4 + apps/desktop/src/i18n/locales/it.ts | 4 + apps/desktop/src/i18n/locales/ja.ts | 4 + apps/desktop/src/i18n/locales/ko.ts | 4 + apps/desktop/src/i18n/locales/pt-BR.ts | 4 + apps/desktop/src/i18n/locales/zh-CN.ts | 4 + apps/desktop/src/i18n/locales/zh-TW.ts | 4 + apps/desktop/src/lib/backend/http.ts | 5 +- apps/desktop/src/lib/backend/tauri.ts | 7 +- apps/desktop/src/lib/export/csvQuoteMode.ts | 16 ++ apps/desktop/src/lib/export/exportFormats.ts | 8 +- .../src/lib/settings/editorSettingsDraft.ts | 1 + apps/desktop/src/stores/queryStore.ts | 2 + apps/desktop/src/stores/settingsStore.ts | 7 +- crates/dbx-core/src/csv_export.rs | 142 ++++++++++++++++-- crates/dbx-core/src/query_result_export.rs | 89 ++++++++--- crates/dbx-core/src/table_export.rs | 74 +++++++-- .../live_clickhouse_query_result_export.rs | 1 + crates/dbx-core/tests/live_mysql57.rs | 2 + .../live_postgres_query_result_export.rs | 6 + .../tests/live_sqlserver_completion.rs | 1 + .../live_sqlserver_query_result_export.rs | 1 + .../app-tests/editorSettingsDraft.test.ts | 8 + packages/app-tests/exportFormats.test.ts | 14 ++ packages/app-tests/settingsStore.test.ts | 22 +++ packages/app-tests/useDataGridExport.test.ts | 6 +- src-tauri/src/commands/csv_export.rs | 8 +- 32 files changed, 442 insertions(+), 56 deletions(-) create mode 100644 apps/desktop/src/lib/export/csvQuoteMode.ts diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index f9ec783686..672961cd90 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -90,6 +90,7 @@ import { type TabPlacement, type TabSortMode, type UpdateDownloadSource, + type CsvQuoteMode, type CustomThemeColors, type CustomTheme, type McpConnectionPolicy, @@ -548,6 +549,7 @@ const editSidebarShowTooltips = ref(settingsStore.editorSettings.sidebarShowTool const editSidebarIndent = ref(settingsStore.editorSettings.sidebarIndent); const editSidebarFontSize = ref(settingsStore.editorSettings.sidebarFontSize); const editExportBatchSize = ref(settingsStore.editorSettings.exportBatchSize); +const editCsvQuoteMode = ref(settingsStore.editorSettings.csvQuoteMode); const editGlobalDateTimeDisplayFormat = ref(settingsStore.editorSettings.globalDateTimeDisplayFormat); const editGlobalDateTimeExportFormat = ref(settingsStore.editorSettings.globalDateTimeExportFormat); const editGlobalDateTimeImportFormat = ref(settingsStore.editorSettings.globalDateTimeImportFormat); @@ -698,6 +700,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft { sidebarCopyTableNameIncludeSchema: editSidebarCopyTableNameIncludeSchema.value, redisKeyTemplates: normalizeRedisKeyTemplates(editRedisKeyTemplates.value), exportBatchSize: editExportBatchSize.value, + csvQuoteMode: editCsvQuoteMode.value, globalDateTimeDisplayFormat: editGlobalDateTimeDisplayFormat.value, globalDateTimeExportFormat: editGlobalDateTimeExportFormat.value, globalDateTimeImportFormat: editGlobalDateTimeImportFormat.value, @@ -1102,6 +1105,7 @@ function syncEditorSettingsDraftFromStore() { editSidebarIndent.value = settingsStore.editorSettings.sidebarIndent; editSidebarFontSize.value = settingsStore.editorSettings.sidebarFontSize; editExportBatchSize.value = settingsStore.editorSettings.exportBatchSize; + editCsvQuoteMode.value = settingsStore.editorSettings.csvQuoteMode; editGlobalDateTimeDisplayFormat.value = settingsStore.editorSettings.globalDateTimeDisplayFormat; editGlobalDateTimeExportFormat.value = settingsStore.editorSettings.globalDateTimeExportFormat; editGlobalDateTimeImportFormat.value = settingsStore.editorSettings.globalDateTimeImportFormat; @@ -1398,6 +1402,7 @@ function resetDefaultsForTab(tab: SettingsCategory) { editTableColumnTemplateRows.value = tableColumnTemplateRowsFromSettings(DEFAULT_EDITOR_SETTINGS.tableColumnTemplateFields); editRedisKeyTemplates.value = normalizeRedisKeyTemplates(DEFAULT_EDITOR_SETTINGS.redisKeyTemplates).join("\n"); editExportBatchSize.value = DEFAULT_EDITOR_SETTINGS.exportBatchSize; + editCsvQuoteMode.value = DEFAULT_EDITOR_SETTINGS.csvQuoteMode; editGlobalDateTimeDisplayFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeDisplayFormat; editGlobalDateTimeExportFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeExportFormat; editGlobalDateTimeImportFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeImportFormat; @@ -1507,6 +1512,7 @@ function resetAllDefaults() { editSidebarCopyTableNameIncludeSchema.value = DEFAULT_EDITOR_SETTINGS.sidebarCopyTableNameIncludeSchema; editRedisKeyTemplates.value = normalizeRedisKeyTemplates(DEFAULT_EDITOR_SETTINGS.redisKeyTemplates).join("\n"); editExportBatchSize.value = DEFAULT_EDITOR_SETTINGS.exportBatchSize; + editCsvQuoteMode.value = DEFAULT_EDITOR_SETTINGS.csvQuoteMode; editGlobalDateTimeDisplayFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeDisplayFormat; editGlobalDateTimeExportFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeExportFormat; editGlobalDateTimeImportFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeImportFormat; @@ -6518,6 +6524,23 @@ onUnmounted(() => {
{{ t("settings.exportSection") }}
+
+
+ +

+ {{ t("settings.csvQuoteModeDescription") }} +

+
+ +
diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 6fa17375a9..0eec24ae25 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -2156,7 +2156,7 @@ 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); + await api.exportQueryResultCsv(filePath, result.columns, result.rows, 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); @@ -2194,6 +2194,7 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | " tableName: row.name, filePath, format, + csvQuoteMode: settingsStore.editorSettings.csvQuoteMode, columns, columnComments: format === "xlsx" ? columnComments : undefined, autoFilter: format === "xlsx" ? autoFilter : undefined, diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index a1dff83f3c..337baec568 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -756,7 +756,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { } outputPath = path as string; } - await api.exportQueryResultCsv(outputPath, result.columns, rows); + await api.exportQueryResultCsv(outputPath, result.columns, rows, useSettingsStore().editorSettings.csvQuoteMode); if (needsFullExport && exportProgressState) { exportProgressState.value = { ...exportProgressState.value, @@ -795,7 +795,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { outputPath = path as string; } const result = await resultToExport(undefined, undefined, false); - await api.exportQueryResultCsv(outputPath, result.columns, result.rows); + await api.exportQueryResultCsv(outputPath, result.columns, result.rows, useSettingsStore().editorSettings.csvQuoteMode); toast(t("grid.exported")); } catch (e: any) { toast(t("grid.exportFailed", { message: translateBackendError(t, e) }), 5000); @@ -1154,6 +1154,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { tableName: meta.tableName, filePath: outputPath, format, + csvQuoteMode: editorSettings.csvQuoteMode, columns: columns.value, columnTypes: columnTypes.value, columnComments: format === "xlsx" ? buildXlsxHeaderOverrides(columns.value, visibleXlsxColumnComments.value, headerMode) : undefined, @@ -1227,7 +1228,14 @@ export function useDataGridExport(options: UseDataGridExportOptions) { }); const columnComments = format === "xlsx" ? buildXlsxHeaderOverrides(allColumns.value, allXlsxColumnComments.value, headerMode) : undefined; const request = baseRequest - ? { ...baseRequest, dateTimeFormat: useSettingsStore().editorSettings.globalDateTimeExportFormat || undefined, numericColumnRightAlign: useSettingsStore().editorSettings.numericColumnRightAlign ?? true, columnComments, autoFilter: format === "xlsx" ? autoFilter : undefined } + ? { + ...baseRequest, + csvQuoteMode: useSettingsStore().editorSettings.csvQuoteMode, + dateTimeFormat: useSettingsStore().editorSettings.globalDateTimeExportFormat || undefined, + numericColumnRightAlign: useSettingsStore().editorSettings.numericColumnRightAlign ?? true, + columnComments, + autoFilter: format === "xlsx" ? autoFilter : undefined, + } : undefined; if (!request) throw new Error("Unable to build query result export request"); diff --git a/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts b/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts index bdb8b62157..b5a064c7dd 100644 --- a/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts +++ b/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts @@ -18,6 +18,7 @@ import { fetchTableDataForExport } from "@/lib/table/tableDataExport"; 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"; +import type { CsvQuoteMode } from "@/lib/export/csvQuoteMode"; type StructureCopyFormat = "tsv" | "markdown"; @@ -42,6 +43,7 @@ interface SidebarTableExportTarget { identifierQuote?: string; batchSize: number; rowLimit: number | null; + csvQuoteMode: CsvQuoteMode; fileNameBase?: string; } @@ -360,6 +362,7 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt identifierQuote: connectionStore.connectionIdentifierQuote(connectionId), batchSize: editorSettings.exportBatchSize, rowLimit: editorSettings.exportRowLimitEnabled ? editorSettings.exportRowLimit : null, + csvQuoteMode: editorSettings.csvQuoteMode, }; } @@ -395,7 +398,7 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt executePage: (sql) => api.executeQuery(connectionId, database, sql), }); if (format === "csv") { - await api.exportQueryResultCsv(outputPath, result.columns, result.rows); + await api.exportQueryResultCsv(outputPath, result.columns, result.rows, 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); @@ -424,6 +427,7 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt tableName: target.tableName, filePath: outputPath, format, + csvQuoteMode: target.csvQuoteMode, columns: queryColumns, columnComments, autoFilter: format === "xlsx" ? autoFilter : undefined, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 951b801a84..62ca7203ae 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -6855,6 +6855,10 @@ export default { queryExportKeysetOptimizationEnabled: "Try keyset optimization", queryExportKeysetOptimizationEnabledDescription: "Only applies to safely recognized single-table queries; complex queries automatically fall back.", exportSection: "Export", + csvQuoteMode: "CSV field quotes", + csvQuoteModeDescription: "Choose how CSV fields use double quotes.", + csvQuoteModeAll: "All fields", + csvQuoteModeNecessary: "Only when needed", performanceSection: "Performance", shortcutExecuteSql: "Execute SQL", shortcutExecuteSqlInNewResultTab: "Execute SQL in new result tab", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 6e57ae02b2..e6c145f591 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -6472,6 +6472,10 @@ export default withEnglishFallback({ queryExportKeysetOptimizationEnabled: "Intentar optimización keyset", queryExportKeysetOptimizationEnabledDescription: "Solo aplica a consultas de una sola tabla reconocidas de forma segura; las consultas complejas usan el método estándar automáticamente.", exportSection: "Exportar", + csvQuoteMode: "Comillas de campos CSV", + csvQuoteModeDescription: "Elige cómo usar comillas dobles en los campos CSV.", + csvQuoteModeAll: "Todos los campos", + csvQuoteModeNecessary: "Solo cuando sea necesario", performanceSection: "Rendimiento", shortcutExecuteSql: "Ejecutar SQL", shortcutExecuteSqlInNewResultTab: "Ejecutar SQL en una nueva pestaña de resultados", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 9507f3b091..379fbb00dd 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -6472,6 +6472,10 @@ export default withEnglishFallback({ queryExportKeysetOptimizationEnabled: "Prova ottimizzazione keyset", queryExportKeysetOptimizationEnabledDescription: "Si applica solo a query a tabella singola riconosciute in sicurezza; le query complesse ricadono automaticamente.", exportSection: "Esportazione", + csvQuoteMode: "Virgolette campi CSV", + csvQuoteModeDescription: "Scegli come usare le virgolette doppie nei campi CSV.", + csvQuoteModeAll: "Tutti i campi", + csvQuoteModeNecessary: "Solo quando necessario", performanceSection: "Prestazioni", shortcutExecuteSql: "Esegui SQL", shortcutExecuteSqlInNewResultTab: "Esegui SQL in una nuova scheda dei risultati", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 4003d7f9a9..cbe28f02c3 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -6480,6 +6480,10 @@ export default withEnglishFallback({ exportBatchSize: "エクスポートバッチサイズ", exportBatchSizeDescription: "テーブルデータエクスポート時のバッチあたりの取得行数(100〜100000)。", exportSection: "エクスポート", + csvQuoteMode: "CSV フィールドの引用符", + csvQuoteModeDescription: "CSV フィールドを二重引用符で囲む方法を選択します。", + csvQuoteModeAll: "すべてのフィールド", + csvQuoteModeNecessary: "必要な場合のみ", performanceSection: "パフォーマンス", shortcutExecuteSql: "SQLを実行", shortcutExecuteSqlInNewResultTab: "新しい結果タブでSQLを実行", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 5180846f28..23c6b788c4 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -6631,6 +6631,10 @@ export default withEnglishFallback({ shortcutUppercaseSelection: "선택 영역을 대문자로 변환", shortcutLowercaseSelection: "선택 영역을 소문자로 변환", shortcutExPasteSqlInCondition: "ExPaste: IN 조건으로 붙여넣기", + csvQuoteMode: "CSV 필드 따옴표", + csvQuoteModeDescription: "CSV 내보내기 시 필드에 큰따옴표를 사용하는 방식을 선택합니다.", + csvQuoteModeAll: "모든 필드", + csvQuoteModeNecessary: "필수 필드만", }, driverStore: { jreDirRemoveFailed: "이전 JRE 디렉터리 제거 실패: {path} (원래 오류: {error})", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 3d90841045..1dd151fb8f 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -6474,6 +6474,10 @@ export default withEnglishFallback({ queryExportKeysetOptimizationEnabled: "Tentar otimização keyset", queryExportKeysetOptimizationEnabledDescription: "Aplica-se apenas a consultas de tabela única reconhecidas com segurança; consultas complexas recuam automaticamente.", exportSection: "Exportar", + csvQuoteMode: "Aspas dos campos CSV", + csvQuoteModeDescription: "Escolha como usar aspas duplas nos campos CSV.", + csvQuoteModeAll: "Todos os campos", + csvQuoteModeNecessary: "Somente quando necessário", performanceSection: "Desempenho", shortcutExecuteSql: "Executar SQL", shortcutExecuteSqlInNewResultTab: "Executar SQL em uma nova aba de resultados", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index fd7b90ac32..fd160f13e6 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -6836,6 +6836,10 @@ export default withEnglishFallback({ queryExportKeysetOptimizationEnabled: "尝试 keyset 优化", queryExportKeysetOptimizationEnabledDescription: "仅对可安全识别的单表查询生效,复杂查询会自动回退。", exportSection: "导出", + csvQuoteMode: "CSV 字段引号", + csvQuoteModeDescription: "选择 CSV 导出时字段使用双引号的方式。", + csvQuoteModeAll: "全部字段", + csvQuoteModeNecessary: "仅必要字段", performanceSection: "性能", shortcutExecuteSql: "执行 SQL", shortcutExecuteSqlInNewResultTab: "在新结果标签页中执行 SQL", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 9c3d1877c4..cb468341b1 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -6190,6 +6190,10 @@ export default withEnglishFallback({ updateDownloadSourceOfficial: "官方來源(建議)", updateDownloadSourceCnb: "CNB", exportSection: "匯出", + csvQuoteMode: "CSV 欄位引號", + csvQuoteModeDescription: "選擇 CSV 匯出時欄位使用雙引號的方式。", + csvQuoteModeAll: "全部欄位", + csvQuoteModeNecessary: "僅必要欄位", performanceSection: "效能", exportBatchSize: "匯出批次大小", dateTimeSection: "日期與時間", diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 4cabe7279b..f15d05049c 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -153,6 +153,7 @@ import type { ElasticsearchDeleteByQueryResult, } from "@/lib/backend/tauri"; import type { QueryEditability } from "@/lib/sql/sqlAnalysis"; +import type { CsvQuoteMode } from "@/lib/export/csvQuoteMode"; import { isTerminalTransferProgress } from "@/lib/backend/transferProgress"; import type { DataGridColumnDistinctValuesSqlOptions, @@ -2595,9 +2596,9 @@ export async function cancelQueryResultExport(exportId: string, executionId?: st }); } -export async function exportQueryResultCsv(filePath: string, columns: string[], rows: readonly (readonly XlsxCellValue[])[]): Promise { +export async function exportQueryResultCsv(filePath: string, columns: string[], rows: readonly (readonly XlsxCellValue[])[], csvQuoteMode: CsvQuoteMode = "all"): Promise { const { formatCsv } = await import("@/lib/export/exportFormats"); - const content = formatCsv(columns, rows as (string | number | boolean | null)[][]); + const content = formatCsv(columns, rows as (string | number | boolean | null)[][], csvQuoteMode); const fileName = filePath.split(/[\\/]/).pop() || "export.csv"; const blob = new Blob(["\uFEFF", content], { type: "text/csv;charset=utf-8", diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index ce69a4e81b..3901032fb2 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -8,6 +8,7 @@ import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog"; import { decodeMeilisearchDocumentPage, decodeMeilisearchSearchResult, type MeilisearchDocumentPage, type MeilisearchDocumentPageWire, type MeilisearchSearchResult, type MeilisearchSearchWireResult } from "@/lib/backend/meilisearchTransport"; import type { XuguTablespaceInfo } from "@/types/database"; import type { CreatedKey, EnqueuedTaskSummary, KeyCreateInput, KeyListItem, KeyPage, KeyUpdateInput, MeilisearchSystemOverview, MeilisearchTask, TaskListInput, TaskPage, TaskSelector } from "@/types/meilisearchManagement"; +import type { CsvQuoteMode } from "@/lib/export/csvQuoteMode"; /** Normalize Tauri rejections once at the public backend boundary. */ async function invokeBackend(command: string, args?: Record): Promise { @@ -4839,6 +4840,7 @@ export interface TableExportRequest { tableName: string; filePath: string; format: "csv" | "xlsx" | "json" | "markdown" | "sql" | "txt"; + csvQuoteMode?: CsvQuoteMode; columns?: string[]; columnTypes?: Array; columnComments?: Array | null; @@ -4862,6 +4864,7 @@ export interface TableCsvExportOptions { columns?: string[]; pageSize?: number; timeoutSecs?: number; + csvQuoteMode?: CsvQuoteMode; } export interface TableExportProgress { @@ -4886,6 +4889,7 @@ export interface QueryResultExportRequest { useAgentCursor: boolean; filePath: string; format: "csv" | "xlsx" | "txt" | "sql"; + csvQuoteMode?: CsvQuoteMode; includeSqlSheet?: boolean; pageSize: number; rowLimit?: number | null; @@ -5029,12 +5033,13 @@ export async function recordDatabaseExportDestination(directory: string): Promis await invoke("record_database_export_destination", { directory }); } -export async function exportQueryResultCsv(filePath: string, columns: string[], rows: readonly (readonly XlsxCellValue[])[]): Promise { +export async function exportQueryResultCsv(filePath: string, columns: string[], rows: readonly (readonly XlsxCellValue[])[], csvQuoteMode: CsvQuoteMode = "all"): Promise { return invoke("export_query_result_csv", { request: { filePath, columns, rows, + csvQuoteMode, }, }); } diff --git a/apps/desktop/src/lib/export/csvQuoteMode.ts b/apps/desktop/src/lib/export/csvQuoteMode.ts new file mode 100644 index 0000000000..6b292ea1b7 --- /dev/null +++ b/apps/desktop/src/lib/export/csvQuoteMode.ts @@ -0,0 +1,16 @@ +export type CsvQuoteMode = "all" | "necessary"; + +export const DEFAULT_CSV_QUOTE_MODE: CsvQuoteMode = "all"; + +export function normalizeCsvQuoteMode(value: unknown): CsvQuoteMode { + return value === "necessary" ? "necessary" : DEFAULT_CSV_QUOTE_MODE; +} + +export function csvFieldNeedsQuotes(value: string): boolean { + return /[",\r\n]/.test(value); +} + +export function escapeCsvField(value: string, quoteMode: CsvQuoteMode): string { + if (quoteMode === "necessary" && !csvFieldNeedsQuotes(value)) return value; + return `"${value.replace(/"/g, '""')}"`; +} diff --git a/apps/desktop/src/lib/export/exportFormats.ts b/apps/desktop/src/lib/export/exportFormats.ts index 9419e7833d..1ebdfd9efc 100644 --- a/apps/desktop/src/lib/export/exportFormats.ts +++ b/apps/desktop/src/lib/export/exportFormats.ts @@ -1,12 +1,12 @@ import type { DatabaseType, QueryResult } from "@/types/database"; import * as api from "@/lib/backend/api"; +import { escapeCsvField, type CsvQuoteMode } from "@/lib/export/csvQuoteMode"; export type ExportCellValue = string | number | boolean | null; -export function formatCsv(columns: string[], rows: ExportCellValue[][]): string { - const esc = (v: string) => `"${v.replace(/"/g, '""')}"`; - const header = columns.map(esc).join(","); - const body = rows.map((row) => row.map((c) => (c === null ? "" : esc(String(c)))).join(",")).join("\n"); +export function formatCsv(columns: string[], rows: ExportCellValue[][], quoteMode: CsvQuoteMode = "all"): string { + const header = columns.map((column) => escapeCsvField(column, quoteMode)).join(","); + const body = rows.map((row) => row.map((cell) => (cell === null ? "" : escapeCsvField(String(cell), quoteMode))).join(",")).join("\n"); return `${header}\n${body}`; } diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts index 8e0e7dcfa9..7162fe790a 100644 --- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts +++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts @@ -88,6 +88,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [ "sidebarCopyTableNameIncludeSchema", "redisKeyTemplates", "exportBatchSize", + "csvQuoteMode", "exportRowLimitEnabled", "exportRowLimit", "queryExportKeysetOptimizationEnabled", diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 4401c98c0c..f71cd55479 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -6975,6 +6975,7 @@ export const useQueryStore = defineStore("query", () => { totalRows, timeoutSecs: queryTimeoutSecsForConnection(conn, settingsStore.editorSettings.globalQueryTimeoutSecs), keysetOptimizationEnabled: settings.queryExportKeysetOptimizationEnabled, + csvQuoteMode: settings.csvQuoteMode, clientSessionId, executionId: uuid(), exportTableName: options.exportTableName, @@ -7012,6 +7013,7 @@ export const useQueryStore = defineStore("query", () => { totalRows: null, timeoutSecs: queryTimeoutSecsForConnection(conn, settingsStore.editorSettings.globalQueryTimeoutSecs), keysetOptimizationEnabled: settings.queryExportKeysetOptimizationEnabled, + csvQuoteMode: settings.csvQuoteMode, clientSessionId: `${tabClientSessionId(tab, "export")}:${exportId}`, executionId: uuid(), numericColumnRightAlign: settings.numericColumnRightAlign, diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index cfe13464fb..8341127187 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -26,11 +26,12 @@ import { normalizeSqlVariableSyntaxOverrides, type SqlVariableSyntaxOverrides } import { DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS, normalizeTableColumnTemplateFields } from "@/lib/table/tableColumnTemplates"; import { type DataTabReuseMode, DEFAULT_DATA_TAB_REUSE_MODE, normalizeDataTabReuseMode } from "@/lib/tabs/dataTabReuseMode"; import { normalizeCompletionTriggerMode, type SqlCompletionTriggerMode } from "@/lib/sql/sqlCompletionTriggerPolicy"; +import { DEFAULT_CSV_QUOTE_MODE, normalizeCsvQuoteMode, type CsvQuoteMode } from "@/lib/export/csvQuoteMode"; import { configureMetadataRuntimeCache, METADATA_CACHE_DEFAULT_MEMORY_MB, normalizeMetadataCacheMemoryMb } from "@/lib/metadata/metadataRuntimeCache"; import type { AiApiStyle, AiAssistantMode, AiAuthMethod, AiChatSelectionState, AiConfig, AiConfigItem, AiConfiguredModel, AiEffortLevel, AiEffortSelection, AiModelEffortPreference, AiProvider, AiReasoningLevel, AiTestConnectionResult } from "@/types/ai"; import type { SqlShortcutAction, SqlSnippet, TableInfoTab } from "@/types/database"; -export type { AiApiStyle, AiAuthMethod, AiChatSelectionState, AiConfig, AiConfigItem, AiConfiguredModel, AiEffortLevel, AiEffortSelection, AiProvider, AiReasoningLevel, AiTestConnectionResult, DataTabReuseMode, SavedSqlOpenTargetMode, SqlCompletionTriggerMode }; +export type { AiApiStyle, AiAuthMethod, AiChatSelectionState, AiConfig, AiConfigItem, AiConfiguredModel, AiEffortLevel, AiEffortSelection, AiProvider, AiReasoningLevel, AiTestConnectionResult, CsvQuoteMode, DataTabReuseMode, SavedSqlOpenTargetMode, SqlCompletionTriggerMode }; export interface DesktopSettings { show_tray_icon: boolean; @@ -789,6 +790,7 @@ export interface EditorSettings { sqlShortcuts: SqlShortcutAction[]; tableColumnTemplateFields: string[]; exportBatchSize: number; + csvQuoteMode: CsvQuoteMode; /** Global Redis key-search templates; overridden by non-empty connection templates. */ redisKeyTemplates: string[]; exportRowLimitEnabled: boolean; @@ -1014,6 +1016,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { sqlShortcuts: [], tableColumnTemplateFields: [...DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS], exportBatchSize: 2000, + csvQuoteMode: DEFAULT_CSV_QUOTE_MODE, redisKeyTemplates: [], exportRowLimitEnabled: false, exportRowLimit: 100000, @@ -1484,6 +1487,7 @@ export function normalizeEditorSettings(settings: Partial, exist sqlShortcuts: normalizeSqlShortcuts(settings.sqlShortcuts, existing?.sqlShortcuts), tableColumnTemplateFields: normalizeTableColumnTemplateFields(settings.tableColumnTemplateFields), exportBatchSize: typeof settings.exportBatchSize === "number" && settings.exportBatchSize >= 100 && settings.exportBatchSize <= 100000 ? Math.round(settings.exportBatchSize) : DEFAULT_EDITOR_SETTINGS.exportBatchSize, + csvQuoteMode: normalizeCsvQuoteMode(settings.csvQuoteMode), redisKeyTemplates: normalizeRedisKeyTemplates(settings.redisKeyTemplates), exportRowLimitEnabled: typeof settings.exportRowLimitEnabled === "boolean" ? settings.exportRowLimitEnabled : DEFAULT_EDITOR_SETTINGS.exportRowLimitEnabled, exportRowLimit: typeof settings.exportRowLimit === "number" && settings.exportRowLimit >= 100 && settings.exportRowLimit <= 2147483647 ? Math.round(settings.exportRowLimit) : DEFAULT_EDITOR_SETTINGS.exportRowLimit, @@ -2151,6 +2155,7 @@ export const useSettingsStore = defineStore("settings", () => { if (partial.sqlShortcuts !== undefined) editorSettings.value.sqlShortcuts = normalizeSqlShortcuts(partial.sqlShortcuts); 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))); + if (partial.csvQuoteMode !== undefined) editorSettings.value.csvQuoteMode = normalizeCsvQuoteMode(partial.csvQuoteMode); if (partial.redisKeyTemplates !== undefined) editorSettings.value.redisKeyTemplates = normalizeRedisKeyTemplates(partial.redisKeyTemplates); if (partial.exportRowLimitEnabled !== undefined) editorSettings.value.exportRowLimitEnabled = partial.exportRowLimitEnabled; if (partial.exportRowLimit !== undefined) editorSettings.value.exportRowLimit = Math.min(2147483647, Math.max(100, Math.round(partial.exportRowLimit))); diff --git a/crates/dbx-core/src/csv_export.rs b/crates/dbx-core/src/csv_export.rs index e20702a200..36e06658c8 100644 --- a/crates/dbx-core/src/csv_export.rs +++ b/crates/dbx-core/src/csv_export.rs @@ -11,6 +11,14 @@ use crate::sql_dialect::{build_table_data_select_sql, TableDataSelectSqlOptions} const TABLE_DATA_EXPORT_PAGE_SIZE: usize = 10_000; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CsvQuoteMode { + #[default] + All, + Necessary, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TableCsvExportOptions { @@ -26,6 +34,8 @@ pub struct TableCsvExportOptions { pub page_size: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout_secs: Option, + #[serde(default)] + pub csv_quote_mode: CsvQuoteMode, } /// CSV 转义直写目标 buffer:包引号 + 内部 `"` 翻倍。值不含 `"` 时整段拷贝, @@ -46,6 +56,18 @@ pub(crate) fn push_csv_escaped(out: &mut String, value: &str) { out.push('"'); } +fn csv_field_needs_quotes(value: &str) -> bool { + value.bytes().any(|byte| matches!(byte, b',' | b'"' | b'\n' | b'\r')) +} + +pub(crate) fn push_csv_field(out: &mut String, value: &str, quote_mode: CsvQuoteMode) { + if quote_mode == CsvQuoteMode::All || csv_field_needs_quotes(value) { + push_csv_escaped(out, value); + } else { + out.push_str(value); + } +} + struct CsvEscapedWriter<'a>(&'a mut String); impl fmt::Write for CsvEscapedWriter<'_> { @@ -72,6 +94,26 @@ pub(crate) fn push_csv_text_value(out: &mut String, value: &Value) { out.push('"'); } +fn push_csv_value_with_quote_mode(out: &mut String, value: &Value, quote_mode: CsvQuoteMode, quote_null: bool) { + if quote_mode == CsvQuoteMode::All { + if value.is_null() && !quote_null { + return; + } + push_csv_text_value(out, value); + return; + } + + match value { + Value::Null => {} + Value::String(value) => push_csv_field(out, value, quote_mode), + Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }), + Value::Number(value) => { + fmt::write(out, format_args!("{value}")).expect("writing a number into a String cannot fail") + } + other => push_csv_field(out, &other.to_string(), quote_mode), + } +} + fn push_csv_value(out: &mut String, value: &Value) { if value.is_null() { return; @@ -118,12 +160,19 @@ pub(crate) fn estimated_rows_capacity(rows: &[Vec]) -> usize { cells.saturating_mul(12).min(ROWS_CAPACITY_ESTIMATE_MAX) } +#[cfg_attr(not(test), allow(dead_code))] pub(crate) fn escape_csv(value: &str) -> String { let mut out = String::with_capacity(value.len() + 2); push_csv_escaped(&mut out, value); out } +fn escape_csv_with_quote_mode(value: &str, quote_mode: CsvQuoteMode) -> String { + let mut out = String::with_capacity(value.len() + 2); + push_csv_field(&mut out, value, quote_mode); + out +} + fn format_csv_value(value: &Value) -> String { let mut out = String::new(); push_csv_value(&mut out, value); @@ -172,20 +221,28 @@ pub(crate) fn format_tsv(columns: &[String], rows: &[Vec]) -> String { /// use the same empty-cell representation as table-data exports. Used by the /// streaming query-result export for batches after the first. pub(crate) fn push_query_result_csv_row(out: &mut String, row: &[Value]) { + push_query_result_csv_row_with_quote_mode(out, row, CsvQuoteMode::All); +} + +pub(crate) fn push_query_result_csv_row_with_quote_mode(out: &mut String, row: &[Value], quote_mode: CsvQuoteMode) { for (cell_index, cell) in row.iter().enumerate() { if cell_index > 0 { out.push(','); } - push_csv_value(out, cell); + push_csv_value_with_quote_mode(out, cell, quote_mode, false); } } pub(crate) fn push_table_csv_row(out: &mut String, row: &[Value]) { + push_table_csv_row_with_quote_mode(out, row, CsvQuoteMode::All); +} + +pub(crate) fn push_table_csv_row_with_quote_mode(out: &mut String, row: &[Value], quote_mode: CsvQuoteMode) { for (cell_index, cell) in row.iter().enumerate() { if cell_index > 0 { out.push(','); } - push_csv_text_value(out, cell); + push_csv_value_with_quote_mode(out, cell, quote_mode, true); } } @@ -223,30 +280,73 @@ pub fn format_csv(columns: &[String], rows: &[Vec]) -> String { format_csv_with_value_formatter(columns, rows) } +pub fn format_csv_with_quote_mode(columns: &[String], rows: &[Vec], quote_mode: CsvQuoteMode) -> String { + let mut out = String::with_capacity( + estimated_rows_capacity(rows).saturating_add(columns.len().saturating_mul(12)).min(ROWS_CAPACITY_ESTIMATE_MAX), + ); + for (index, column) in columns.iter().enumerate() { + if index > 0 { + out.push(','); + } + push_csv_field(&mut out, column, quote_mode); + } + out.push('\n'); + for (row_index, row) in rows.iter().enumerate() { + if row_index > 0 { + out.push('\n'); + } + push_query_result_csv_row_with_quote_mode(&mut out, row, quote_mode); + } + out +} + pub fn format_query_result_csv(columns: &[String], rows: &[Vec]) -> String { format_csv(columns, rows) } -fn write_csv_text_row(writer: &mut impl Write, values: impl IntoIterator) -> Result<(), String> { +pub fn format_query_result_csv_with_quote_mode( + columns: &[String], + rows: &[Vec], + quote_mode: CsvQuoteMode, +) -> String { + format_csv_with_quote_mode(columns, rows, quote_mode) +} + +fn write_csv_text_row( + writer: &mut impl Write, + values: impl IntoIterator, + quote_mode: CsvQuoteMode, +) -> Result<(), String> { let mut first = true; for value in values { if !first { writer.write_all(b",").map_err(|err| err.to_string())?; } first = false; - writer.write_all(escape_csv(&value).as_bytes()).map_err(|err| err.to_string())?; + writer.write_all(escape_csv_with_quote_mode(&value, quote_mode).as_bytes()).map_err(|err| err.to_string())?; } Ok(()) } -fn write_csv_value_row(writer: &mut impl Write, values: impl IntoIterator) -> Result<(), String> { +fn write_csv_value_row( + writer: &mut impl Write, + values: impl IntoIterator, + quote_mode: CsvQuoteMode, +) -> Result<(), String> { let mut first = true; for value in values { if !first { writer.write_all(b",").map_err(|err| err.to_string())?; } first = false; - writer.write_all(format_csv_value(&value).as_bytes()).map_err(|err| err.to_string())?; + let formatted = if quote_mode == CsvQuoteMode::All { + format_csv_value(&value) + } else { + let mut formatted = String::new(); + push_csv_value_with_quote_mode(&mut formatted, &value, quote_mode, false); + formatted + }; + writer.write_all(formatted.as_bytes()).map_err(|err| err.to_string())?; } Ok(()) } @@ -304,7 +404,7 @@ pub async fn export_table_data_csv_core(state: &AppState, options: TableCsvExpor .await?; if !wrote_header { - write_csv_text_row(&mut writer, result.columns)?; + write_csv_text_row(&mut writer, result.columns, options.csv_quote_mode)?; wrote_header = true; } @@ -314,7 +414,7 @@ pub async fn export_table_data_csv_core(state: &AppState, options: TableCsvExpor } for row in result.rows { writer.write_all(b"\n").map_err(|err| err.to_string())?; - write_csv_value_row(&mut writer, row)?; + write_csv_value_row(&mut writer, row, options.csv_quote_mode)?; } rows_exported += fetched as u64; @@ -333,7 +433,10 @@ pub async fn export_table_data_csv_core(state: &AppState, options: TableCsvExpor #[cfg(test)] mod tests { - use super::{format_csv, format_query_result_csv, format_query_result_csv_rows, format_tsv}; + use super::{ + format_csv, format_csv_with_quote_mode, format_query_result_csv, format_query_result_csv_rows, format_tsv, + CsvQuoteMode, + }; use serde_json::json; #[test] @@ -354,6 +457,27 @@ mod tests { assert_eq!(out, "\"id\",\"note\"\n\"1\","); } + #[test] + fn necessary_quote_mode_only_quotes_csv_special_characters() { + let out = format_csv_with_quote_mode( + &["id".to_string(), "district,name".to_string(), "note".to_string()], + &[ + vec![json!(2085252644_u64), json!("延庆县"), json!("plain")], + vec![json!(2085252645_u64), json!("门头沟区"), json!("line 1\n\"line 2\"")], + ], + CsvQuoteMode::Necessary, + ); + assert_eq!( + out, + "id,\"district,name\",note\n2085252644,延庆县,plain\n2085252645,门头沟区,\"line 1\n\"\"line 2\"\"\"" + ); + } + + #[test] + fn csv_quote_mode_defaults_to_all_for_backward_compatibility() { + assert_eq!(CsvQuoteMode::default(), CsvQuoteMode::All); + } + #[test] fn formats_streamed_query_result_null_as_empty_cell_and_preserves_literal_null() { let out = format_query_result_csv_rows(&[vec![Value::Null, json!("NULL"), json!("")]]); diff --git a/crates/dbx-core/src/query_result_export.rs b/crates/dbx-core/src/query_result_export.rs index 4181198bc7..05340759f7 100644 --- a/crates/dbx-core/src/query_result_export.rs +++ b/crates/dbx-core/src/query_result_export.rs @@ -10,7 +10,10 @@ use std::sync::{ use std::time::{Duration, Instant}; use crate::connection::{AppState, PoolKind}; -use crate::csv_export::{format_query_result_csv, format_tsv, push_query_result_csv_row, push_tsv_row}; +use crate::csv_export::{ + format_query_result_csv_with_quote_mode, format_tsv, push_query_result_csv_row_with_quote_mode, push_tsv_row, + CsvQuoteMode, +}; pub use crate::database_export::ExportStatus; use crate::database_export::{build_export_insert_statements, is_export_cancelled, BuildExportInsertStatementsOptions}; use crate::models::connection::DatabaseType; @@ -79,6 +82,8 @@ pub struct QueryResultExportRequest { pub file_path: String, pub format: String, #[serde(default)] + pub csv_quote_mode: CsvQuoteMode, + #[serde(default)] pub include_sql_sheet: bool, pub page_size: usize, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -410,8 +415,12 @@ fn effective_row_limit(request: &QueryResultExportRequest) -> Option { request.row_limit } -fn format_text_export_header(format: &str, columns: &[String]) -> String { - let content = if format == "csv" { format_query_result_csv(columns, &[]) } else { format_tsv(columns, &[]) }; +fn format_text_export_header(format: &str, columns: &[String], csv_quote_mode: CsvQuoteMode) -> String { + let content = if format == "csv" { + format_query_result_csv_with_quote_mode(columns, &[], csv_quote_mode) + } else { + format_tsv(columns, &[]) + }; content.strip_suffix('\n').unwrap_or(&content).to_string() } @@ -420,11 +429,12 @@ fn write_text_export_row( format: &str, row: &[Value], buffer: &mut String, + csv_quote_mode: CsvQuoteMode, ) -> Result<(), String> { buffer.clear(); buffer.push('\n'); if format == "csv" { - push_query_result_csv_row(buffer, row); + push_query_result_csv_row_with_quote_mode(buffer, row, csv_quote_mode); } else { push_tsv_row(buffer, row); } @@ -436,12 +446,13 @@ fn write_text_export_rows( format: &str, rows: &[Vec], buffer: &mut String, + csv_quote_mode: CsvQuoteMode, ) -> Result<(), String> { buffer.clear(); for row in rows { buffer.push('\n'); if format == "csv" { - push_query_result_csv_row(buffer, row); + push_query_result_csv_row_with_quote_mode(buffer, row, csv_quote_mode); } else { push_tsv_row(buffer, row); } @@ -905,14 +916,26 @@ async fn export_query_result_core_inner( if format == "csv" || format == "txt" { if let Some(file) = text_file.as_mut() { if !wrote_text_header { - let header = format_text_export_header(&format, &columns); + let header = format_text_export_header(&format, &columns, request.csv_quote_mode); file.write_all(header.as_bytes()).map_err(|e| format!("Failed to write export header: {e}"))?; if row_count > 0 { - write_text_export_rows(file, &format, formatted_rows.as_ref(), &mut text_buffer)?; + write_text_export_rows( + file, + &format, + formatted_rows.as_ref(), + &mut text_buffer, + request.csv_quote_mode, + )?; } wrote_text_header = true; } else if row_count > 0 { - write_text_export_rows(file, &format, formatted_rows.as_ref(), &mut text_buffer)?; + write_text_export_rows( + file, + &format, + formatted_rows.as_ref(), + &mut text_buffer, + request.csv_quote_mode, + )?; } } } else if format == "sql" { @@ -981,7 +1004,7 @@ async fn export_query_result_core_inner( if format == "csv" || format == "txt" { if !wrote_text_header { - let header = format_text_export_header(&format, &columns); + let header = format_text_export_header(&format, &columns, request.csv_quote_mode); if let Some(file) = text_file.as_mut() { file.write_all(header.as_bytes()).map_err(|e| format!("Failed to write export header: {e}"))?; } @@ -1092,7 +1115,7 @@ async fn try_export_postgres_query_result_stream( if let Some(writer) = sql_writer.as_mut() { writer.set_columns(columns.clone(), &column_types, &[], request); } else if let Some(file) = text_file.as_mut() { - let header = format_text_export_header(format, &columns); + let header = format_text_export_header(format, &columns, request.csv_quote_mode); file.write_all(header.as_bytes()).map_err(|e| format!("Failed to write export header: {e}"))?; } else { let xlsx_file = @@ -1114,7 +1137,13 @@ async fn try_export_postgres_query_result_stream( if let Some(writer) = sql_writer.as_mut() { writer.write_row(formatted.into_owned(), None)?; } else if let Some(file) = text_file.as_mut() { - write_text_export_row(file, format, formatted.as_ref(), &mut text_buffer)?; + write_text_export_row( + file, + format, + formatted.as_ref(), + &mut text_buffer, + request.csv_quote_mode, + )?; } else if let Some(writer) = xlsx.as_mut() { writer.write_row(formatted.as_ref()).map_err(|e| format!("Failed to write XLSX row: {e}"))?; } else { @@ -1327,7 +1356,7 @@ async fn try_export_mysql_query_result_stream( if let Some(writer) = sql_writer.as_mut() { writer.set_columns(columns.clone(), &column_types, &[], request); } else if let Some(file) = text_file.as_mut() { - let header = format_text_export_header(format, &columns); + let header = format_text_export_header(format, &columns, request.csv_quote_mode); file.write_all(header.as_bytes()).map_err(|e| format!("Failed to write export header: {e}"))?; } else { let xlsx_file = @@ -1349,7 +1378,13 @@ async fn try_export_mysql_query_result_stream( if let Some(writer) = sql_writer.as_mut() { writer.write_row(formatted.into_owned(), None)?; } else if let Some(file) = text_file.as_mut() { - write_text_export_row(file, format, formatted.as_ref(), &mut text_buffer)?; + write_text_export_row( + file, + format, + formatted.as_ref(), + &mut text_buffer, + request.csv_quote_mode, + )?; } else if let Some(writer) = xlsx.as_mut() { writer.write_row(formatted.as_ref()).map_err(|e| format!("Failed to write XLSX row: {e}"))?; } else { @@ -1541,7 +1576,7 @@ async fn try_export_clickhouse_query_result_stream( if let Some(writer) = sql_writer.as_mut() { writer.set_columns(columns.clone(), &column_types, &[], request); } else if let Some(file) = text_file.as_mut() { - let header = format_text_export_header(format, &columns); + let header = format_text_export_header(format, &columns, request.csv_quote_mode); file.write_all(header.as_bytes()).map_err(|e| format!("Failed to write export header: {e}"))?; } else { let xlsx_file = @@ -1563,7 +1598,13 @@ async fn try_export_clickhouse_query_result_stream( if let Some(writer) = sql_writer.as_mut() { writer.write_row(formatted.into_owned(), None)?; } else if let Some(file) = text_file.as_mut() { - write_text_export_row(file, format, formatted.as_ref(), &mut text_buffer)?; + write_text_export_row( + file, + format, + formatted.as_ref(), + &mut text_buffer, + request.csv_quote_mode, + )?; } else if let Some(writer) = xlsx.as_mut() { writer.write_row(formatted.as_ref()).map_err(|e| format!("Failed to write XLSX row: {e}"))?; } else { @@ -1724,7 +1765,7 @@ async fn try_export_sqlserver_query_result_stream( if let Some(writer) = sql_writer.as_mut() { writer.set_columns(columns.clone(), &temporal_column_types, &[], request); } else if let Some(file) = text_file.as_mut() { - let header = format_text_export_header(format, &columns); + let header = format_text_export_header(format, &columns, request.csv_quote_mode); file.write_all(header.as_bytes()).map_err(|e| format!("Failed to write export header: {e}"))?; } else { let xlsx_file = @@ -1742,7 +1783,13 @@ async fn try_export_sqlserver_query_result_stream( if let Some(writer) = sql_writer.as_mut() { writer.write_row(formatted.into_owned(), None)?; } else if let Some(file) = text_file.as_mut() { - write_text_export_row(file, format, formatted.as_ref(), &mut text_buffer)?; + write_text_export_row( + file, + format, + formatted.as_ref(), + &mut text_buffer, + request.csv_quote_mode, + )?; } else if let Some(writer) = xlsx.as_mut() { writer.write_row(formatted.as_ref()).map_err(|e| format!("Failed to write XLSX row: {e}"))?; } else { @@ -1922,6 +1969,7 @@ mod tests { client_session_id: None, execution_id: None, date_time_format: None, + csv_quote_mode: CsvQuoteMode::All, export_table_name: None, export_column_types: None, numeric_column_right_align: false, @@ -1969,7 +2017,10 @@ mod tests { #[test] fn txt_export_header_keeps_columns_for_empty_results() { - assert_eq!(format_text_export_header("txt", &["id".to_string(), "note".to_string()]), "id\tnote"); + assert_eq!( + format_text_export_header("txt", &["id".to_string(), "note".to_string()], CsvQuoteMode::All), + "id\tnote" + ); } #[test] @@ -1978,7 +2029,7 @@ mod tests { let mut output = Vec::new(); let mut buffer = String::new(); - write_text_export_row(&mut output, "csv", &row, &mut buffer).expect("write csv row"); + write_text_export_row(&mut output, "csv", &row, &mut buffer, CsvQuoteMode::All).expect("write csv row"); assert_eq!(String::from_utf8(output).expect("utf8 csv"), "\n,\"\",\"line\n\"\"two\"\"\""); } diff --git a/crates/dbx-core/src/table_export.rs b/crates/dbx-core/src/table_export.rs index a75c963e27..842275add2 100644 --- a/crates/dbx-core/src/table_export.rs +++ b/crates/dbx-core/src/table_export.rs @@ -10,8 +10,13 @@ use tokio_util::sync::CancellationToken; use crate::connection::MysqlMode; use crate::connection::{config_for_pool_key, task_client_session_id, AppState, PoolKind}; #[cfg(test)] +use crate::csv_export::format_csv; +#[cfg(test)] use crate::csv_export::format_tsv_rows; -use crate::csv_export::{format_csv, format_tsv, push_table_csv_row, push_tsv_row}; +use crate::csv_export::{ + format_csv_with_quote_mode, format_tsv, push_table_csv_row, push_table_csv_row_with_quote_mode, push_tsv_row, + CsvQuoteMode, +}; pub use crate::database_export::ExportStatus; use crate::database_export::{ build_export_insert_statements, is_export_cancelled, is_internal_export_column, BuildExportInsertStatementsOptions, @@ -47,6 +52,8 @@ pub struct TableExportRequest { /// "csv", "xlsx", "json", "markdown", "sql", or "txt" pub format: String, #[serde(default)] + pub csv_quote_mode: CsvQuoteMode, + #[serde(default)] pub columns: Option>, #[serde(default)] pub column_types: Option>>, @@ -100,11 +107,17 @@ fn format_csv_rows(rows: &[Vec]) -> String { out } -fn write_table_text_row(file: &mut W, csv: bool, row: &[Value], buffer: &mut String) -> Result<(), String> { +fn write_table_text_row( + file: &mut W, + csv: bool, + row: &[Value], + buffer: &mut String, + csv_quote_mode: CsvQuoteMode, +) -> Result<(), String> { buffer.clear(); buffer.push('\n'); if csv { - push_table_csv_row(buffer, row); + push_table_csv_row_with_quote_mode(buffer, row, csv_quote_mode); } else { push_tsv_row(buffer, row); } @@ -116,12 +129,13 @@ fn write_table_text_rows( csv: bool, rows: &[Vec], buffer: &mut String, + csv_quote_mode: CsvQuoteMode, ) -> Result<(), String> { buffer.clear(); for row in rows { buffer.push('\n'); if csv { - push_table_csv_row(buffer, row); + push_table_csv_row_with_quote_mode(buffer, row, csv_quote_mode); } else { push_tsv_row(buffer, row); } @@ -958,7 +972,7 @@ async fn try_export_native_table_stream( std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to create file: {e}"))?, ); file.write_all(b"\xEF\xBB\xBF").map_err(|e| format!("Failed to write BOM: {e}"))?; - let header = format_csv(col_names, &[]); + let header = format_csv_with_quote_mode(col_names, &[], request.csv_quote_mode); let header = header.strip_suffix('\n').unwrap_or(&header); file.write_all(header.as_bytes()).map_err(|e| format!("Failed to write CSV: {e}"))?; let mut row_buffer = String::new(); @@ -977,7 +991,7 @@ async fn try_export_native_table_stream( column_types, request.date_time_format.as_deref(), ); - write_table_text_row(&mut file, true, formatted.as_ref(), &mut row_buffer)?; + write_table_text_row(&mut file, true, formatted.as_ref(), &mut row_buffer, request.csv_quote_mode)?; rows_exported += 1; if rows_exported.is_multiple_of(progress_interval) { on_progress(TableExportProgress { @@ -1021,7 +1035,13 @@ async fn try_export_native_table_stream( column_types, request.date_time_format.as_deref(), ); - write_table_text_row(&mut file, false, formatted.as_ref(), &mut row_buffer)?; + write_table_text_row( + &mut file, + false, + formatted.as_ref(), + &mut row_buffer, + request.csv_quote_mode, + )?; rows_exported += 1; if rows_exported.is_multiple_of(progress_interval) { on_progress(TableExportProgress { @@ -1578,12 +1598,19 @@ async fn export_table_data_core_inner( if is_first_batch { // First batch: write header + rows via format_csv - let csv_content = format_csv(&col_names, formatted_rows.as_ref()); + let csv_content = + format_csv_with_quote_mode(&col_names, formatted_rows.as_ref(), request.csv_quote_mode); 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) - write_table_text_rows(&mut file, true, formatted_rows.as_ref(), &mut text_buffer)?; + write_table_text_rows( + &mut file, + true, + formatted_rows.as_ref(), + &mut text_buffer, + request.csv_quote_mode, + )?; } rows_exported += row_count as u64; @@ -1665,10 +1692,22 @@ async fn export_table_data_core_inner( ); if is_first_batch { - write_table_text_rows(&mut file, false, formatted_rows.as_ref(), &mut text_buffer)?; + write_table_text_rows( + &mut file, + false, + formatted_rows.as_ref(), + &mut text_buffer, + request.csv_quote_mode, + )?; is_first_batch = false; } else { - write_table_text_rows(&mut file, false, formatted_rows.as_ref(), &mut text_buffer)?; + write_table_text_rows( + &mut file, + false, + formatted_rows.as_ref(), + &mut text_buffer, + request.csv_quote_mode, + )?; } rows_exported += row_count as u64; @@ -2210,6 +2249,7 @@ mod tests { batch_size: Some(batch_size), row_limit, date_time_format: None, + csv_quote_mode: CsvQuoteMode::All, numeric_column_right_align: false, column_comments: None, auto_filter: None, @@ -2314,7 +2354,7 @@ mod tests { let mut output = Vec::new(); let mut buffer = String::new(); - write_table_text_row(&mut output, true, &row, &mut buffer).expect("write csv row"); + write_table_text_row(&mut output, true, &row, &mut buffer, CsvQuoteMode::All).expect("write csv row"); assert_eq!(String::from_utf8(output).expect("utf8 csv"), "\n\"\",\"\",\"line\n\"\"two\"\"\""); } @@ -2391,6 +2431,7 @@ mod tests { batch_size: Some(50), row_limit: None, date_time_format: None, + csv_quote_mode: CsvQuoteMode::All, numeric_column_right_align: false, column_comments: None, auto_filter: None, @@ -2447,6 +2488,7 @@ mod tests { batch_size: Some(50), row_limit: None, date_time_format: None, + csv_quote_mode: CsvQuoteMode::All, numeric_column_right_align: false, column_comments: None, auto_filter: None, @@ -2481,6 +2523,7 @@ mod tests { batch_size: Some(50), row_limit: None, date_time_format: None, + csv_quote_mode: CsvQuoteMode::All, numeric_column_right_align: false, column_comments: None, auto_filter: None, @@ -2510,6 +2553,7 @@ mod tests { batch_size: Some(50), row_limit: None, date_time_format: None, + csv_quote_mode: CsvQuoteMode::All, numeric_column_right_align: false, column_comments: None, auto_filter: None, @@ -2547,6 +2591,7 @@ mod tests { batch_size: Some(25), row_limit: None, date_time_format: None, + csv_quote_mode: CsvQuoteMode::All, numeric_column_right_align: false, column_comments: None, auto_filter: None, @@ -2596,6 +2641,7 @@ mod tests { batch_size: Some(500), row_limit: Some(1000), date_time_format: None, + csv_quote_mode: CsvQuoteMode::All, numeric_column_right_align: false, column_comments: None, auto_filter: None, @@ -2639,6 +2685,7 @@ mod tests { batch_size: Some(50), row_limit: None, date_time_format: None, + csv_quote_mode: CsvQuoteMode::All, numeric_column_right_align: false, column_comments: None, auto_filter: None, @@ -2710,6 +2757,7 @@ mod tests { batch_size: Some(100), row_limit: None, date_time_format: None, + csv_quote_mode: CsvQuoteMode::All, numeric_column_right_align: false, column_comments: None, auto_filter: None, @@ -2763,6 +2811,7 @@ mod tests { batch_size: Some(100), row_limit: None, date_time_format: None, + csv_quote_mode: CsvQuoteMode::All, numeric_column_right_align: false, column_comments: None, auto_filter: None, @@ -2818,6 +2867,7 @@ mod tests { batch_size: Some(100), row_limit: None, date_time_format: None, + csv_quote_mode: CsvQuoteMode::All, numeric_column_right_align: false, column_comments: None, auto_filter: None, diff --git a/crates/dbx-core/tests/live_clickhouse_query_result_export.rs b/crates/dbx-core/tests/live_clickhouse_query_result_export.rs index 7afcc789fb..3e30d03cf8 100644 --- a/crates/dbx-core/tests/live_clickhouse_query_result_export.rs +++ b/crates/dbx-core/tests/live_clickhouse_query_result_export.rs @@ -101,6 +101,7 @@ async fn live_clickhouse_query_result_export_xlsx_streams_random_order_query_onc client_session_id: None, execution_id: Some(format!("live-clickhouse-query-export-{suffix}")), date_time_format: None, + csv_quote_mode: Default::default(), export_table_name: None, export_column_types: None, column_comments: None, diff --git a/crates/dbx-core/tests/live_mysql57.rs b/crates/dbx-core/tests/live_mysql57.rs index bd10f51c72..f1ce6b758e 100644 --- a/crates/dbx-core/tests/live_mysql57.rs +++ b/crates/dbx-core/tests/live_mysql57.rs @@ -474,6 +474,7 @@ async fn live_mysql_query_result_export_xlsx_streams_single_query_without_duplic client_session_id: None, execution_id: Some(format!("live-mysql-query-export-{suffix}")), date_time_format: None, + csv_quote_mode: Default::default(), export_table_name: None, export_column_types: None, column_comments: None, @@ -566,6 +567,7 @@ async fn live_mysql_xlsx_export_can_outlive_query_timeout_while_rows_keep_arrivi client_session_id: None, execution_id: Some(format!("live-mysql-query-export-timeout-{suffix}")), date_time_format: None, + csv_quote_mode: Default::default(), export_table_name: None, export_column_types: None, column_comments: None, diff --git a/crates/dbx-core/tests/live_postgres_query_result_export.rs b/crates/dbx-core/tests/live_postgres_query_result_export.rs index 3f837bd123..d94a214edc 100644 --- a/crates/dbx-core/tests/live_postgres_query_result_export.rs +++ b/crates/dbx-core/tests/live_postgres_query_result_export.rs @@ -143,6 +143,7 @@ async fn live_postgres_query_result_export_uses_single_streamed_query() { client_session_id: None, execution_id: Some(format!("live-postgres-query-export-{suffix}")), date_time_format: None, + csv_quote_mode: Default::default(), export_table_name: None, export_column_types: None, column_comments: None, @@ -225,6 +226,7 @@ async fn live_postgres_query_result_xlsx_preserves_temporal_cell_types() { client_session_id: Some(format!("live-postgres-xlsx-temporal-{suffix}")), execution_id: Some(format!("live-postgres-xlsx-temporal-{suffix}")), date_time_format: None, + csv_quote_mode: Default::default(), export_table_name: None, export_column_types: None, column_comments: None, @@ -296,6 +298,7 @@ async fn live_postgres_numeric_xlsx_ignores_fractional_trailing_zeros() { client_session_id: Some(format!("live-postgres-xlsx-numeric-{suffix}")), execution_id: Some(format!("live-postgres-xlsx-numeric-{suffix}")), date_time_format: None, + csv_quote_mode: Default::default(), export_table_name: None, export_column_types: None, column_comments: None, @@ -372,6 +375,7 @@ async fn live_postgres_truncated_batch_result_export_replays_safe_temp_setup() { client_session_id: Some(format!("temp-export-csv-{short_suffix}")), execution_id: Some(format!("temp-export-csv-{short_suffix}")), date_time_format: None, + csv_quote_mode: Default::default(), export_table_name: None, export_column_types: None, column_comments: None, @@ -450,6 +454,7 @@ async fn live_postgres_xlsx_export_can_outlive_query_timeout_while_rows_keep_arr client_session_id: None, execution_id: Some(format!("live-postgres-query-export-timeout-{suffix}")), date_time_format: None, + csv_quote_mode: Default::default(), export_table_name: None, export_column_types: None, column_comments: None, @@ -519,6 +524,7 @@ async fn live_postgres_stream_still_times_out_without_progress_and_recovers() { client_session_id: None, execution_id: Some(format!("live-postgres-query-export-stall-{suffix}")), date_time_format: None, + csv_quote_mode: Default::default(), export_table_name: None, export_column_types: None, column_comments: None, diff --git a/crates/dbx-core/tests/live_sqlserver_completion.rs b/crates/dbx-core/tests/live_sqlserver_completion.rs index db45dcccf9..421f86594b 100644 --- a/crates/dbx-core/tests/live_sqlserver_completion.rs +++ b/crates/dbx-core/tests/live_sqlserver_completion.rs @@ -1203,6 +1203,7 @@ async fn live_sqlserver_query_result_export_streams_cte_query_to_csv() { client_session_id: None, execution_id: Some(format!("live-sqlserver-export-{suffix}")), date_time_format: None, + csv_quote_mode: Default::default(), export_table_name: None, export_column_types: None, column_comments: None, diff --git a/crates/dbx-core/tests/live_sqlserver_query_result_export.rs b/crates/dbx-core/tests/live_sqlserver_query_result_export.rs index 0245c6962f..9ae57ccba5 100644 --- a/crates/dbx-core/tests/live_sqlserver_query_result_export.rs +++ b/crates/dbx-core/tests/live_sqlserver_query_result_export.rs @@ -122,6 +122,7 @@ async fn live_sqlserver_xlsx_export_can_outlive_query_timeout_while_rows_keep_ar client_session_id: None, execution_id: Some(format!("live-sqlserver-xlsx-{suffix}")), date_time_format: None, + csv_quote_mode: Default::default(), export_table_name: None, export_column_types: None, column_comments: None, diff --git a/packages/app-tests/editorSettingsDraft.test.ts b/packages/app-tests/editorSettingsDraft.test.ts index 63b52b6b74..c08055f0f1 100644 --- a/packages/app-tests/editorSettingsDraft.test.ts +++ b/packages/app-tests/editorSettingsDraft.test.ts @@ -56,3 +56,11 @@ test("round-trips completion selection behavior through the editor settings draf assert.deepEqual(patch, { selectFirstCompletionOnOpen: false }); assert.equal(editorSettingsDraftFromSettings({ ...DEFAULT_EDITOR_SETTINGS, ...patch }).selectFirstCompletionOnOpen, false); }); + +test("round-trips the persisted CSV quote mode through the editor settings draft", () => { + const base = editorSettingsDraftFromSettings(DEFAULT_EDITOR_SETTINGS); + const patch = editorSettingsPatchFromDraft({ ...base, csvQuoteMode: "necessary" }, base); + + assert.deepEqual(patch, { csvQuoteMode: "necessary" }); + assert.equal(editorSettingsDraftFromSettings({ ...DEFAULT_EDITOR_SETTINGS, ...patch }).csvQuoteMode, "necessary"); +}); diff --git a/packages/app-tests/exportFormats.test.ts b/packages/app-tests/exportFormats.test.ts index 894f0e35c9..3339e5e3be 100644 --- a/packages/app-tests/exportFormats.test.ts +++ b/packages/app-tests/exportFormats.test.ts @@ -15,3 +15,17 @@ test("formatCsv writes database null as an empty cell and preserves literal NULL '"id","note"\n"1",\n"2",""\n"3","NULL"', ); }); + +test("formatCsv only quotes fields with CSV special characters in necessary mode", () => { + assert.equal( + formatCsv( + ["id", "district,name", "note"], + [ + [2085252644, "延庆县", "plain"], + [2085252645, "门头沟区", 'line 1\n"line 2"'], + ], + "necessary", + ), + 'id,"district,name",note\n2085252644,延庆县,plain\n2085252645,门头沟区,"line 1\n""line 2"""', + ); +}); diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts index 4222bab3b0..7aa032cadf 100644 --- a/packages/app-tests/settingsStore.test.ts +++ b/packages/app-tests/settingsStore.test.ts @@ -186,6 +186,21 @@ test("updateEditorSettings persists numericColumnRightAlign toggles", async () = }); }); +test("updateEditorSettings persists the CSV quote mode", async () => { + await withMockLocalStorage({}, async () => { + setActivePinia(createPinia()); + const store = useSettingsStore(); + await store.initEditorSettings(); + + store.updateEditorSettings({ csvQuoteMode: "necessary" }); + assert.equal(store.editorSettings.csvQuoteMode, "necessary"); + await vi.waitFor(() => { + const saved = saveEditorSettingsMock.mock.calls.at(-1)?.[0] as { csvQuoteMode?: string } | undefined; + assert.equal(saved?.csvQuoteMode, "necessary"); + }); + }); +}); + test("migrates legacy execute-all settings to current once and preserves later explicit choices", async () => { await withMockLocalStorage({ "dbx-app-state:editor_settings": JSON.stringify({ executeMode: "all" }) }, async () => { setActivePinia(createPinia()); @@ -229,6 +244,13 @@ test("defaults export batch size to 2000 rows", () => { assert.equal(normalizeEditorSettings({ exportBatchSize: 2000 }).exportBatchSize, 2000); }); +test("CSV quote mode defaults to all fields and preserves a saved necessary mode", () => { + assert.equal(DEFAULT_EDITOR_SETTINGS.csvQuoteMode, "all"); + assert.equal(normalizeEditorSettings({}).csvQuoteMode, "all"); + assert.equal(normalizeEditorSettings({ csvQuoteMode: "necessary" }).csvQuoteMode, "necessary"); + assert.equal(normalizeEditorSettings({ csvQuoteMode: "invalid" as any }).csvQuoteMode, "all"); +}); + test("migrates the legacy saved export batch default to 2000 once", async () => { await withMockLocalStorage({ "dbx-editor-settings": JSON.stringify({ exportBatchSize: 10000 }) }, async () => { setActivePinia(createPinia()); diff --git a/packages/app-tests/useDataGridExport.test.ts b/packages/app-tests/useDataGridExport.test.ts index 0ccc00438c..d75b72e47c 100644 --- a/packages/app-tests/useDataGridExport.test.ts +++ b/packages/app-tests/useDataGridExport.test.ts @@ -382,7 +382,7 @@ test("default data grid export file names use sanitized base names and compact l }); test("full query result CSV export streams through the backend without loading all rows", async () => { - useSettingsStore().updateEditorSettings({ globalDateTimeExportFormat: "YYYY/M/D HH:mm:ss" }); + useSettingsStore().updateEditorSettings({ globalDateTimeExportFormat: "YYYY/M/D HH:mm:ss", csvQuoteMode: "necessary" }); const { composable, fullExportResult, queryResultExportRequest, exportProgressDialog, exportProgressState } = buildExportHarness(); await composable.exportCsv(); @@ -391,6 +391,7 @@ test("full query result CSV export streams through the backend without loading a assert.equal(queryResultExportRequest.mock.calls.length, 1); assert.equal(apiMock.startQueryResultExport.mock.calls.length, 1); assert.equal(apiMock.startQueryResultExport.mock.calls[0][0].dateTimeFormat, "YYYY/M/D HH:mm:ss"); + assert.equal(apiMock.startQueryResultExport.mock.calls[0][0].csvQuoteMode, "necessary"); assert.equal(apiMock.exportQueryResultCsv.mock.calls.length, 0); assert.equal(exportProgressDialog.value, true); assert.equal(exportProgressState.value.status, "Done"); @@ -687,6 +688,7 @@ test("missing query result export request does not fall back to the in-memory pa }); test("selected query result CSV export keeps the existing in-memory path", async () => { + useSettingsStore().updateEditorSettings({ csvQuoteMode: "necessary" }); const { composable, queryResultExportRequest } = buildExportHarness(); await composable.exportCsv([1]); @@ -696,6 +698,7 @@ test("selected query result CSV export keeps the existing in-memory path", async assert.equal(apiMock.exportQueryResultCsv.mock.calls.length, 1); assert.deepEqual(apiMock.exportQueryResultCsv.mock.calls[0][1], ["id", "name"]); assert.deepEqual(apiMock.exportQueryResultCsv.mock.calls[0][2], [[1, "Ada"]]); + assert.equal(apiMock.exportQueryResultCsv.mock.calls[0][3], "necessary"); }); test("selected query result CSV export formats only typed temporal columns", async () => { @@ -852,6 +855,7 @@ test("table data export leaves row limit unset by default", async () => { assert.equal(apiMock.startTableExport.mock.calls.length, 1); assert.equal(apiMock.startTableExport.mock.calls[0][0].rowLimit, null); + assert.equal(apiMock.startTableExport.mock.calls[0][0].csvQuoteMode, "all"); } finally { restoreStorage(); } diff --git a/src-tauri/src/commands/csv_export.rs b/src-tauri/src/commands/csv_export.rs index 790d28755b..01bf8af608 100644 --- a/src-tauri/src/commands/csv_export.rs +++ b/src-tauri/src/commands/csv_export.rs @@ -1,7 +1,9 @@ use std::sync::Arc; use crate::commands::connection::AppState; -use dbx_core::csv_export::{export_table_data_csv_core, format_query_result_csv, TableCsvExportOptions}; +use dbx_core::csv_export::{ + export_table_data_csv_core, format_query_result_csv_with_quote_mode, CsvQuoteMode, TableCsvExportOptions, +}; use serde::Deserialize; use serde_json::Value; use tauri::State; @@ -12,12 +14,14 @@ pub struct QueryResultCsvExportRequest { pub file_path: String, pub columns: Vec, pub rows: Vec>, + #[serde(default)] + pub csv_quote_mode: CsvQuoteMode, } #[tauri::command] pub async fn export_query_result_csv(request: QueryResultCsvExportRequest) -> Result<(), String> { tauri::async_runtime::spawn_blocking(move || { - let csv = format_query_result_csv(&request.columns, &request.rows); + let csv = format_query_result_csv_with_quote_mode(&request.columns, &request.rows, request.csv_quote_mode); std::fs::write(&request.file_path, format!("\u{FEFF}{csv}")).map_err(|err| err.to_string()) }) .await