From c20e2782438065eba6ab02b65ec45f60d70a715b Mon Sep 17 00:00:00 2001 From: Nilanjan Chatterjee Date: Sun, 11 Oct 2026 14:52:35 -0500 Subject: [PATCH] GH-45394: [C++][Dataset] Read single-line JSON files without a trailing newline The dataset JSON reader derived the physical schema from the complete lines of the first block only. A file holding one object without a newline produced an empty schema, so every column was read as null. Parse the whole block when it already covers the entire file. Co-Authored-By: Claude Opus 5.5 --- cpp/src/arrow/dataset/file_json.cc | 26 ++++++++++++++++++------- cpp/src/arrow/dataset/file_json_test.cc | 18 +++++++++++++++++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/cpp/src/arrow/dataset/file_json.cc b/cpp/src/arrow/dataset/file_json.cc index 587619e04d02..19579965b7d0 100644 --- a/cpp/src/arrow/dataset/file_json.cc +++ b/cpp/src/arrow/dataset/file_json.cc @@ -135,12 +135,19 @@ json::ParseOptions GetInitialParseOptions(json::ParseOptions options) { return options; } +// `is_final` means `data` holds the whole file, so a trailing object without a +// newline is complete. Result> ParseToStructType( - std::string_view data, const json::ParseOptions& parse_options, MemoryPool* pool) { + std::string_view data, bool is_final, const json::ParseOptions& parse_options, + MemoryPool* pool) { auto full_buffer = std::make_shared(data); std::shared_ptr buffer, partial; - auto chunker = json::MakeChunker(parse_options); - RETURN_NOT_OK(chunker->Process(full_buffer, &buffer, &partial)); + if (is_final) { + buffer = full_buffer; + } else { + auto chunker = json::MakeChunker(parse_options); + RETURN_NOT_OK(chunker->Process(full_buffer, &buffer, &partial)); + } std::unique_ptr parser; RETURN_NOT_OK(json::BlockParser::Make(pool, parse_options, &parser)); @@ -151,10 +158,11 @@ Result> ParseToStructType( return checked_pointer_cast(parsed->type()); } -Result> ParseToSchema(std::string_view data, +Result> ParseToSchema(std::string_view data, bool is_final, const json::ParseOptions& parse_options, MemoryPool* pool) { - ARROW_ASSIGN_OR_RAISE(auto type, ParseToStructType(data, parse_options, pool)); + ARROW_ASSIGN_OR_RAISE(auto type, + ParseToStructType(data, is_final, parse_options, pool)); return schema(type->fields()); } @@ -289,8 +297,10 @@ Result> DoOpenReader( // schema for the reader based on the dataset schema. ARROW_ASSIGN_OR_RAISE(auto first_block, state->stream->Peek(state->read_options.block_size)); + const bool is_final = + static_cast(first_block.size()) < state->read_options.block_size; ARROW_ASSIGN_OR_RAISE(auto physical_schema, - ParseToSchema(first_block, state->parse_options, + ParseToSchema(first_block, is_final, state->parse_options, state->scan_options->pool)); ARROW_ASSIGN_OR_RAISE(state->parse_options.explicit_schema, GetPartialSchema(*state->scan_options, *physical_schema)); @@ -344,8 +354,10 @@ Result> DoInspectFragment( stream->Peek(format_options.read_options.block_size)); auto parse_options = GetInitialParseOptions(format_options.parse_options); + const bool is_final = + static_cast(first_block.size()) < format_options.read_options.block_size; ARROW_ASSIGN_OR_RAISE(auto struct_type, - ParseToStructType(first_block, parse_options, pool)); + ParseToStructType(first_block, is_final, parse_options, pool)); std::vector column_names; column_names.reserve(struct_type->num_fields()); diff --git a/cpp/src/arrow/dataset/file_json_test.cc b/cpp/src/arrow/dataset/file_json_test.cc index aa060e73bb22..b8d71e864780 100644 --- a/cpp/src/arrow/dataset/file_json_test.cc +++ b/cpp/src/arrow/dataset/file_json_test.cc @@ -220,6 +220,23 @@ class JsonScanMixin { ASSERT_EQ(num_rows, 3); } + void TestScanSingleLineWithoutNewline() { + // GH-45394 + auto source = ToFileSource("{\"i\":7}"); + auto fragment = this_->MakeFragment(*source); + this_->SetSchema({field("i", int64())}); + this_->SetJsonOptions(); + + RecordBatchVector batches; + for (auto maybe_batch : this_->Batches(fragment)) { + ASSERT_OK_AND_ASSIGN(auto batch, maybe_batch); + batches.push_back(std::move(batch)); + } + ASSERT_OK_AND_ASSIGN(auto table, Table::FromRecordBatches(batches)); + AssertTablesEqual(*TableFromJSON(schema({field("i", int64())}), {R"([{"i": 7}])"}), + *table); + } + void TestScanWithParallelDecoding() { // Test intra-fragment parallelism, which the JSON reader supports independent of the // scanner. We set a small block size to stress thread usage alongside the scanner's @@ -313,6 +330,7 @@ TEST_P(TestJsonScan, ScanWithBOM) { TestScanWithBOM(); } TEST_P(TestJsonScan, ScanWithCustomParseOptions) { TestScanWithCustomParseOptions(); } TEST_P(TestJsonScan, ScanWithCustomBlockSize) { TestScanWithCustomBlockSize(); } TEST_P(TestJsonScan, ScanWithParallelDecoding) { TestScanWithParallelDecoding(); } +TEST_P(TestJsonScan, ScanSingleLineWithoutNewline) { TestScanSingleLineWithoutNewline(); } INSTANTIATE_TEST_SUITE_P(TestJsonScan, TestJsonScan, ::testing::ValuesIn(TestFormatParams::Values()),