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/iceberg-cpp.git
The following commit(s) were added to refs/heads/main by this push:
new e1633ad5 fix: preserve list types for empty Parquet splits (#1040)
e1633ad5 is described below
commit e1633ad5667f9933c0876bdcf2402ec9657814f3
Author: Haoxiang Meng <[email protected]>
AuthorDate: Mon Oct 19 15:22:54 2026 +0800
fix: preserve list types for empty Parquet splits (#1040)
Use Arrow's record batch reader for empty splits so schema alignment
preserves configured and stored list types. Add regression tests for
empty-split schema consistency and end-of-stream behavior.
Co-authored-by: kiwimeng <[email protected]>
---
src/iceberg/parquet/parquet_reader.cc | 28 ++-----
src/iceberg/test/parquet_test.cc | 148 ++++++++++++++++++++++++++++++++++
2 files changed, 153 insertions(+), 23 deletions(-)
diff --git a/src/iceberg/parquet/parquet_reader.cc
b/src/iceberg/parquet/parquet_reader.cc
index 3f15ab7a..90fa0646 100644
--- a/src/iceberg/parquet/parquet_reader.cc
+++ b/src/iceberg/parquet/parquet_reader.cc
@@ -77,19 +77,6 @@ Result<SchemaProjection>
BuildProjection(::parquet::arrow::FileReader* reader,
return projection;
}
-class EmptyRecordBatchReader : public ::arrow::RecordBatchReader {
- public:
- EmptyRecordBatchReader() = default;
- ~EmptyRecordBatchReader() override = default;
-
- std::shared_ptr<::arrow::Schema> schema() const override { return nullptr; }
-
- ::arrow::Status ReadNext(std::shared_ptr<::arrow::RecordBatch>* batch)
override {
- *batch = nullptr;
- return ::arrow::Status::OK();
- }
-};
-
// forward declaration to unblock cycle dependence.
std::shared_ptr<::arrow::Field> UseLargeListField(
const std::shared_ptr<::arrow::Field>& field);
@@ -423,16 +410,11 @@ class ParquetReader::Impl {
std::iota(row_group_indices.begin(), row_group_indices.end(), 0); //
NOLINT
}
- // Create the record batch reader
- if (row_group_indices.empty()) {
- // None of the row groups are selected, return an empty record batch
reader
- context_->record_batch_reader_ =
std::make_unique<EmptyRecordBatchReader>();
- } else {
- auto column_indices = SelectedColumnIndices(projection_);
- ICEBERG_ARROW_ASSIGN_OR_RETURN(
- context_->record_batch_reader_,
- reader_->GetRecordBatchReader(row_group_indices, column_indices));
- }
+ // Create the record batch reader, including for empty splits to preserve
the schema.
+ auto column_indices = SelectedColumnIndices(projection_);
+ ICEBERG_ARROW_ASSIGN_OR_RETURN(
+ context_->record_batch_reader_,
+ reader_->GetRecordBatchReader(row_group_indices, column_indices));
if (NeedsRowPositions(*read_schema_, metadata_context_)) {
ICEBERG_ASSIGN_OR_RAISE(context_->row_number_reader_,
diff --git a/src/iceberg/test/parquet_test.cc b/src/iceberg/test/parquet_test.cc
index 6dedba44..9fbeae87 100644
--- a/src/iceberg/test/parquet_test.cc
+++ b/src/iceberg/test/parquet_test.cc
@@ -420,6 +420,35 @@ class ParquetReaderTest : public TempFileTestBase {
ASSERT_FALSE(data.value().has_value());
}
+ void VerifyEmptySplitSchema(const std::shared_ptr<Schema>& projection,
+ const ReaderProperties& properties,
+ const std::shared_ptr<::arrow::DataType>&
expected_type) {
+ ReaderOptions options{.path = temp_parquet_file_,
+ .io = file_io_,
+ .projection = projection,
+ .properties = properties};
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto full_reader,
ReaderFactoryRegistry::Open(FileFormatType::kParquet, options));
+ ICEBERG_UNWRAP_OR_FAIL(auto full_c_schema, full_reader->Schema());
+ auto full_schema = ::arrow::ImportSchema(&full_c_schema).ValueOrDie();
+ ASSERT_THAT(full_reader->Close(), IsOk());
+
+ auto& io =
internal::checked_cast<arrow::ArrowFileSystemFileIO&>(*file_io_);
+ auto file_info = io.fs()->GetFileInfo(temp_parquet_file_).ValueOrDie();
+ options.split = Split{.offset = static_cast<size_t>(file_info.size()),
.length = 1};
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto reader, ReaderFactoryRegistry::Open(FileFormatType::kParquet,
options));
+ ICEBERG_UNWRAP_OR_FAIL(auto arrow_c_schema, reader->Schema());
+ auto schema = ::arrow::ImportSchema(&arrow_c_schema).ValueOrDie();
+ EXPECT_TRUE(schema->Equals(*full_schema, /*check_metadata=*/true))
+ << schema->ToString() << " vs " << full_schema->ToString();
+ auto actual_type = ::arrow::struct_(schema->fields());
+ EXPECT_TRUE(actual_type->Equals(expected_type))
+ << actual_type->ToString() << " vs " << expected_type->ToString();
+ ASSERT_NO_FATAL_FAILURE(VerifyExhausted(*reader));
+ ASSERT_THAT(reader->Close(), IsOk());
+ }
+
std::shared_ptr<FileIO> file_io_;
std::string temp_parquet_file_;
};
@@ -858,6 +887,125 @@ TEST_F(ParquetReaderTest,
ReadRenamedLargeListColumnWithArrowSchema) {
ASSERT_NO_FATAL_FAILURE(VerifyExhausted(*reader));
}
+TEST_F(ParquetReaderTest, EmptySplitReadsListsAsLargeListWhenRequested) {
+ ASSERT_NO_FATAL_FAILURE(CreateListParquetFile());
+ auto schema =
+
std::make_shared<Schema>(std::vector<SchemaField>{SchemaField::MakeOptional(
+ 2, "numbers", list(SchemaField::MakeOptional(101, "element",
int32())))});
+
+ for (bool use_large_list : {false, true}) {
+ SCOPED_TRACE(use_large_list);
+ ReaderProperties properties;
+ properties.Set(ReaderProperties::kArrowUseLargeList, use_large_list);
+ auto element = ::arrow::field("element", ::arrow::int32());
+ auto list_type =
+ use_large_list ? ::arrow::large_list(element) : ::arrow::list(element);
+ auto expected_type = ::arrow::struct_({::arrow::field("numbers",
list_type)});
+ ASSERT_NO_FATAL_FAILURE(VerifyEmptySplitSchema(schema, properties,
expected_type));
+ }
+}
+
+TEST_F(ParquetReaderTest, EmptySplitKeepsStoredListTypes) {
+ ASSERT_NO_FATAL_FAILURE(CreateMixedListParquetFileWithArrowSchema());
+ auto schema = std::make_shared<Schema>(std::vector<SchemaField>{
+ SchemaField::MakeOptional(3, "renamed",
+ list(SchemaField::MakeOptional(102, "element",
int32()))),
+ SchemaField::MakeOptional(
+ 2, "small_lists", list(SchemaField::MakeOptional(101, "element",
int32())))});
+ auto element = ::arrow::field("element", ::arrow::int32());
+ auto expected_type =
+ ::arrow::struct_({::arrow::field("renamed",
::arrow::large_list(element)),
+ ::arrow::field("small_lists",
::arrow::list(element))});
+
+ for (bool use_large_list : {false, true}) {
+ SCOPED_TRACE(use_large_list);
+ ReaderProperties properties;
+ properties.Set(ReaderProperties::kArrowUseLargeList, use_large_list);
+ ASSERT_NO_FATAL_FAILURE(VerifyEmptySplitSchema(schema, properties,
expected_type));
+ }
+}
+
+TEST_F(ParquetReaderTest, EmptySplitAppliesLargeListToMissingFields) {
+ ASSERT_NO_FATAL_FAILURE(CreateSimpleParquetFile());
+ for (bool include_physical_field : {false, true}) {
+ SCOPED_TRACE(include_physical_field);
+ for (bool use_large_list : {false, true}) {
+ SCOPED_TRACE(use_large_list);
+ auto element = ::arrow::field("element", ::arrow::int32());
+ auto list_type =
+ use_large_list ? ::arrow::large_list(element) :
::arrow::list(element);
+ std::vector<SchemaField> fields{SchemaField::MakeOptional(
+ 9, "missing", list(SchemaField::MakeOptional(109, "element",
int32())))};
+ ::arrow::FieldVector expected_fields{::arrow::field("missing",
list_type)};
+ if (include_physical_field) {
+ fields.push_back(SchemaField::MakeRequired(1, "id", int32()));
+ expected_fields.push_back(::arrow::field("id", ::arrow::int32(),
false));
+ }
+ auto schema = std::make_shared<Schema>(std::move(fields));
+ ReaderProperties properties;
+ properties.Set(ReaderProperties::kArrowUseLargeList, use_large_list);
+ ASSERT_NO_FATAL_FAILURE(VerifyEmptySplitSchema(
+ schema, properties, ::arrow::struct_(std::move(expected_fields))));
+ }
+ }
+}
+
+TEST_F(ParquetReaderTest, EmptySplitWithoutPhysicalColumns) {
+ ASSERT_NO_FATAL_FAILURE(CreateSimpleParquetFile());
+ for (const auto& fields : {std::vector<SchemaField>{},
+
std::vector<SchemaField>{MetadataColumns::kFilePath,
+
MetadataColumns::kRowPosition}}) {
+ SCOPED_TRACE(fields.size());
+ auto schema = std::make_shared<Schema>(fields);
+ ArrowSchema arrow_c_schema;
+ ASSERT_THAT(ToArrowSchema(*schema, &arrow_c_schema), IsOk());
+ auto expected_type = ::arrow::ImportType(&arrow_c_schema).ValueOrDie();
+ ASSERT_NO_FATAL_FAILURE(VerifyEmptySplitSchema(schema, {}, expected_type));
+ }
+}
+
+TEST_F(ParquetReaderTest, EmptySplitKeepsPrunedNestedListTypes) {
+ auto write_schema =
+
std::make_shared<Schema>(std::vector<SchemaField>{SchemaField::MakeOptional(
+ 1, "profile",
+ struct_({SchemaField::MakeOptional(2, "ignored", int32()),
+ SchemaField::MakeOptional(
+ 3, "numbers",
+ list(SchemaField::MakeOptional(103, "element",
int32())))}))});
+ ArrowSchema arrow_c_schema;
+ ASSERT_THAT(ToArrowSchema(*write_schema, &arrow_c_schema), IsOk());
+ auto arrow_type = ::arrow::ImportType(&arrow_c_schema).ValueOrDie();
+ auto array = ::arrow::json::ArrayFromJSONString(
+ arrow_type, R"([[{"ignored": 1, "numbers": [1, 2]}]])")
+ .ValueOrDie();
+ WriterProperties writer_properties;
+ writer_properties.Set(WriterProperties::kParquetCompression,
+ std::string("uncompressed"));
+ ASSERT_THAT(WriteArray(array, {.path = temp_parquet_file_,
+ .schema = write_schema,
+ .io = file_io_,
+ .properties = std::move(writer_properties)}),
+ IsOk());
+
+ auto schema =
+
std::make_shared<Schema>(std::vector<SchemaField>{SchemaField::MakeOptional(
+ 1, "profile",
+ struct_({SchemaField::MakeOptional(
+ 3, "renamed",
+ list(SchemaField::MakeOptional(103, "element", int32())))}))});
+ for (bool use_large_list : {false, true}) {
+ SCOPED_TRACE(use_large_list);
+ ReaderProperties properties;
+ properties.Set(ReaderProperties::kArrowUseLargeList, use_large_list);
+ auto element = ::arrow::field("element", ::arrow::int32());
+ auto list_type =
+ use_large_list ? ::arrow::large_list(element) : ::arrow::list(element);
+ auto expected_type = ::arrow::struct_({::arrow::field(
+ "profile", ::arrow::struct_({::arrow::field("renamed", list_type)}))});
+ ASSERT_NO_FATAL_FAILURE(VerifyEmptySplitSchema(schema, properties,
expected_type));
+ }
+}
+
TEST_F(ParquetReaderTest, ReadSplit) {
CreateSplitParquetFile();