Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions packages/csv-stringify/lib/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ const stringifier = function (options, state, info) {
quoted_string,
quoted_match,
record_delimiter,
quote_record_delimiter,
escape_formulas,
} = options;
if ("" === value && "" === field) {
Expand Down Expand Up @@ -208,10 +209,11 @@ const stringifier = function (options, state, info) {
const containsdelimiter = emits_separator(value, delimiter);
const containsQuote = quote !== "" && value.indexOf(quote) >= 0;
const containsEscape = value.indexOf(escape) >= 0 && escape !== quote;
const containsRecordDelimiter = emits_separator(
value,
record_delimiter,
);
// Testing `\n` and `\r` covers the three sequences `parse` discovers
const containsRecordDelimiter =
emits_separator(value, record_delimiter) ||
(quote_record_delimiter === true &&
(emits_separator(value, "\n") || emits_separator(value, "\r")));
const quotedString = quoted_string && typeof field === "string";
let quotedMatch =
quoted_match &&
Expand Down
16 changes: 16 additions & 0 deletions packages/csv-stringify/lib/api/normalize_options.js
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,22 @@ const normalize_options = function (opts) {
) {
return [Error(`Invalid Option: "on_record" must be a function.`)];
}
// Normalize option `quote_record_delimiter`
if (
options.quote_record_delimiter === undefined ||
options.quote_record_delimiter === null
) {
options.quote_record_delimiter =
options.record_delimiter === undefined ||
options.record_delimiter === null;
} else if (typeof options.quote_record_delimiter !== "boolean") {
return [
new CsvError("CSV_OPTION_QUOTE_RECORD_DELIMITER_INVALID_TYPE", [
"option `quote_record_delimiter` must be a boolean,",
`got ${JSON.stringify(options.quote_record_delimiter)}`,
]),
];
}
// Normalize option `record_delimiter`
if (
options.record_delimiter === undefined ||
Expand Down
14 changes: 14 additions & 0 deletions packages/csv-stringify/lib/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ export interface OptionsNormalized extends stream.TransformOptions {
* defaults to '\n'.
*/
record_delimiter: RecordDelimiter;
/**
* Boolean, quote the fields containing one of the record delimiters discovered by `parse`, `\r\n`, `\n` and `\r`.
* Defaults to true unless `record_delimiter` is provided.
* It preserves round trip calls between `stringify` and `parse` with default options: `stringify` only writes `\n` while `parse` treats the three sequences as record delimiters,
* so an unquoted field holding a `\r` is otherwise read back as multiple records.
*/
quote_record_delimiter: boolean;
/**
* Boolean, default to false, if true, fields that begin with `=`, `+`, `-`, `@`, `\t`, or `\r` will be prepended with a `'` to protect against csv injection attacks
*/
Expand Down Expand Up @@ -200,6 +207,13 @@ export interface Options extends stream.TransformOptions {
* defaults to '\n'.
*/
record_delimiter?: RecordDelimiter;
/**
* Boolean, quote the fields containing one of the record delimiters discovered by `parse`, `\r\n`, `\n` and `\r`.
* Defaults to true unless `record_delimiter` is provided.
* It preserves round trip calls between `stringify` and `parse` with default options: `stringify` only writes `\n` while `parse` treats the three sequences as record delimiters,
* so an unquoted field holding a `\r` is otherwise read back as multiple records.
*/
quote_record_delimiter?: boolean;
/**
* Boolean, default to false, if true, fields that begin with `=`, `+`, `-`, `@`, `\t`, or `\r` will be prepended with a `'` to protect against csv injection attacks
*/
Expand Down
7 changes: 7 additions & 0 deletions packages/csv-stringify/test/api.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe("API Types", function () {
"header_as_comment",
"on_record",
"quote",
"quote_record_delimiter",
"quoted",
"quoted_empty",
"quoted_match",
Expand Down Expand Up @@ -159,6 +160,12 @@ describe("API Types", function () {
options.record_delimiter = "|";
options.record_delimiter = Buffer.from("|");
});

it("quote_record_delimiter", function () {
const options: Options = {};
options.quote_record_delimiter = true;
options.quote_record_delimiter = false;
});
});

describe("CastingContext", function () {
Expand Down
3 changes: 2 additions & 1 deletion packages/csv-stringify/test/option.escape_formulas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ describe("Option `escape_formulas`", function () {
"'-c,3",
"'@d,4",
"'\te,5",
"'\rf,6",
// The carriage return forces quoting so the field round-trips.
'"\'\rf",6',
"g,7",
"'\uFF1Dh,8",
"'\uFF0Bi,9",
Expand Down
122 changes: 122 additions & 0 deletions packages/csv-stringify/test/option.quote_record_delimiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { stringify } from "../lib/index.js";

describe("Option `quote_record_delimiter`", function () {
it("quotes a field containing a carriage return", function (next) {
stringify([["a\rb"]], { eof: false }, (err, data) => {
if (err) return next(err);
data.should.eql('"a\rb"');
next();
});
});

it("quotes a field containing a line feed", function (next) {
stringify([["a\nb"]], { eof: false }, (err, data) => {
if (err) return next(err);
data.should.eql('"a\nb"');
next();
});
});

it("quotes a field containing a carriage return and line feed", function (next) {
stringify([["a\r\nb"]], { eof: false }, (err, data) => {
if (err) return next(err);
data.should.eql('"a\r\nb"');
next();
});
});

it("defaults to false when `record_delimiter` is provided", function (next) {
stringify(
[["a\rb"]],
{ record_delimiter: "::", eof: false },
(err, data) => {
if (err) return next(err);
data.should.eql("a\rb");
next();
},
);
});

it("disabled with a default `record_delimiter`", function (next) {
stringify(
[["a\rb"]],
{ quote_record_delimiter: false, eof: false },
(err, data) => {
if (err) return next(err);
data.should.eql("a\rb");
next();
},
);
});

it("enabled with a custom `record_delimiter`", function (next) {
stringify(
[["a\rb"]],
{ record_delimiter: "::", quote_record_delimiter: true, eof: false },
(err, data) => {
if (err) return next(err);
data.should.eql('"a\rb"');
next();
},
);
});

it("quotes the configured `record_delimiter` when disabled", function (next) {
stringify(
[["a::b"]],
{ record_delimiter: "::", quote_record_delimiter: false, eof: false },
(err, data) => {
if (err) return next(err);
data.should.eql('"a::b"');
next();
},
);
});

it("applies to a `record_delimiter` returned by cast", function (next) {
// The emitted delimiter stays the global one, so `\r` is still quoted
stringify(
[["a\rb"], ["c"]],
{
cast: { string: (value) => ({ value, record_delimiter: "::" }) },
eof: false,
},
(err, data) => {
if (err) return next(err);
data.should.eql('"a\rb"\nc');
next();
},
);
});

it("quotes a `record_delimiter` returned by cast", function (next) {
stringify(
[["a::b"]],
{
cast: { string: (value) => ({ value, record_delimiter: "::" }) },
eof: false,
},
(err, data) => {
if (err) return next(err);
data.should.eql('"a::b"');
next();
},
);
});

it("defaults to false in cast when `record_delimiter` is provided", function (next) {
stringify(
[["a\rb"]],
{
record_delimiter: ";;",
cast: { string: (value) => ({ value, record_delimiter: "::" }) },
eof: false,
},
(err, data) => {
if (err) return next(err);
data.should.eql("a\rb");
next();
},
);
});
});