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