fix(grid): copy cells containing double quotes verbatim
This commit is contained in:
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);
|
||||
|
||||
Reference in new issue
Block a user