This is an automated email from the ASF dual-hosted git repository.

wgtmac pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow.git


The following commit(s) were added to refs/heads/main by this push:
     new 51955ecd2c0 GH-51097: Fix Parquet null counts for fixed-width leaves 
(#51357)
51955ecd2c0 is described below

commit 51955ecd2c0bb94b55c2130171386f8568fc2dbe
Author: Anurag Tryambak Raut <[email protected]>
AuthorDate: Sat Sep 26 15:55:33 2026 +0530

    GH-51097: Fix Parquet null counts for fixed-width leaves (#51357)
    
    ### Rationale for this change
    
    Parquet null-count statistics can be incorrect for fixed-width leaf columns 
nested below a repeated ancestor, such as `list<struct<...>>`.
    
    When a list is null or empty, its descendant leaf does not produce a value 
in the leaf values buffer. `DefLevelsToBitmap` intentionally excludes these 
repeated-ancestor nulls from its `null_count`. `MaybeCalculateValidityBits` was 
using that value directly for the Parquet column statistics, causing the null 
count to be undercounted.
    
    For example, a `list<struct<string, int32>>` column can report an incorrect 
`null_count` for the `int32` leaf even though the encoded definition levels 
correctly represent the null and empty list entries.
    
    ### What changes are included in this PR?
    
    * Update `MaybeCalculateValidityBits` to calculate the total null count 
from `batch_size - out_values_to_write`.
    * This includes nulls represented by repeated ancestors, such as null or 
empty lists, while preserving the existing validity bitmap behavior.
    * Add a regression test covering a fixed-width `int32` leaf under 
`list<struct<...>>`.
    
    ### Are these changes tested?
    
    Yes.
    
    Added a regression test that verifies the `int32` leaf reports:
    
    * `null_count = 3`
    * `num_values = 2`
    
    The focused regression test passes:
    
    `StatisticsTest.FixedWidthLeafUnderListStructNullCount`
    
    The existing `parquet-writer-test` also passes.
    
    ### Are there any user-facing changes?
    
    Yes. This fixes incorrect Parquet column statistics for affected nested 
fixed-width columns. The change does not alter the encoded data or public APIs.
    
    ### This PR contains a "Critical Fix".
    
    This fixes a bug that produces incorrect Parquet statistics. The underlying 
data remains correct, but the reported `null_count` for affected fixed-width 
leaf columns can be incorrect.
    
    ### Was AI used for this PR?
    
    **PR code and description written by:**
    
    * [x] Human
    * [ ] AI
    
    **Reviewed before submission by:**
    
    * [x] Human
    * [ ] AI
    * [ ] Not reviewed
    
    * GitHub Issue: #51097
    
    Lead-authored-by: AnuragRaut08 <[email protected]>
    Co-authored-by: Gang Wu <[email protected]>
    Signed-off-by: Gang Wu <[email protected]>
---
 cpp/src/parquet/arrow/arrow_statistics_test.cc | 103 +++++++++++++++++++++++++
 cpp/src/parquet/column_writer.cc               |  13 ++--
 2 files changed, 111 insertions(+), 5 deletions(-)

diff --git a/cpp/src/parquet/arrow/arrow_statistics_test.cc 
b/cpp/src/parquet/arrow/arrow_statistics_test.cc
index 27a76fd72be..a6817f1cbc2 100644
--- a/cpp/src/parquet/arrow/arrow_statistics_test.cc
+++ b/cpp/src/parquet/arrow/arrow_statistics_test.cc
@@ -20,6 +20,7 @@
 #include "arrow/array.h"
 #include "arrow/array/builder_primitive.h"
 #include "arrow/array/builder_time.h"
+#include "arrow/compute/api.h"
 #include "arrow/table.h"
 #include "arrow/testing/gtest_util.h"
 
@@ -160,6 +161,108 @@ INSTANTIATE_TEST_SUITE_P(
             /*expected_min=*/"z",
             /*expected_max=*/"z"}));
 
+TEST(StatisticsTest, FixedWidthLeafUnderListStructNullCount) {
+  // Null counts for leaves under list<struct<...>> must include null and empty
+  // list entries from the repeated ancestor.
+  for (const auto data_page_version :
+       {ParquetDataPageVersion::V1, ParquetDataPageVersion::V2}) {
+    for (const bool use_dictionary : {false, true}) {
+      SCOPED_TRACE(::testing::Message()
+                   << "data_page_version=" << 
static_cast<int>(data_page_version)
+                   << ", use_dictionary=" << use_dictionary);
+
+      auto string_type = use_dictionary
+                             ? ::arrow::dictionary(::arrow::int32(), 
::arrow::utf8())
+                             : ::arrow::utf8();
+      auto list_type = ::arrow::list(::arrow::struct_(
+          {::arrow::field("s", string_type), ::arrow::field("i32", 
::arrow::int32())}));
+      auto schema = ::arrow::schema({::arrow::field("col", list_type)});
+      auto table = ::arrow::TableFromJSON(schema, {R"([
+        [[{"s":"a","i32":1}]],
+        [null],
+        [[]],
+        [[{"s":null,"i32":null},{"s":"b","i32":2}]]
+      ])"});
+
+      WriterProperties::Builder properties_builder;
+      properties_builder.data_page_version(data_page_version);
+      if (use_dictionary) {
+        properties_builder.enable_dictionary();
+      } else {
+        properties_builder.disable_dictionary();
+      }
+
+      std::shared_ptr<::arrow::ResizableBuffer> serialized_data = 
AllocateBuffer();
+      auto out_stream =
+          std::make_shared<::arrow::io::BufferOutputStream>(serialized_data);
+
+      ASSERT_OK_AND_ASSIGN(std::unique_ptr<FileWriter> writer,
+                           FileWriter::Open(*schema, default_memory_pool(), 
out_stream,
+                                            properties_builder.build(),
+                                            
default_arrow_writer_properties()));
+      ASSERT_OK(writer->WriteTable(*table));
+      ASSERT_OK(writer->Close());
+      ASSERT_OK(out_stream->Close());
+
+      auto buffer_reader = 
std::make_shared<::arrow::io::BufferReader>(serialized_data);
+      auto parquet_reader = ParquetFileReader::Open(std::move(buffer_reader));
+      auto metadata = parquet_reader->metadata();
+      auto row_group = metadata->RowGroup(0);
+
+      ASSERT_EQ(row_group->num_columns(), 2);
+
+      for (int i = 0; i < 2; ++i) {
+        auto stats = row_group->ColumnChunk(i)->statistics();
+        ASSERT_NE(stats, nullptr);
+        EXPECT_EQ(stats->null_count(), 3);
+        EXPECT_EQ(stats->num_values(), 2);
+
+        if (data_page_version == ParquetDataPageVersion::V2) {
+          auto page_reader = 
parquet_reader->RowGroup(0)->GetColumnPageReader(i);
+          if (use_dictionary) {
+            auto dictionary_page = page_reader->NextPage();
+            ASSERT_NE(dictionary_page, nullptr);
+            ASSERT_EQ(dictionary_page->type(), PageType::DICTIONARY_PAGE);
+          }
+          auto page = page_reader->NextPage();
+          ASSERT_NE(page, nullptr);
+          ASSERT_EQ(page->type(), PageType::DATA_PAGE_V2);
+          auto data_page = std::static_pointer_cast<DataPageV2>(page);
+          EXPECT_EQ(data_page->num_values(), 5);
+          EXPECT_EQ(data_page->num_nulls(), 3);
+          EXPECT_EQ(page_reader->NextPage(), nullptr);
+        }
+      }
+
+      ASSERT_OK_AND_ASSIGN(
+          auto file_reader,
+          FileReader::Make(default_memory_pool(), std::move(parquet_reader),
+                           default_arrow_reader_properties()));
+
+      ASSERT_OK_AND_ASSIGN(auto read_table, file_reader->ReadTable());
+
+      if (use_dictionary) {
+        auto plain_list_type =
+            ::arrow::list(::arrow::struct_({::arrow::field("s", 
::arrow::utf8()),
+                                            ::arrow::field("i32", 
::arrow::int32())}));
+
+        ASSERT_OK_AND_ASSIGN(
+            auto read_array,
+            ::arrow::compute::Cast(read_table->column(0)->chunk(0), 
plain_list_type));
+
+        auto expected_array = table->column(0)->chunk(0);
+
+        ASSERT_OK_AND_ASSIGN(auto expected_plain,
+                             ::arrow::compute::Cast(expected_array, 
plain_list_type));
+
+        ASSERT_TRUE(read_array.Equals(expected_plain));
+      } else {
+        ASSERT_TRUE(read_table->Equals(*table));
+      }
+    }
+  }
+}
+
 TEST(StatisticsTest, TruncateOnlyHalfMinMax) {
   // GH-43382: Tests when we only have min or max, the `HasMinMax` should be 
false.
   std::shared_ptr<::arrow::ResizableBuffer> serialized_data = AllocateBuffer();
diff --git a/cpp/src/parquet/column_writer.cc b/cpp/src/parquet/column_writer.cc
index 653f28f64bd..3296af62f0c 100644
--- a/cpp/src/parquet/column_writer.cc
+++ b/cpp/src/parquet/column_writer.cc
@@ -1397,18 +1397,19 @@ class TypedColumnWriterImpl : public ColumnWriterImpl,
 
       WriteLevelsSpaced(batch_size, AddIfNotNull(def_levels, offset),
                         AddIfNotNull(rep_levels, offset));
+      const int64_t parquet_null_count = batch_size - batch_num_values;
       if (bits_buffer_ != nullptr) {
         WriteValuesSpaced(AddIfNotNull(values, value_offset), batch_num_values,
                           batch_num_spaced_values, bits_buffer_->data(), 
/*offset=*/0,
-                          /*num_levels=*/batch_size, null_count);
+                          /*num_levels=*/batch_size, parquet_null_count);
       } else {
         WriteValuesSpaced(AddIfNotNull(values, value_offset), batch_num_values,
                           batch_num_spaced_values, valid_bits,
                           valid_bits_offset + value_offset, 
/*num_levels=*/batch_size,
-                          null_count);
+                          parquet_null_count);
       }
-      CommitWriteAndCheckPageLimit(batch_size, batch_num_spaced_values, 
null_count,
-                                   check_page);
+      CommitWriteAndCheckPageLimit(batch_size, batch_num_spaced_values,
+                                   parquet_null_count, check_page);
       value_offset += batch_num_spaced_values;
 
       // Dictionary size checked separately from data page size since we
@@ -2051,7 +2052,9 @@ Status 
TypedColumnWriterImpl<ParquetType>::WriteArrowDictionary(
     dict_encoder->PutIndices(*writeable_indices);
     // Update unencoded byte array data size to size statistics
     UpdateUnencodedDataBytes();
-    CommitWriteAndCheckPageLimit(batch_size, batch_num_values, null_count, 
check_page);
+    const int64_t parquet_null_count = batch_size - batch_num_values;
+    CommitWriteAndCheckPageLimit(batch_size, batch_num_values, 
parquet_null_count,
+                                 check_page);
     value_offset += batch_num_spaced_values;
   };
 

Reply via email to