mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(export): configurable CSV field quoting mode
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6480,6 +6480,10 @@ export default withEnglishFallback({
|
||||
exportBatchSize: "エクスポートバッチサイズ",
|
||||
exportBatchSizeDescription: "テーブルデータエクスポート時のバッチあたりの取得行数(100〜100000)。",
|
||||
exportSection: "エクスポート",
|
||||
csvQuoteMode: "CSV フィールドの引用符",
|
||||
csvQuoteModeDescription: "CSV フィールドを二重引用符で囲む方法を選択します。",
|
||||
csvQuoteModeAll: "すべてのフィールド",
|
||||
csvQuoteModeNecessary: "必要な場合のみ",
|
||||
performanceSection: "パフォーマンス",
|
||||
shortcutExecuteSql: "SQLを実行",
|
||||
shortcutExecuteSqlInNewResultTab: "新しい結果タブでSQLを実行",
|
||||
|
||||
@@ -6631,6 +6631,10 @@ export default withEnglishFallback({
|
||||
shortcutUppercaseSelection: "선택 영역을 대문자로 변환",
|
||||
shortcutLowercaseSelection: "선택 영역을 소문자로 변환",
|
||||
shortcutExPasteSqlInCondition: "ExPaste: IN 조건으로 붙여넣기",
|
||||
csvQuoteMode: "CSV 필드 따옴표",
|
||||
csvQuoteModeDescription: "CSV 내보내기 시 필드에 큰따옴표를 사용하는 방식을 선택합니다.",
|
||||
csvQuoteModeAll: "모든 필드",
|
||||
csvQuoteModeNecessary: "필수 필드만",
|
||||
},
|
||||
driverStore: {
|
||||
jreDirRemoveFailed: "이전 JRE 디렉터리 제거 실패: {path} (원래 오류: {error})",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6836,6 +6836,10 @@ export default withEnglishFallback({
|
||||
queryExportKeysetOptimizationEnabled: "尝试 keyset 优化",
|
||||
queryExportKeysetOptimizationEnabledDescription: "仅对可安全识别的单表查询生效,复杂查询会自动回退。",
|
||||
exportSection: "导出",
|
||||
csvQuoteMode: "CSV 字段引号",
|
||||
csvQuoteModeDescription: "选择 CSV 导出时字段使用双引号的方式。",
|
||||
csvQuoteModeAll: "全部字段",
|
||||
csvQuoteModeNecessary: "仅必要字段",
|
||||
performanceSection: "性能",
|
||||
shortcutExecuteSql: "执行 SQL",
|
||||
shortcutExecuteSqlInNewResultTab: "在新结果标签页中执行 SQL",
|
||||
|
||||
@@ -6190,6 +6190,10 @@ export default withEnglishFallback({
|
||||
updateDownloadSourceOfficial: "官方來源(建議)",
|
||||
updateDownloadSourceCnb: "CNB",
|
||||
exportSection: "匯出",
|
||||
csvQuoteMode: "CSV 欄位引號",
|
||||
csvQuoteModeDescription: "選擇 CSV 匯出時欄位使用雙引號的方式。",
|
||||
csvQuoteModeAll: "全部欄位",
|
||||
csvQuoteModeNecessary: "僅必要欄位",
|
||||
performanceSection: "效能",
|
||||
exportBatchSize: "匯出批次大小",
|
||||
dateTimeSection: "日期與時間",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, '""')}"`;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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!("")]]);
|
||||
|
||||
@@ -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\"\"\"");
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user