fix(grid): copy cells containing double quotes verbatim

This commit is contained in:
弈秋 authored and GitHub committed 2026-09-23 14:43:06 +08:00
1 parent bd32919542
commit 6471e37e5f
6 files changed
+97 -19

No files matched your search

@@ -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 = [ const rows = [
[1, '{"msg":"success"}'], [1, '{"msg":"success"}'],
[2, "inside\ttab\nnext line"], [2, "inside\ttab\nnext line"],
@@ -737,7 +737,9 @@ describe("useDataGridExport prepared row statements", () => {
columns: ["id", "name"], columns: ["id", "name"],
rows, 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 }); 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); const state = createExportState(editableTable, ["id", "name"], matrix, undefined, undefined, rows);
@@ -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");
});
});
+7 -4
View File
@@ -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)" // 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 // export: fields are joined by a tab and NULL becomes empty. A field is wrapped
// wrapped in double quotes (with " doubled) when it contains a tab, newline, // in double quotes only when it contains a tab or a line break - the characters
// or quote - i.e. the minimum needed to round-trip the value. // 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 { export function formatTsv(columns: string[], rows: ExportCellValue[][]): string {
const esc = (value: ExportCellValue) => { const esc = (value: ExportCellValue) => {
const text = value === null ? "" : String(value); 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.replace(/"/g, '""')}"`;
} }
return text; return text;
@@ -20,6 +20,7 @@ pub(super) fn write_dsv(
context: &ExtractContext<'_>, context: &ExtractContext<'_>,
output: &mut dyn Write, output: &mut dyn Write,
options: &DataGridDsvOptions, options: &DataGridDsvOptions,
quote_char_triggers_quoting: bool,
) -> Result<(), DataGridExtractError> { ) -> Result<(), DataGridExtractError> {
validate_dsv_options(options, true)?; validate_dsv_options(options, true)?;
let mut first_row = 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())), context.selected_columns.iter().map(|column| Cow::Borrowed(column.display_name.as_str())),
options, options,
options.include_row_header.then(|| "#".to_string()), options.include_row_header.then(|| "#".to_string()),
quote_char_triggers_quoting,
)?; )?;
first_row = false; first_row = false;
} }
@@ -48,6 +50,7 @@ pub(super) fn write_dsv(
}), }),
options, options,
options.include_row_header.then(|| (row_index + 1).to_string()), options.include_row_header.then(|| (row_index + 1).to_string()),
quote_char_triggers_quoting,
)?; )?;
first_row = false; first_row = false;
} }
@@ -61,17 +64,18 @@ fn write_dsv_row<'a>(
values: impl Iterator<Item = Cow<'a, str>>, values: impl Iterator<Item = Cow<'a, str>>,
options: &DataGridDsvOptions, options: &DataGridDsvOptions,
row_header: Option<String>, row_header: Option<String>,
quote_char_triggers_quoting: bool,
) -> Result<(), DataGridExtractError> { ) -> Result<(), DataGridExtractError> {
let mut first = true; let mut first = true;
if let Some(header) = row_header { if let Some(header) = row_header {
write_dsv_field(output, &header, options)?; write_dsv_field(output, &header, options, quote_char_triggers_quoting)?;
first = false; first = false;
} }
for value in values { for value in values {
if !first { if !first {
write_bytes(output, options.column_separator.as_bytes())?; 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; first = false;
} }
Ok(()) Ok(())
@@ -83,26 +87,35 @@ fn write_dsv_data_row<'a>(
cells: impl Iterator<Item = DsvCell<'a>>, cells: impl Iterator<Item = DsvCell<'a>>,
options: &DataGridDsvOptions, options: &DataGridDsvOptions,
row_header: Option<String>, row_header: Option<String>,
quote_char_triggers_quoting: bool,
) -> Result<(), DataGridExtractError> { ) -> Result<(), DataGridExtractError> {
let mut first = true; let mut first = true;
if let Some(header) = row_header { if let Some(header) = row_header {
write_dsv_field(output, &header, options)?; write_dsv_field(output, &header, options, quote_char_triggers_quoting)?;
first = false; first = false;
} }
for cell in cells { for cell in cells {
if !first { if !first {
write_bytes(output, options.column_separator.as_bytes())?; 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; first = false;
} }
Ok(()) 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.column_separator)
|| value.contains(&options.row_separator) || value.contains(&options.row_separator)
|| value.contains(options.quote) || (quote_char_triggers_quoting && value.contains(options.quote))
|| value.contains('\r') || value.contains('\r')
|| value.contains('\n') || value.contains('\n')
} }
@@ -134,11 +147,12 @@ fn write_dsv_field(
output: &mut dyn Write, output: &mut dyn Write,
value: &str, value: &str,
options: &DataGridDsvOptions, options: &DataGridDsvOptions,
quote_char_triggers_quoting: bool,
) -> Result<(), DataGridExtractError> { ) -> Result<(), DataGridExtractError> {
let should_quote = match options.quote_policy { let should_quote = match options.quote_policy {
DataGridQuotePolicy::Always => true, DataGridQuotePolicy::Always => true,
DataGridQuotePolicy::Never => false, DataGridQuotePolicy::Never => false,
DataGridQuotePolicy::Minimal => dsv_needs_quote(value, options), DataGridQuotePolicy::Minimal => dsv_needs_quote(value, options, quote_char_triggers_quoting),
}; };
if !should_quote { if !should_quote {
return write_bytes(output, value.as_bytes()); return write_bytes(output, value.as_bytes());
@@ -155,6 +169,7 @@ fn write_dsv_data_field(
output: &mut dyn Write, output: &mut dyn Write,
cell: &DsvCell<'_>, cell: &DsvCell<'_>,
options: &DataGridDsvOptions, options: &DataGridDsvOptions,
quote_char_triggers_quoting: bool,
) -> Result<(), DataGridExtractError> { ) -> Result<(), DataGridExtractError> {
match cell { match cell {
DsvCell::Null => write_bytes(output, options.null_text.as_bytes()), DsvCell::Null => write_bytes(output, options.null_text.as_bytes()),
@@ -163,7 +178,8 @@ fn write_dsv_data_field(
DataGridQuotePolicy::Always => true, DataGridQuotePolicy::Always => true,
DataGridQuotePolicy::Never => false, DataGridQuotePolicy::Never => false,
DataGridQuotePolicy::Minimal => { 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 { 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())?; write_bytes(output, options.column_separator.as_bytes())?;
} }
let cell = if value.is_null() { DsvCell::Null } else { DsvCell::Text(value_text(value)) }; 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; first = false;
} }
Ok(()) Ok(())
@@ -269,7 +269,9 @@ fn write_extraction(
include_column_header: extractor == DataGridExtractorId::TsvWithHeaders, include_column_header: extractor == DataGridExtractorId::TsvWithHeaders,
..context.request.options.dsv.clone() ..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")) Ok(text_metadata("text/tab-separated-values", "tsv"))
} }
DataGridExtractorId::Csv | DataGridExtractorId::CsvWithHeaders => { DataGridExtractorId::Csv | DataGridExtractorId::CsvWithHeaders => {
@@ -278,17 +280,17 @@ fn write_extraction(
include_column_header: extractor == DataGridExtractorId::CsvWithHeaders, include_column_header: extractor == DataGridExtractorId::CsvWithHeaders,
..context.request.options.dsv.clone() ..context.request.options.dsv.clone()
}; };
write_dsv(context, output, &options)?; write_dsv(context, output, &options, true)?;
Ok(text_metadata("text/csv", "csv")) Ok(text_metadata("text/csv", "csv"))
} }
DataGridExtractorId::PipeSeparated => { DataGridExtractorId::PipeSeparated => {
let options = let options =
DataGridDsvOptions { column_separator: "|".to_string(), ..context.request.options.dsv.clone() }; 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")) Ok(text_metadata("text/plain", "txt"))
} }
DataGridExtractorId::Dsv => { 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")) Ok(text_metadata("text/plain", "txt"))
} }
DataGridExtractorId::OneRow => { DataGridExtractorId::OneRow => {
@@ -188,6 +188,37 @@ fn extracts_raw_values_without_escaping_quotes() {
assert_eq!(result.mime_type, "text/plain"); 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] #[test]
fn raw_exports_null_as_an_empty_value() { fn raw_exports_null_as_an_empty_value() {
let mut request = request(DataGridExtractorId::Raw); let mut request = request(DataGridExtractorId::Raw);