feat(export): configurable CSV field quoting mode

This commit is contained in:
zipg authored and GitHub committed 2026-09-03 17:51:50 +08:00
1 parent d421bd33fc
commit 203eab3dec
32 files changed
+442 -56

No files matched your search

+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();
}