Skip to content

Commit 32f095f

Browse files
zhf999子懿
authored andcommitted
fix(parquet): allow reading nested list/map columns whose leaf types differ only in representation (#172)
1 parent c0b8780 commit 32f095f

3 files changed

Lines changed: 337 additions & 5 deletions

File tree

src/paimon/format/parquet/parquet_file_batch_reader.cpp

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,67 @@ class Predicate;
6464

6565
namespace paimon::parquet {
6666

67+
namespace {
68+
// LIST/MAP do not support pruning fields from their nested value types, but physical and
69+
// logical leaf types may still differ (for example, Parquet reports LTZ timestamps as UTC
70+
// while Paimon exposes them in the local timezone). Compare only the nested projection shape
71+
// here so those representation differences are handled by the normal cast path.
72+
bool HasSameNestedProjectionShape(const std::shared_ptr<arrow::DataType>& read_type,
73+
const std::shared_ptr<arrow::DataType>& file_type) {
74+
const bool read_is_nested = ArrowSchemaValidator::IsNestedType(read_type);
75+
const bool file_is_nested = ArrowSchemaValidator::IsNestedType(file_type);
76+
if (!read_is_nested || !file_is_nested) {
77+
if (read_is_nested || file_is_nested) {
78+
return false;
79+
}
80+
// ParquetTimestampConverter explicitly supports timestamp unit and timezone
81+
// conversion after reading. Other atomic type differences remain unsupported here.
82+
if (read_type->id() == arrow::Type::TIMESTAMP &&
83+
file_type->id() == arrow::Type::TIMESTAMP) {
84+
const auto& read_timestamp = static_cast<const arrow::TimestampType&>(*read_type);
85+
const auto& file_timestamp = static_cast<const arrow::TimestampType&>(*file_type);
86+
return read_timestamp.unit() == file_timestamp.unit() ||
87+
(file_timestamp.unit() == arrow::TimeUnit::MILLI &&
88+
read_timestamp.unit() == arrow::TimeUnit::SECOND);
89+
}
90+
return read_type->Equals(file_type);
91+
}
92+
if (read_type->id() != file_type->id()) {
93+
return false;
94+
}
95+
96+
switch (file_type->id()) {
97+
case arrow::Type::STRUCT: {
98+
if (read_type->num_fields() != file_type->num_fields()) {
99+
return false;
100+
}
101+
for (int32_t i = 0; i < file_type->num_fields(); ++i) {
102+
const auto& read_child = read_type->field(i);
103+
const auto& file_child = file_type->field(i);
104+
if (read_child->name() != file_child->name() ||
105+
!HasSameNestedProjectionShape(read_child->type(), file_child->type())) {
106+
return false;
107+
}
108+
}
109+
return true;
110+
}
111+
case arrow::Type::LIST: {
112+
const auto& read_list = static_cast<const arrow::ListType&>(*read_type);
113+
const auto& file_list = static_cast<const arrow::ListType&>(*file_type);
114+
return HasSameNestedProjectionShape(read_list.value_type(), file_list.value_type());
115+
}
116+
case arrow::Type::MAP: {
117+
const auto& read_map = static_cast<const arrow::MapType&>(*read_type);
118+
const auto& file_map = static_cast<const arrow::MapType&>(*file_type);
119+
return HasSameNestedProjectionShape(read_map.key_type(), file_map.key_type()) &&
120+
HasSameNestedProjectionShape(read_map.item_type(), file_map.item_type());
121+
}
122+
default:
123+
return false;
124+
}
125+
}
126+
} // namespace
127+
67128
ParquetFileBatchReader::ParquetFileBatchReader(
68129
std::shared_ptr<arrow::io::RandomAccessFile>&& input_stream,
69130
std::unique_ptr<FileReaderWrapper>&& reader, const std::map<std::string, std::string>& options,
@@ -667,18 +728,30 @@ Status ParquetFileBatchReader::CollectLeafIndices(const std::shared_ptr<arrow::D
667728
SkipLeafIndices(file_child->type(), leaf_index);
668729
}
669730
}
670-
} else if (file_type->id() == arrow::Type::LIST || file_type->id() == arrow::Type::MAP) {
731+
} else if (file_type->id() == arrow::Type::LIST) {
671732
// Keep behavior aligned with ORC path: list/map inner partial projection
672733
// is currently unsupported and should fail-fast.
673-
if (!read_type->Equals(file_type)) {
734+
if (!HasSameNestedProjectionShape(read_type, file_type)) {
674735
return Status::Invalid(fmt::format(
675736
"Parquet does not support partial projection inside list/map: src {} vs target {}",
676737
file_type->ToString(), read_type->ToString()));
677738
}
678-
for (int32_t i = 0; i < file_type->num_fields(); i++) {
679-
PAIMON_RETURN_NOT_OK(CollectLeafIndices(
680-
read_type->field(i)->type(), file_type->field(i)->type(), leaf_index, indices));
739+
const auto& read_list = static_cast<const arrow::ListType&>(*read_type);
740+
const auto& file_list = static_cast<const arrow::ListType&>(*file_type);
741+
PAIMON_RETURN_NOT_OK(CollectLeafIndices(read_list.value_type(), file_list.value_type(),
742+
leaf_index, indices));
743+
} else if (file_type->id() == arrow::Type::MAP) {
744+
if (!HasSameNestedProjectionShape(read_type, file_type)) {
745+
return Status::Invalid(fmt::format(
746+
"Parquet does not support partial projection inside list/map: src {} vs target {}",
747+
file_type->ToString(), read_type->ToString()));
681748
}
749+
const auto& read_map = static_cast<const arrow::MapType&>(*read_type);
750+
const auto& file_map = static_cast<const arrow::MapType&>(*file_type);
751+
PAIMON_RETURN_NOT_OK(
752+
CollectLeafIndices(read_map.key_type(), file_map.key_type(), leaf_index, indices));
753+
PAIMON_RETURN_NOT_OK(
754+
CollectLeafIndices(read_map.item_type(), file_map.item_type(), leaf_index, indices));
682755
} else {
683756
// Leaf column — collect its index.
684757
indices->push_back((*leaf_index)++);

src/paimon/format/parquet/parquet_file_batch_reader_test.cpp

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,156 @@ TEST_F(ParquetFileBatchReaderTest, TestReadSchemaWithMapSelectedKeysMetadata) {
646646
<< "expected: " << expected_array->ToString() << "\nactual: " << result_array->ToString();
647647
}
648648

649+
TEST_F(ParquetFileBatchReaderTest, TestNestedListTimestampTimezoneAndMapFieldName) {
650+
const std::string timezone = "Asia/Shanghai";
651+
paimon::test::TimezoneGuard timezone_guard(timezone);
652+
653+
auto write_attrs_type =
654+
std::make_shared<arrow::MapType>(arrow::field("key", arrow::utf8(), /*nullable=*/false),
655+
arrow::field("attrs", arrow::utf8()));
656+
auto write_element_type = arrow::struct_({
657+
arrow::field("key", arrow::utf8()),
658+
arrow::field("attrs", write_attrs_type),
659+
arrow::field("updated_at", arrow::timestamp(arrow::TimeUnit::MICRO, timezone)),
660+
});
661+
auto write_schema = arrow::schema(
662+
{arrow::field("annotations", arrow::list(arrow::field("element", write_element_type)))});
663+
664+
const std::string data_json = R"([
665+
[[ ["ann-1", [["source", "model"]], "2026-07-16 12:00:00.000001"] ]],
666+
[[ ["ann-2", [], "2026-07-16 12:00:00.000002"],
667+
["ann-3", null, null] ]],
668+
[null]
669+
])";
670+
auto write_array = std::dynamic_pointer_cast<arrow::StructArray>(
671+
arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(write_schema->fields()), data_json)
672+
.ValueOrDie());
673+
WriteArray(file_path_, write_array, write_schema, /*write_batch_size=*/write_array->length(),
674+
/*enable_dictionary=*/false, /*max_row_group_length=*/write_array->length());
675+
676+
auto read_element_type = arrow::struct_({
677+
arrow::field("key", arrow::utf8()),
678+
arrow::field("attrs", arrow::map(arrow::utf8(), arrow::utf8())),
679+
arrow::field("updated_at", arrow::timestamp(arrow::TimeUnit::MICRO, timezone)),
680+
});
681+
auto read_schema = arrow::schema(
682+
{arrow::field("annotations", arrow::list(arrow::field("element", read_element_type)))});
683+
auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
684+
arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(read_schema->fields()), data_json)
685+
.ValueOrDie());
686+
687+
auto parquet_batch_reader =
688+
PrepareParquetFileBatchReader(file_path_, read_schema, /*predicate=*/nullptr,
689+
/*selection_bitmap=*/std::nullopt, /*batch_size=*/2);
690+
ASSERT_OK_AND_ASSIGN(auto result_array, paimon::test::ReadResultCollector::CollectResult(
691+
parquet_batch_reader.get()));
692+
auto expected_chunked_array = arrow::ChunkedArray::Make({expected_array}).ValueOrDie();
693+
ASSERT_TRUE(result_array->Equals(expected_chunked_array))
694+
<< "expected: " << expected_chunked_array->ToString()
695+
<< "\nactual: " << result_array->ToString();
696+
697+
auto projected_element_type = arrow::struct_({
698+
arrow::field("key", arrow::utf8()),
699+
arrow::field("attrs", arrow::map(arrow::utf8(), arrow::utf8())),
700+
});
701+
auto partial_read_schema = arrow::schema({arrow::field(
702+
"annotations", arrow::list(arrow::field("element", projected_element_type)))});
703+
auto c_partial_read_schema = std::make_unique<ArrowSchema>();
704+
ASSERT_TRUE(arrow::ExportSchema(*partial_read_schema, c_partial_read_schema.get()).ok());
705+
ASSERT_NOK_WITH_MSG(
706+
parquet_batch_reader->SetReadSchema(c_partial_read_schema.get(), /*predicate=*/nullptr,
707+
/*selection_bitmap=*/std::nullopt),
708+
"Parquet does not support partial projection inside list/map");
709+
710+
auto mismatched_element_type = arrow::struct_({
711+
arrow::field("key", arrow::utf8()),
712+
arrow::field("attrs", arrow::map(arrow::utf8(), arrow::utf8())),
713+
arrow::field("updated_at", arrow::utf8()),
714+
});
715+
auto mismatched_read_schema = arrow::schema({arrow::field(
716+
"annotations", arrow::list(arrow::field("element", mismatched_element_type)))});
717+
auto c_mismatched_read_schema = std::make_unique<ArrowSchema>();
718+
ASSERT_TRUE(arrow::ExportSchema(*mismatched_read_schema, c_mismatched_read_schema.get()).ok());
719+
ASSERT_NOK_WITH_MSG(
720+
parquet_batch_reader->SetReadSchema(c_mismatched_read_schema.get(), /*predicate=*/nullptr,
721+
/*selection_bitmap=*/std::nullopt),
722+
"Parquet does not support partial projection inside list/map");
723+
724+
auto unsupported_timestamp_element_type = arrow::struct_({
725+
arrow::field("key", arrow::utf8()),
726+
arrow::field("attrs", arrow::map(arrow::utf8(), arrow::utf8())),
727+
arrow::field("updated_at", arrow::timestamp(arrow::TimeUnit::NANO, timezone)),
728+
});
729+
auto unsupported_timestamp_schema = arrow::schema({arrow::field(
730+
"annotations", arrow::list(arrow::field("element", unsupported_timestamp_element_type)))});
731+
auto c_unsupported_timestamp_schema = std::make_unique<ArrowSchema>();
732+
ASSERT_TRUE(
733+
arrow::ExportSchema(*unsupported_timestamp_schema, c_unsupported_timestamp_schema.get())
734+
.ok());
735+
ASSERT_NOK_WITH_MSG(parquet_batch_reader->SetReadSchema(c_unsupported_timestamp_schema.get(),
736+
/*predicate=*/nullptr,
737+
/*selection_bitmap=*/std::nullopt),
738+
"Parquet does not support partial projection inside list/map");
739+
}
740+
741+
TEST_F(ParquetFileBatchReaderTest, TestNestedTimestampSecondReadFromMilliFile) {
742+
const std::string timezone = "Asia/Shanghai";
743+
paimon::test::TimezoneGuard timezone_guard(timezone);
744+
745+
// Parquet has no second-precision timestamp, so the writer stores second timestamps as
746+
// milliseconds. Reading them back with a second-precision schema must cast milli to second,
747+
// including for timestamp leaves nested inside list/struct/map.
748+
auto event_type = arrow::struct_({
749+
arrow::field("name", arrow::utf8()),
750+
arrow::field("ts_sec", arrow::timestamp(arrow::TimeUnit::SECOND)),
751+
arrow::field("ts_tz_sec", arrow::timestamp(arrow::TimeUnit::SECOND, timezone)),
752+
});
753+
auto schema = arrow::schema({
754+
arrow::field("events", arrow::list(arrow::field("element", event_type))),
755+
arrow::field("marks", arrow::map(arrow::utf8(), arrow::timestamp(arrow::TimeUnit::SECOND))),
756+
});
757+
758+
const std::string data_json = R"([
759+
[[ ["e-1", "2026-07-16 12:00:01", "2026-07-16 12:00:02"] ],
760+
[["begin", "2026-07-16 12:00:03"]]],
761+
[[ ["e-2", "2026-07-16 12:00:04", null],
762+
["e-3", null, "2026-07-16 12:00:05"] ], []],
763+
[[null], null]
764+
])";
765+
auto write_array = std::dynamic_pointer_cast<arrow::StructArray>(
766+
arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(schema->fields()), data_json)
767+
.ValueOrDie());
768+
WriteArray(file_path_, write_array, schema, /*write_batch_size=*/write_array->length(),
769+
/*enable_dictionary=*/false, /*max_row_group_length=*/write_array->length());
770+
771+
auto parquet_batch_reader =
772+
PrepareParquetFileBatchReader(file_path_, schema, /*predicate=*/nullptr,
773+
/*selection_bitmap=*/std::nullopt, /*batch_size=*/2);
774+
775+
// The nested second timestamps are physically stored as milliseconds in the file.
776+
ASSERT_OK_AND_ASSIGN(auto c_file_schema, parquet_batch_reader->GetFileSchema());
777+
auto file_schema = arrow::ImportSchema(c_file_schema.get()).ValueOr(nullptr);
778+
ASSERT_TRUE(file_schema);
779+
auto file_event_type =
780+
static_cast<const arrow::ListType&>(*file_schema->field(0)->type()).value_type();
781+
ASSERT_EQ(arrow::Type::STRUCT, file_event_type->id());
782+
ASSERT_EQ(arrow::TimeUnit::MILLI,
783+
static_cast<const arrow::TimestampType&>(*file_event_type->field(1)->type()).unit());
784+
ASSERT_EQ(arrow::TimeUnit::MILLI,
785+
static_cast<const arrow::TimestampType&>(*file_event_type->field(2)->type()).unit());
786+
auto file_mark_type =
787+
static_cast<const arrow::MapType&>(*file_schema->field(1)->type()).item_type();
788+
ASSERT_EQ(arrow::TimeUnit::MILLI,
789+
static_cast<const arrow::TimestampType&>(*file_mark_type).unit());
790+
791+
ASSERT_OK_AND_ASSIGN(
792+
std::shared_ptr<arrow::ChunkedArray> result_array,
793+
paimon::test::ReadResultCollector::CollectResult(parquet_batch_reader.get()));
794+
auto expected_array = arrow::ChunkedArray::Make({write_array}).ValueOrDie();
795+
ASSERT_TRUE(result_array->Equals(expected_array))
796+
<< "expected: " << expected_array->ToString() << "\nactual: " << result_array->ToString();
797+
}
798+
649799
TEST_F(ParquetFileBatchReaderTest, TestGetFileSchemaWithFieldId) {
650800
std::string file_name = paimon::test::GetDataDir() +
651801
"parquet/parquet_append_table.db/parquet_append_table/bucket-0/"

0 commit comments

Comments
 (0)