Skip to content

Commit 2e3ff3b

Browse files
committed
fix(cpp): preserve quoted CSV line endings in tsfile-cli (#996)
* fix(cpp): preserve quoted CSV line endings in tsfile-cli * test(cpp): write embedded-newline CSV fixture in binary mode
1 parent 356ea0b commit 2e3ff3b

6 files changed

Lines changed: 166 additions & 21 deletions

File tree

‎cpp/test/tools/command_e2e_test.cc‎

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -849,7 +849,8 @@ TEST(CliE2E, WriteImportsQuotedFieldWithEmbeddedNewline) {
849849
std::string csv_path =
850850
tsfile_cli_test::unique_temp_path("tsfile_cli_nl_in", ".csv");
851851
{
852-
std::ofstream o(csv_path.c_str());
852+
// Keep the fixture's LF bytes unchanged on Windows as well.
853+
std::ofstream o(csv_path.c_str(), std::ios::binary);
853854
// The note field on the first row spans two physical lines inside
854855
// quotes; it must import as a single row, not be split into two.
855856
o << "time,id1,note\n0,dev,\"line one\nline two\"\n1,dev,plain\n";
@@ -1985,3 +1986,90 @@ TEST(CliE2E, WriteCatCsvWritePreservesLeadingBackslashes) {
19851986
std::remove(exported.c_str());
19861987
std::remove(second.c_str());
19871988
}
1989+
1990+
TEST(CliE2E, WriteCatAndExportCsvPreserveQuotedLineEndings) {
1991+
const std::string input =
1992+
tsfile_cli_test::unique_temp_path("tsfile_cli_crlf_input", ".csv");
1993+
const std::string first =
1994+
tsfile_cli_test::unique_temp_path("tsfile_cli_crlf_first", ".tsfile");
1995+
const std::string expected_csv =
1996+
"time,site,message,note\n"
1997+
"0,\"s\r\n1\",\"first\r\nsecond\",\"\"\"quoted\"\"\r\n\r\nlast\"\n"
1998+
"1,\"s\r\n1\",\"lf\nonly\",\"mixed\r\nand\nmore\"\n"
1999+
"2,\"s\r\n1\",\"carriage\rreturn\",\"\r\n\"\n"
2000+
"3,\"s\r\n1\",\"ends\r\",\"line\r\nend\"\n";
2001+
{
2002+
std::ofstream csv(input.c_str(), std::ios::binary);
2003+
ASSERT_TRUE(csv.good());
2004+
// CRLF record boundaries, embedded CRLF/LF/CR, and no final newline.
2005+
csv << "time,site,message,note\r\n"
2006+
"0,\"s\r\n1\",\"first\r\nsecond\","
2007+
"\"\"\"quoted\"\"\r\n\r\nlast\"\r\n"
2008+
"1,\"s\r\n1\",\"lf\nonly\",\"mixed\r\nand\nmore\"\r\n"
2009+
"2,\"s\r\n1\",\"carriage\rreturn\",\"\r\n\"\r\n"
2010+
"3,\"s\r\n1\",\"ends\r\",\"line\r\nend\"";
2011+
}
2012+
const auto write = [&](const std::string& source,
2013+
const std::string& target) {
2014+
std::ostringstream out, err;
2015+
const int code = tsfile_cli::run_cli(
2016+
{"write", "--table", "t", "--tag", "site", "STRING", "--field",
2017+
"message", "STRING", "--field", "note", "TEXT", "-i", source, "-o",
2018+
target},
2019+
out, err);
2020+
EXPECT_EQ(code, 0) << err.str();
2021+
return code;
2022+
};
2023+
const auto read = [&](const std::string& path, const std::string& format) {
2024+
std::ostringstream out, err;
2025+
EXPECT_EQ(tsfile_cli::run_cli({"cat", "-t", "t", "-f", format, path},
2026+
out, err),
2027+
0)
2028+
<< err.str();
2029+
return out.str();
2030+
};
2031+
const std::string expected_ndjson =
2032+
R"json({"time":"0","site":"s\r\n1","message":"first\r\nsecond","note":"\"quoted\"\r\n\r\nlast"})json"
2033+
"\n"
2034+
R"json({"time":"1","site":"s\r\n1","message":"lf\nonly","note":"mixed\r\nand\nmore"})json"
2035+
"\n"
2036+
R"json({"time":"2","site":"s\r\n1","message":"carriage\rreturn","note":"\r\n"})json"
2037+
"\n"
2038+
R"json({"time":"3","site":"s\r\n1","message":"ends\r","note":"line\r\nend"})json"
2039+
"\n";
2040+
ASSERT_EQ(write(input, first), 0);
2041+
EXPECT_EQ(read(first, "ndjson"), expected_ndjson);
2042+
2043+
for (const std::string command : {"cat", "export"}) {
2044+
SCOPED_TRACE(command);
2045+
const std::string exported = tsfile_cli_test::unique_temp_path(
2046+
"tsfile_cli_crlf_" + command, ".csv");
2047+
const std::string second = tsfile_cli_test::unique_temp_path(
2048+
"tsfile_cli_crlf_" + command, ".tsfile");
2049+
if (command == "cat") {
2050+
const std::string csv = read(first, "csv");
2051+
EXPECT_EQ(csv, expected_csv);
2052+
std::ofstream file(exported.c_str(), std::ios::binary);
2053+
ASSERT_TRUE(file.good());
2054+
file << csv;
2055+
} else {
2056+
std::ostringstream out, err;
2057+
ASSERT_EQ(tsfile_cli::run_cli({"export", "-t", "t", "--type", "csv",
2058+
"-o", exported, first},
2059+
out, err),
2060+
0)
2061+
<< err.str();
2062+
std::ifstream file(exported.c_str(), std::ios::binary);
2063+
ASSERT_TRUE(file.good());
2064+
std::ostringstream bytes;
2065+
bytes << file.rdbuf();
2066+
EXPECT_EQ(bytes.str(), expected_csv);
2067+
}
2068+
ASSERT_EQ(write(exported, second), 0);
2069+
EXPECT_EQ(read(second, "ndjson"), expected_ndjson);
2070+
std::remove(exported.c_str());
2071+
std::remove(second.c_str());
2072+
}
2073+
std::remove(input.c_str());
2074+
std::remove(first.c_str());
2075+
}

‎cpp/test/tools/input_format_test.cc‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,16 +158,52 @@ TEST(InputFormatTest, ReadRecordStripsCarriageReturn) {
158158
long long n = 0;
159159
ASSERT_TRUE(tsfile_cli::read_record(in, true, rec, n));
160160
EXPECT_EQ(rec, "a,b");
161+
EXPECT_EQ(n, 1);
162+
ASSERT_TRUE(tsfile_cli::read_record(in, true, rec, n));
163+
EXPECT_EQ(rec, "c,d");
164+
EXPECT_EQ(n, 1);
165+
EXPECT_FALSE(tsfile_cli::read_record(in, true, rec, n));
166+
}
167+
168+
TEST(InputFormatTest, ReadRecordPreservesQuotedLineEndings) {
169+
std::istringstream in(
170+
"1,\"line one\r\n\r\nline \"\"two\"\"\nline three\",x\r\n"
171+
"2,plain,y\r\n");
172+
std::string rec;
173+
long long n = 0;
174+
ASSERT_TRUE(tsfile_cli::read_record(in, true, rec, n));
175+
EXPECT_EQ(rec, "1,\"line one\r\n\r\nline \"\"two\"\"\nline three\",x");
176+
EXPECT_EQ(n, 4);
177+
const auto fields = tsfile_cli::split_line(rec, ',', true);
178+
ASSERT_EQ(fields.size(), 3u);
179+
EXPECT_EQ(fields[1], "line one\r\n\r\nline \"two\"\nline three");
180+
ASSERT_TRUE(tsfile_cli::read_record(in, true, rec, n));
181+
EXPECT_EQ(rec, "2,plain,y");
182+
EXPECT_EQ(n, 1);
183+
EXPECT_FALSE(tsfile_cli::read_record(in, true, rec, n));
184+
}
185+
186+
TEST(InputFormatTest, ReadRecordPreservesQuotedCarriageReturnAtEof) {
187+
std::istringstream in("1,\"never closed\r");
188+
std::string rec;
189+
long long n = 0;
190+
ASSERT_TRUE(tsfile_cli::read_record(in, true, rec, n));
191+
EXPECT_EQ(rec, "1,\"never closed\r");
192+
EXPECT_EQ(n, 1);
193+
EXPECT_FALSE(tsfile_cli::read_record(in, true, rec, n));
161194
}
162195

163196
TEST(InputFormatTest, ReadRecordTsvIgnoresQuotes) {
164197
// With csv_quotes false a quote is just data; no line joining happens.
165-
std::istringstream in("1\t\"open\n2\tclosed\n");
198+
std::istringstream in("1\t\"open\r\n2\tclosed\r\n");
166199
std::string rec;
167200
long long n = 0;
168201
ASSERT_TRUE(tsfile_cli::read_record(in, false, rec, n));
169202
EXPECT_EQ(rec, "1\t\"open");
170203
EXPECT_EQ(n, 1);
204+
ASSERT_TRUE(tsfile_cli::read_record(in, false, rec, n));
205+
EXPECT_EQ(rec, "2\tclosed");
206+
EXPECT_EQ(n, 1);
171207
}
172208

173209
TEST(InputFormatTest, ReadRecordUnterminatedQuoteAtEof) {

‎cpp/tools/commands/cmd_write.cc‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -562,7 +562,7 @@ int cmd_write(const ParsedArgs& args, std::ostream& /*out*/,
562562
return kExitFile;
563563
}
564564
has_input_stat = true;
565-
fin.open(args.file.c_str());
565+
fin.open(args.file.c_str(), std::ios::binary);
566566
if (!fin.is_open()) {
567567
err << "Error: cannot open input: " << args.file << "\n";
568568
return kExitFile;

‎cpp/tools/format/input_format.cc‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,23 +44,26 @@ bool read_record(std::istream& in, bool csv_quotes, std::string& record,
4444
std::string physical;
4545
bool open_quote = false;
4646
while (std::getline(in, physical)) {
47-
strip_cr(physical);
4847
++lines_consumed;
49-
if (!record.empty()) {
50-
record.push_back('\n'); // restore the newline inside the field
51-
}
52-
record += physical;
5348
if (csv_quotes) {
5449
for (char c : physical) {
5550
if (c == '"') {
5651
open_quote = !open_quote;
5752
}
5853
}
59-
if (open_quote) {
60-
continue; // quote still open: the field spans the next line
61-
}
6254
}
63-
return true;
55+
if (!open_quote) {
56+
// Strip CR from record boundaries, but keep it as field data
57+
// when the quoted field continues onto the next physical line.
58+
strip_cr(physical);
59+
}
60+
if (!record.empty()) {
61+
record.push_back('\n'); // restore the newline inside the field
62+
}
63+
record += physical;
64+
if (!open_quote) {
65+
return true;
66+
}
6467
}
6568
return lines_consumed > 0; // trailing record with an unterminated quote
6669
}
@@ -80,8 +83,7 @@ std::vector<std::string> split_line(const std::string& line, char delim,
8083
}
8184

8285
bool normalize_write_columns(const std::vector<WriteColumnSpec>& specs,
83-
std::vector<ColumnDef>& out,
84-
std::string& error) {
86+
std::vector<ColumnDef>& out, std::string& error) {
8587
out.clear();
8688
if (specs.empty()) {
8789
error = "empty write column declarations";

‎cpp/tools/format/input_format.h‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,7 @@ struct ColumnDef {
3838

3939
bool parse_datatype_name(const std::string& s, common::TSDataType& out);
4040
bool normalize_write_columns(const std::vector<WriteColumnSpec>& specs,
41-
std::vector<ColumnDef>& out,
42-
std::string& error);
41+
std::vector<ColumnDef>& out, std::string& error);
4342
std::vector<std::string> split_line(const std::string& line, char delim,
4443
bool csv_quotes);
4544
bool parse_bool_cell(const std::string& s, bool& out);
@@ -49,9 +48,12 @@ bool validate_identifier(const std::string& name, std::string& error);
4948

5049
// Read one logical record from `in`. When `csv_quotes` is true a field may span
5150
// multiple physical lines if it opens a double-quote that is not yet closed, so
52-
// continuation lines are appended (newline preserved) until the quote closes.
53-
// `lines_consumed` returns how many physical lines were read, so callers can
54-
// keep accurate line numbers. Returns false at end of input with no record.
51+
// continuation lines are appended (LF or CRLF preserved) until the quote
52+
// closes. CRLF outside quotes is a record boundary, not field data. File
53+
// streams should be opened in binary mode to prevent platform newline
54+
// translation. `lines_consumed` returns how many physical lines were read, so
55+
// callers can keep accurate line numbers. Returns false at end of input with no
56+
// record.
5557
bool read_record(std::istream& in, bool csv_quotes, std::string& record,
5658
long long& lines_consumed);
5759

‎cpp/tools/tools_main.cc‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@
1919

2020
#include <exception>
2121
#include <iostream>
22-
#ifndef _WIN32
22+
#ifdef _WIN32
23+
#include <fcntl.h>
24+
#include <io.h>
25+
26+
#include <cstdio>
27+
#else
2328
#include <csignal>
2429
#endif
2530
#include <string>
@@ -29,7 +34,19 @@
2934
#include "cli/run_cli.h"
3035

3136
int main(int argc, char** argv) {
32-
#ifndef _WIN32
37+
#ifdef _WIN32
38+
// Preserve CSV field bytes in pipes and redirected files. CRT text mode
39+
// otherwise converts CRLF on input and LF on output, including inside
40+
// quotes.
41+
const auto binary_if_redirected = [](FILE* stream) {
42+
const int fd = _fileno(stream);
43+
if (fd >= 0 && _isatty(fd) == 0) {
44+
_setmode(fd, _O_BINARY);
45+
}
46+
};
47+
binary_if_redirected(stdin);
48+
binary_if_redirected(stdout);
49+
#else
3350
std::signal(SIGPIPE, SIG_IGN);
3451
#endif
3552
std::vector<std::string> args(argv + 1, argv + argc);

0 commit comments

Comments
 (0)