diff --git a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts index b63f3a8d75..a4590c1a7c 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts @@ -726,7 +726,7 @@ describe("useDataGridExport prepared row statements", () => { ]); }); - it("uses escaped TSV for a multi-cell smart copy without relying on clipboard metadata", async () => { + it("uses TSV (quotes only for separator/newline) for a multi-cell smart copy without relying on clipboard metadata", async () => { const rows = [ [1, '{"msg":"success"}'], [2, "inside\ttab\nnext line"], @@ -737,7 +737,9 @@ describe("useDataGridExport prepared row statements", () => { columns: ["id", "name"], rows, }; - const text = '1\t"{""msg"":""success""}"\n2\t"inside\ttab\nnext line"'; + // A value containing only double quotes is copied verbatim (#10087); a value + // containing a tab/newline is still quoted so the TSV shape survives. + const text = '1\t{"msg":"success"}\n2\t"inside\ttab\nnext line"'; vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text, mimeType: "text/tab-separated-values", fileExtension: "tsv", rowCount: 2, columnCount: 2 }); const state = createExportState(editableTable, ["id", "name"], matrix, undefined, undefined, rows); diff --git a/apps/desktop/src/lib/export/__tests__/exportFormats.spec.ts b/apps/desktop/src/lib/export/__tests__/exportFormats.spec.ts new file mode 100644 index 0000000000..d6f48dc24c --- /dev/null +++ b/apps/desktop/src/lib/export/__tests__/exportFormats.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { formatTsv } from "@/lib/export/exportFormats"; + +describe("formatTsv", () => { + it("copies a value that only contains double quotes verbatim (#10087)", () => { + const output = formatTsv(["id", "mag"], [[1, '"9932b4d2ad1c4ff88b5bfe1690ee1bb8"']]); + + expect(output).toBe('id\tmag\n1\t"9932b4d2ad1c4ff88b5bfe1690ee1bb8"'); + }); + + it("still quotes values containing a tab or newline so the TSV shape survives", () => { + const output = formatTsv(["v"], [["a\tb"], ["c\nd"]]); + + expect(output).toBe('v\n"a\tb"\n"c\nd"'); + }); + + it("renders NULL as an empty field without adding quotes", () => { + const output = formatTsv(["id", "name"], [[1, null]]); + + expect(output).toBe("id\tname\n1\t"); + }); +}); diff --git a/apps/desktop/src/lib/export/exportFormats.ts b/apps/desktop/src/lib/export/exportFormats.ts index 800ff986f7..57dbbbb659 100644 --- a/apps/desktop/src/lib/export/exportFormats.ts +++ b/apps/desktop/src/lib/export/exportFormats.ts @@ -12,13 +12,16 @@ export function formatCsv(columns: string[], rows: ExportCellValue[][], quoteMod } // Tab-separated values with a header row, mirroring Navicat's "Text File (*.txt)" -// export: fields are joined by a tab, NULL becomes empty, and a field is only -// wrapped in double quotes (with " doubled) when it contains a tab, newline, -// or quote - i.e. the minimum needed to round-trip the value. +// export: fields are joined by a tab and NULL becomes empty. A field is wrapped +// in double quotes only when it contains a tab or a line break - the characters +// that would otherwise corrupt the TSV row/column shape. A field that merely +// contains a double quote is emitted verbatim: TSV is parsed by splitting on the +// tab alone (there is no quote state machine on paste-back), so quoting such a +// value would corrupt it (e.g. a value of `"abc"` must not become `"""abc"""`). export function formatTsv(columns: string[], rows: ExportCellValue[][]): string { const esc = (value: ExportCellValue) => { const text = value === null ? "" : String(value); - if (text.includes("\t") || text.includes("\n") || text.includes("\r") || text.includes('"')) { + if (text.includes("\t") || text.includes("\n") || text.includes("\r")) { return `"${text.replace(/"/g, '""')}"`; } return text; diff --git a/crates/dbx-sql/src/data_grid_extractors/delimited.rs b/crates/dbx-sql/src/data_grid_extractors/delimited.rs index 1cbc3f7845..d9e9777a39 100644 --- a/crates/dbx-sql/src/data_grid_extractors/delimited.rs +++ b/crates/dbx-sql/src/data_grid_extractors/delimited.rs @@ -20,6 +20,7 @@ pub(super) fn write_dsv( context: &ExtractContext<'_>, output: &mut dyn Write, options: &DataGridDsvOptions, + quote_char_triggers_quoting: bool, ) -> Result<(), DataGridExtractError> { validate_dsv_options(options, true)?; let mut first_row = true; @@ -29,6 +30,7 @@ pub(super) fn write_dsv( context.selected_columns.iter().map(|column| Cow::Borrowed(column.display_name.as_str())), options, options.include_row_header.then(|| "#".to_string()), + quote_char_triggers_quoting, )?; first_row = false; } @@ -48,6 +50,7 @@ pub(super) fn write_dsv( }), options, options.include_row_header.then(|| (row_index + 1).to_string()), + quote_char_triggers_quoting, )?; first_row = false; } @@ -61,17 +64,18 @@ fn write_dsv_row<'a>( values: impl Iterator>, options: &DataGridDsvOptions, row_header: Option, + quote_char_triggers_quoting: bool, ) -> Result<(), DataGridExtractError> { let mut first = true; if let Some(header) = row_header { - write_dsv_field(output, &header, options)?; + write_dsv_field(output, &header, options, quote_char_triggers_quoting)?; first = false; } for value in values { if !first { write_bytes(output, options.column_separator.as_bytes())?; } - write_dsv_field(output, &value, options)?; + write_dsv_field(output, &value, options, quote_char_triggers_quoting)?; first = false; } Ok(()) @@ -83,26 +87,35 @@ fn write_dsv_data_row<'a>( cells: impl Iterator>, options: &DataGridDsvOptions, row_header: Option, + quote_char_triggers_quoting: bool, ) -> Result<(), DataGridExtractError> { let mut first = true; if let Some(header) = row_header { - write_dsv_field(output, &header, options)?; + write_dsv_field(output, &header, options, quote_char_triggers_quoting)?; first = false; } for cell in cells { if !first { write_bytes(output, options.column_separator.as_bytes())?; } - write_dsv_data_field(output, &cell, options)?; + write_dsv_data_field(output, &cell, options, quote_char_triggers_quoting)?; first = false; } Ok(()) } -fn dsv_needs_quote(value: &str, options: &DataGridDsvOptions) -> bool { +/// Whether a value must be wrapped in quotes. A value is always quoted when it +/// contains a separator or a line break, because those would otherwise corrupt +/// the record shape. Whether a bare quote character forces quoting is +/// format-dependent: RFC4180 formats (CSV/DSV/one-row) must escape an embedded +/// quote, but plain delimited formats such as TSV are parsed by splitting on the +/// separator alone (there is no quote state machine on paste-back), so a value +/// that merely contains `"` must be emitted verbatim rather than wrapped and +/// doubled. +fn dsv_needs_quote(value: &str, options: &DataGridDsvOptions, quote_char_triggers_quoting: bool) -> bool { value.contains(&options.column_separator) || value.contains(&options.row_separator) - || value.contains(options.quote) + || (quote_char_triggers_quoting && value.contains(options.quote)) || value.contains('\r') || value.contains('\n') } @@ -134,11 +147,12 @@ fn write_dsv_field( output: &mut dyn Write, value: &str, options: &DataGridDsvOptions, + quote_char_triggers_quoting: bool, ) -> Result<(), DataGridExtractError> { let should_quote = match options.quote_policy { DataGridQuotePolicy::Always => true, DataGridQuotePolicy::Never => false, - DataGridQuotePolicy::Minimal => dsv_needs_quote(value, options), + DataGridQuotePolicy::Minimal => dsv_needs_quote(value, options, quote_char_triggers_quoting), }; if !should_quote { return write_bytes(output, value.as_bytes()); @@ -155,6 +169,7 @@ fn write_dsv_data_field( output: &mut dyn Write, cell: &DsvCell<'_>, options: &DataGridDsvOptions, + quote_char_triggers_quoting: bool, ) -> Result<(), DataGridExtractError> { match cell { DsvCell::Null => write_bytes(output, options.null_text.as_bytes()), @@ -163,7 +178,8 @@ fn write_dsv_data_field( DataGridQuotePolicy::Always => true, DataGridQuotePolicy::Never => false, DataGridQuotePolicy::Minimal => { - dsv_needs_quote(value, options) || value.as_ref() == options.null_text.as_str() + dsv_needs_quote(value, options, quote_char_triggers_quoting) + || value.as_ref() == options.null_text.as_str() } }; if !should_quote { @@ -188,7 +204,9 @@ pub(super) fn write_one_row(context: &ExtractContext<'_>, output: &mut dyn Write write_bytes(output, options.column_separator.as_bytes())?; } let cell = if value.is_null() { DsvCell::Null } else { DsvCell::Text(value_text(value)) }; - write_dsv_data_field(output, &cell, &options)?; + // A one-row extraction is an RFC4180 CSV record, so embedded quotes must + // be escaped. + write_dsv_data_field(output, &cell, &options, true)?; first = false; } Ok(()) diff --git a/crates/dbx-sql/src/data_grid_extractors/mod.rs b/crates/dbx-sql/src/data_grid_extractors/mod.rs index 3ea218c469..9b79dba16b 100644 --- a/crates/dbx-sql/src/data_grid_extractors/mod.rs +++ b/crates/dbx-sql/src/data_grid_extractors/mod.rs @@ -269,7 +269,9 @@ fn write_extraction( include_column_header: extractor == DataGridExtractorId::TsvWithHeaders, ..context.request.options.dsv.clone() }; - write_dsv(context, output, &options)?; + // TSV is split on the tab separator with no quote state machine, so a + // value that merely contains a double quote must not be escaped. + write_dsv(context, output, &options, false)?; Ok(text_metadata("text/tab-separated-values", "tsv")) } DataGridExtractorId::Csv | DataGridExtractorId::CsvWithHeaders => { @@ -278,17 +280,17 @@ fn write_extraction( include_column_header: extractor == DataGridExtractorId::CsvWithHeaders, ..context.request.options.dsv.clone() }; - write_dsv(context, output, &options)?; + write_dsv(context, output, &options, true)?; Ok(text_metadata("text/csv", "csv")) } DataGridExtractorId::PipeSeparated => { let options = DataGridDsvOptions { column_separator: "|".to_string(), ..context.request.options.dsv.clone() }; - write_dsv(context, output, &options)?; + write_dsv(context, output, &options, false)?; Ok(text_metadata("text/plain", "txt")) } DataGridExtractorId::Dsv => { - write_dsv(context, output, &context.request.options.dsv)?; + write_dsv(context, output, &context.request.options.dsv, true)?; Ok(text_metadata("text/plain", "txt")) } DataGridExtractorId::OneRow => { diff --git a/crates/dbx-sql/src/data_grid_extractors/tests.rs b/crates/dbx-sql/src/data_grid_extractors/tests.rs index 45a8973d9b..702d585c52 100644 --- a/crates/dbx-sql/src/data_grid_extractors/tests.rs +++ b/crates/dbx-sql/src/data_grid_extractors/tests.rs @@ -188,6 +188,37 @@ fn extracts_raw_values_without_escaping_quotes() { assert_eq!(result.mime_type, "text/plain"); } +#[test] +fn tsv_and_pipe_emit_embedded_quotes_verbatim_while_csv_escapes_them() { + // Reproduces #10087: a value that is itself wrapped in double quotes (e.g. + // MySQL `SELECT CONCAT('"', id, '"')`) must copy out unchanged from the grid's + // default multi-cell (TSV) copy, instead of being wrapped and doubled. + let quoted = r#""9932b4d2ad1c4ff88b5bfe1690ee1bb8""#; + + for (extractor, separator) in [(DataGridExtractorId::Tsv, "\t"), (DataGridExtractorId::PipeSeparated, "|")] { + let mut request = request(extractor); + request.rows = vec![vec![json!(1), json!(quoted)]]; + let result = extract_data_grid_selection(request).expect("plain delimited extraction"); + assert_eq!(result.text, format!("1{separator}{quoted}"), "extractor {extractor:?} must not escape quotes"); + } + + // CSV is an RFC4180 format, so the same value must still be quoted and doubled. + let mut csv = request(DataGridExtractorId::Csv); + csv.rows = vec![vec![json!(1), json!(quoted)]]; + let csv_result = extract_data_grid_selection(csv).expect("CSV extraction"); + assert_eq!(csv_result.text, format!("1,\"{}\"", quoted.replace('"', "\"\""))); +} + +#[test] +fn tsv_still_quotes_values_that_would_break_the_row_shape() { + // Relaxing the quote-character trigger must not stop quoting on the actual + // separator / line breaks, which would otherwise corrupt the TSV structure. + let mut request = request(DataGridExtractorId::Tsv); + request.rows = vec![vec![json!(1), json!("a\tb\nc")]]; + let result = extract_data_grid_selection(request).expect("TSV extraction"); + assert_eq!(result.text, "1\t\"a\tb\nc\""); +} + #[test] fn raw_exports_null_as_an_empty_value() { let mut request = request(DataGridExtractorId::Raw);