feat(export): configurable CSV field quoting mode

This commit is contained in:
zipg
2026-09-03 17:51:50 +08:00
committed by GitHub
parent d421bd33fc
commit 203eab3dec
32 changed files with 442 additions and 56 deletions
@@ -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<CsvQuoteMode>(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(() => {
<div class="text-sm font-medium text-muted-foreground">
{{ t("settings.exportSection") }}
</div>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 space-y-0.5">
<Label for="csv-quote-mode">{{ t("settings.csvQuoteMode") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.csvQuoteModeDescription") }}
</p>
</div>
<Select v-model="editCsvQuoteMode">
<SelectTrigger id="csv-quote-mode" class="h-8 w-44 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ t("settings.csvQuoteModeAll") }}</SelectItem>
<SelectItem value="necessary">{{ t("settings.csvQuoteModeNecessary") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{{ t("settings.exportBatchSize") }}</Label>
<div class="flex items-center gap-3">
@@ -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,
@@ -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");
@@ -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,
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -6480,6 +6480,10 @@ export default withEnglishFallback({
exportBatchSize: "エクスポートバッチサイズ",
exportBatchSizeDescription: "テーブルデータエクスポート時のバッチあたりの取得行数(100〜100000)。",
exportSection: "エクスポート",
csvQuoteMode: "CSV フィールドの引用符",
csvQuoteModeDescription: "CSV フィールドを二重引用符で囲む方法を選択します。",
csvQuoteModeAll: "すべてのフィールド",
csvQuoteModeNecessary: "必要な場合のみ",
performanceSection: "パフォーマンス",
shortcutExecuteSql: "SQLを実行",
shortcutExecuteSqlInNewResultTab: "新しい結果タブでSQLを実行",
+4
View File
@@ -6631,6 +6631,10 @@ export default withEnglishFallback({
shortcutUppercaseSelection: "선택 영역을 대문자로 변환",
shortcutLowercaseSelection: "선택 영역을 소문자로 변환",
shortcutExPasteSqlInCondition: "ExPaste: IN 조건으로 붙여넣기",
csvQuoteMode: "CSV 필드 따옴표",
csvQuoteModeDescription: "CSV 내보내기 시 필드에 큰따옴표를 사용하는 방식을 선택합니다.",
csvQuoteModeAll: "모든 필드",
csvQuoteModeNecessary: "필수 필드만",
},
driverStore: {
jreDirRemoveFailed: "이전 JRE 디렉터리 제거 실패: {path} (원래 오류: {error})",
+4
View File
@@ -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",
+4
View File
@@ -6836,6 +6836,10 @@ export default withEnglishFallback({
queryExportKeysetOptimizationEnabled: "尝试 keyset 优化",
queryExportKeysetOptimizationEnabledDescription: "仅对可安全识别的单表查询生效,复杂查询会自动回退。",
exportSection: "导出",
csvQuoteMode: "CSV 字段引号",
csvQuoteModeDescription: "选择 CSV 导出时字段使用双引号的方式。",
csvQuoteModeAll: "全部字段",
csvQuoteModeNecessary: "仅必要字段",
performanceSection: "性能",
shortcutExecuteSql: "执行 SQL",
shortcutExecuteSqlInNewResultTab: "在新结果标签页中执行 SQL",
+4
View File
@@ -6190,6 +6190,10 @@ export default withEnglishFallback({
updateDownloadSourceOfficial: "官方來源(建議)",
updateDownloadSourceCnb: "CNB",
exportSection: "匯出",
csvQuoteMode: "CSV 欄位引號",
csvQuoteModeDescription: "選擇 CSV 匯出時欄位使用雙引號的方式。",
csvQuoteModeAll: "全部欄位",
csvQuoteModeNecessary: "僅必要欄位",
performanceSection: "效能",
exportBatchSize: "匯出批次大小",
dateTimeSection: "日期與時間",
+3 -2
View File
@@ -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<void> {
export async function exportQueryResultCsv(filePath: string, columns: string[], rows: readonly (readonly XlsxCellValue[])[], csvQuoteMode: CsvQuoteMode = "all"): Promise<void> {
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",
+6 -1
View File
@@ -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<T>(command: string, args?: Record<string, unknown>): Promise<T> {
@@ -4839,6 +4840,7 @@ export interface TableExportRequest {
tableName: string;
filePath: string;
format: "csv" | "xlsx" | "json" | "markdown" | "sql" | "txt";
csvQuoteMode?: CsvQuoteMode;
columns?: string[];
columnTypes?: Array<string | null | undefined>;
columnComments?: Array<string | null> | 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<void> {
export async function exportQueryResultCsv(filePath: string, columns: string[], rows: readonly (readonly XlsxCellValue[])[], csvQuoteMode: CsvQuoteMode = "all"): Promise<void> {
return invoke("export_query_result_csv", {
request: {
filePath,
columns,
rows,
csvQuoteMode,
},
});
}
@@ -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, '""')}"`;
}
+4 -4
View File
@@ -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}`;
}
@@ -88,6 +88,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"sidebarCopyTableNameIncludeSchema",
"redisKeyTemplates",
"exportBatchSize",
"csvQuoteMode",
"exportRowLimitEnabled",
"exportRowLimit",
"queryExportKeysetOptimizationEnabled",
+2
View File
@@ -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,
+6 -1
View File
@@ -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<EditorSettings>, 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)));
+133 -9
View File
@@ -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<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
#[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<Value>]) -> 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<Value>]) -> 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<Value>]) -> String {
format_csv_with_value_formatter(columns, rows)
}
pub fn format_csv_with_quote_mode(columns: &[String], rows: &[Vec<Value>], 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<Value>]) -> String {
format_csv(columns, rows)
}
fn write_csv_text_row(writer: &mut impl Write, values: impl IntoIterator<Item = String>) -> Result<(), String> {
pub fn format_query_result_csv_with_quote_mode(
columns: &[String],
rows: &[Vec<Value>],
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<Item = String>,
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<Item = Value>) -> Result<(), String> {
fn write_csv_value_row(
writer: &mut impl Write,
values: impl IntoIterator<Item = Value>,
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!("")]]);
+70 -19
View File
@@ -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<usize> {
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<W: Write>(
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<W: Write>(
format: &str,
rows: &[Vec<Value>],
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\"\"\"");
}
+62 -12
View File
@@ -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<Vec<String>>,
#[serde(default)]
pub column_types: Option<Vec<Option<String>>>,
@@ -100,11 +107,17 @@ fn format_csv_rows(rows: &[Vec<Value>]) -> String {
out
}
fn write_table_text_row<W: Write>(file: &mut W, csv: bool, row: &[Value], buffer: &mut String) -> Result<(), String> {
fn write_table_text_row<W: Write>(
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<W: Write>(
csv: bool,
rows: &[Vec<Value>],
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,
@@ -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,
+2
View File
@@ -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,
@@ -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,
@@ -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,
@@ -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,
+8
View File
@@ -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");
});
+14
View File
@@ -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"""',
);
});
+22
View File
@@ -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());
+5 -1
View File
@@ -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();
}
+6 -2
View File
@@ -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<String>,
pub rows: Vec<Vec<Value>>,
#[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