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

@@ -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<Item = Cow<'a, str>>,
options: &DataGridDsvOptions,
row_header: Option<String>,
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<Item = DsvCell<'a>>,
options: &DataGridDsvOptions,
row_header: Option<String>,
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(())
@@ -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 => {
@@ -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);