Skip to content
Merged
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
23 changes: 13 additions & 10 deletions packages/csv-stringify/lib/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ const bom_utf8 = Buffer.from([239, 187, 191]);
// separator (eg value "a:" + delimiter "::" => "a:::", matched at offset 1).
// Such fields must be quoted to round-trip, like RFC 4180 fields containing the
// delimiter, generalized to multi-character delimiters and record delimiters.
const emits_separator = function (value, separator) {
return (
separator.length !== 0 &&
(value.indexOf(separator) !== -1 ||
(separator.length > 1 &&
(value + separator).indexOf(separator) < value.length))
const emits_separator = function (value, separators) {
return separators.some(
(separator) =>
separator.length !== 0 &&
(value.indexOf(separator) !== -1 ||
(separator.length > 1 &&
(value + separator).indexOf(separator) < value.length)),
);
};
// True when `value` matches one of the `quoted_match` patterns. The regexps
Expand Down Expand Up @@ -191,6 +192,7 @@ const stringifier = function (options, state, info) {
quoted_string,
quoted_match,
record_delimiter,
quote_record_delimiter,
escape_formulas,
} = options;
if ("" === value && "" === field) {
Expand All @@ -211,13 +213,14 @@ const stringifier = function (options, state, info) {
),
];
}
const containsdelimiter = emits_separator(value, delimiter);
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,
// Testing `\n` and `\r` covers the three sequences `parse` discovers
const containsRecordDelimiter = emits_separator(value, [
record_delimiter,
);
...(quote_record_delimiter === false ? [] : ["\n", "\r"]),
]);
const quotedString = quoted_string && typeof field === "string";
const quotedMatch = matches_quoted_match(value, quoted_match);
// See https://github.com/adaltas/node-csv/pull/387
Expand Down
14 changes: 14 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,20 @@ 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;
} 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 @@ -112,6 +112,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 @@ -194,6 +201,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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { stringify } from "csv-stringify/sync";
import assert from "node:assert";

// A carriage return inside a field is quoted by default
const records = stringify([["a\rb"], ["c\nd"], ["e::f"]], { eof: false });

assert.equal(records, '"a\rb"\n"c\nd"\ne::f');
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { stringify } from "csv-stringify/sync";
import assert from "node:assert";

// With quote_record_delimiter disabled, a carriage return is not quoted
const cr = stringify([["a\rb"]], {
quote_record_delimiter: false,
eof: false,
});
assert.equal(cr, "a\rb");

// A line feed is still quoted because it matches the default record_delimiter
const lf = stringify([["a\nb"]], {
quote_record_delimiter: false,
eof: false,
});
assert.equal(lf, '"a\nb"');
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { stringify } from "csv-stringify/sync";
import assert from "node:assert";

// When record_delimiter is set, quote_record_delimiter defaults to false.
// A carriage return is not quoted because it does not match the custom delimiter.
const cr = stringify([["a\rb"]], {
record_delimiter: "::",
eof: false,
});
assert.equal(cr, "a\rb");

// The custom delimiter itself is always quoted.
const delim = stringify([["a::b"]], {
record_delimiter: "::",
eof: false,
});
assert.equal(delim, '"a::b"');
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
1 change: 1 addition & 0 deletions packages/csv-stringify/test/option.quote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ describe("Option `quote`", function () {
record_delimiter: "__",
},
(err, data) => {
if (err) return next(err);
data.should.eql(dedent`
123
456,789__,1974
Expand Down
105 changes: 105 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,105 @@
import "should";
import { stringify, normalize_options } from "../lib/sync.js";

describe("Option `quote_record_delimiter`", function () {
it("default to `true`", function () {
let options;
[, options] = normalize_options({});
options.quote_record_delimiter.should.eql(true);
[, options] = normalize_options({ quote_record_delimiter: false });
options.quote_record_delimiter.should.eql(false);
});

it("quotes a field containing a carriage return", function () {
stringify([["a\rb"]], { eof: false }).should.eql('"a\rb"');
stringify([["a\rb"]], {
eof: false,
quote_record_delimiter: true,
}).should.eql('"a\rb"');
stringify([["a\rb"]], {
eof: false,
quote_record_delimiter: false,
}).should.eql("a\rb");
});

it("quotes a field containing a line feed", function () {
// quote_record_delimiter is `false`
// but the input contains the default record_delimiter `\n`
// which cause the `emits_separator` function to return true
stringify([["a\nb"]], { eof: false }).should.eql('"a\nb"');
stringify([["a\nb"]], {
eof: false,
quote_record_delimiter: false,
}).should.eql('"a\nb"');
stringify([["a\nb"]], {
eof: false,
quote_record_delimiter: true,
}).should.eql('"a\nb"');
});

it("quotes a field containing a carriage return and line feed", function () {
// quote_record_delimiter is `false`
// but the input **partially** contains the default record_delimiter `\n`
// which cause the `emits_separator` function to return true
stringify([["a\r\nb"]], { eof: false }).should.eql('"a\r\nb"');
stringify([["a\r\nb"]], {
eof: false,
quote_record_delimiter: false,
}).should.eql('"a\r\nb"');
stringify([["a\r\nb"]], {
eof: false,
quote_record_delimiter: true,
}).should.eql('"a\r\nb"');
});

describe("with `record_delimiter`", function () {
it("defaults to false when `record_delimiter` is provided", function () {
let options;
[, options] = normalize_options({ record_delimiter: "::" });
options.quote_record_delimiter.should.eql(false);
[, options] = normalize_options({
quote_record_delimiter: true,
record_delimiter: "::",
});
options.quote_record_delimiter.should.eql(true);
});

it("quotes the configured `record_delimiter` when disabled", function () {
stringify([["a::b"]], {
record_delimiter: "::",
quote_record_delimiter: false,
eof: false,
}).should.eql('"a::b"');
});
});
describe("with `cast`", function () {
it("applies to a `record_delimiter` returned by cast", function () {
// The emitted delimiter stays the global one, so `\r` is still quoted
stringify([["a\rb"], ["c"]], {
cast: {
string: (value) => ({
value,
quote_record_delimiter: true,
record_delimiter: "::",
}),
},
eof: false,
}).should.eql('"a\rb"\nc');
});

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

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