From b6fe5f976083c436ced8b4ebdb6903fc2ebf8f41 Mon Sep 17 00:00:00 2001 From: Animesh Kumar Date: Tue, 20 Oct 2026 08:23:49 +0530 Subject: [PATCH] GH-54535: [C++] Quote nulls in single-column CSV output so rows aren't written as empty lines --- cpp/src/arrow/csv/options.h | 4 +++ cpp/src/arrow/csv/writer.cc | 11 +++++-- cpp/src/arrow/csv/writer_test.cc | 51 +++++++++++++++++++++++++++----- 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/cpp/src/arrow/csv/options.h b/cpp/src/arrow/csv/options.h index 41d0b63dacbb..adfe67ffe4c2 100644 --- a/cpp/src/arrow/csv/options.h +++ b/cpp/src/arrow/csv/options.h @@ -206,6 +206,10 @@ struct ARROW_EXPORT WriteOptions { char delimiter = ','; /// \brief The string to write for null values. Quotes are not allowed in this string. + /// + /// If this is empty and the schema has a single column, nulls are written as `""` + /// (unless `quoting_style` is `QuotingStyle::None`), so that a null row is not + /// written as an empty line, which readers skip by default. std::string null_string; /// \brief IO context for writing. diff --git a/cpp/src/arrow/csv/writer.cc b/cpp/src/arrow/csv/writer.cc index 2db0dba2de71..0e4cd32e5632 100644 --- a/cpp/src/arrow/csv/writer.cc +++ b/cpp/src/arrow/csv/writer.cc @@ -512,10 +512,15 @@ class CSVWriterImpl : public ipc::RecordBatchWriter { return Status::Invalid("Null string cannot contain quotes."); } + const std::string null_string_value = + schema->num_fields() == 1 && options.null_string.empty() && + options.quoting_style != QuotingStyle::None + ? std::string(R"("")") + : options.null_string; ARROW_ASSIGN_OR_RAISE(std::shared_ptr null_string, - arrow::AllocateBuffer(options.null_string.length())); - memcpy(null_string->mutable_data(), options.null_string.data(), - options.null_string.length()); + arrow::AllocateBuffer(null_string_value.length())); + memcpy(null_string->mutable_data(), null_string_value.data(), + null_string_value.length()); std::vector> populators(schema->num_fields()); std::string delimiter(1, options.delimiter); diff --git a/cpp/src/arrow/csv/writer_test.cc b/cpp/src/arrow/csv/writer_test.cc index ce4d8ab16d01..e6ec4f939314 100644 --- a/cpp/src/arrow/csv/writer_test.cc +++ b/cpp/src/arrow/csv/writer_test.cc @@ -23,6 +23,7 @@ #include #include "arrow/buffer.h" +#include "arrow/csv/reader.h" #include "arrow/csv/writer.h" #include "arrow/io/memory.h" #include "arrow/ipc/writer.h" @@ -384,13 +385,49 @@ TEST_P(TestWriteCSV, TestWrite) { INSTANTIATE_TEST_SUITE_P(MultiColumnWriteCSVTest, TestWriteCSV, ::testing::ValuesIn(GenerateTestCases())); -INSTANTIATE_TEST_SUITE_P(SingleColumnWriteCSVTest, TestWriteCSV, - ::testing::Values(WriterTestParams( - schema({field("int64", int64())}), - R"([{ "int64": 9999}, {}, { "int64": -15}])", WriteOptions(), - R"("int64")" - "\n9999\n\n-15\n", - Status::OK()))); +INSTANTIATE_TEST_SUITE_P( + SingleColumnWriteCSVTest, TestWriteCSV, + ::testing::Values( + WriterTestParams(schema({field("int64", int64())}), + R"([{ "int64": 9999}, {}, { "int64": -15}])", WriteOptions(), + R"("int64")" + "\n9999\n\"\"\n-15\n", + Status::OK()), + WriterTestParams(schema({field("int64", int64())}), R"([{}, { "int64": 1}, {}])", + DefaultTestOptions(), "\"\"\n1\n\"\"\n"), + WriterTestParams(schema({field("int64", int64())}), R"([{}, { "int64": 1}, {}])", + DefaultTestOptions(/*include_header=*/false, /*null_string=*/"", + QuotingStyle::AllValid), + "\"\"\n\"1\"\n\"\"\n"), + WriterTestParams(schema({field("int64", int64())}), R"([{}, { "int64": 1}, {}])", + DefaultTestOptions(/*include_header=*/false, /*null_string=*/"", + QuotingStyle::None), + "\n1\n\n"), + WriterTestParams(schema({field("int64", int64())}), R"([{}, { "int64": 1}, {}])", + DefaultTestOptions(/*include_header=*/false, + /*null_string=*/"NA"), + "NA\n1\nNA\n"), + WriterTestParams(schema({field("s", utf8())}), R"([{ "s": "a"}, {}, { "s": ""}])", + DefaultTestOptions(), "\"a\"\n\"\"\n\"\"\n"), + WriterTestParams(schema({field("a", int64()), field("b", int64())}), + R"([{ "a": 1}, {}])", DefaultTestOptions(), "1,\n,\n"))); + +TEST(TestWriteCSV, SingleColumnNullsRoundTrip) { + auto schema = arrow::schema({field("c", int64())}); + auto batch = RecordBatchFromJSON(schema, R"([{"c": 1}, {}, {"c": 3}, {}, {"c": 5}])"); + ASSERT_OK_AND_ASSIGN(auto out, io::BufferOutputStream::Create()); + ASSERT_OK(WriteCSV(*batch, WriteOptions::Defaults(), out.get())); + ASSERT_OK_AND_ASSIGN(std::shared_ptr csv, out->Finish()); + + auto input = std::make_shared(csv); + ASSERT_OK_AND_ASSIGN( + auto reader, + TableReader::Make(io::default_io_context(), input, ReadOptions::Defaults(), + ParseOptions::Defaults(), ConvertOptions::Defaults())); + ASSERT_OK_AND_ASSIGN(auto table, reader->Read()); + ASSERT_OK_AND_ASSIGN(auto expected, Table::FromRecordBatches({batch})); + AssertTablesEqual(*expected, *table); +} #ifndef _WIN32 // TODO(ARROW-13168):