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

morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new 2db07c83e76 [test](external) Port the branch-4.x Arrow convertor and 
Parquet timestamp tests (#68963)
2db07c83e76 is described below

commit 2db07c83e76114af4648e1a25461b529bddc2789
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Tue Oct 13 23:09:46 2026 +0800

    [test](external) Port the branch-4.x Arrow convertor and Parquet timestamp 
tests (#68963)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #68381, #68441, #68396, #68883, #68516
    
    Problem Summary:
    
    **In short.** This ports the tests of branch-4.x #68381 (explicit,
    schema-owned Arrow block convertors) and #68441 (aligned Parquet and ORC
    timestamp semantics) that did not come to master with their behavior.
    master has the production code through #68396 and #68883, and through
    #65446 and #68516; a hunk-by-hunk comparison found no gap there. This PR
    changes no production code.
    
    **Background.**
    
    - #68381 moved format-specific Arrow writing into per-format convertors.
    For Variant, Iceberg writes `arrow.parquet.variant` extension arrays
    with a `metadata, value` storage struct, and Paimon writes its own
    `value, metadata` struct. #68883 brought the Variant hooks of both
    convertors to master.
    - #68441 made the Parquet and ORC timestamp semantics explicit: Paimon
    marks every timestamp field with `timestamp_is_adjusted_to_utc` (INT96
    alone cannot tell a wall clock from an instant), a Hive write always
    sends `hive.parquet.time-zone` (empty means wall clock), a Hudi MOR scan
    says up front whether a slice may be read as native Parquet, ALTER
    CATALOG validates the time zone, and only connectors that use the Hive
    INT96 time zone receive it.
    
    **The problem, and what it cost.** These behaviors were not pinned on
    master:
    
    | Behavior | Tested on master before this PR |
    |---|---|
    | Iceberg Variant through `IcebergArrowBlockConvertor`: extension array,
    encoded bytes unchanged, outer NULLs, Parquet Variant logical type and
    field ids, at the top level and nested in LIST, MAP and STRUCT | no;
    only a one-value round trip of the SerDe hook |
    | `PaimonArrowBlockConvertor`: bytes and outer NULLs for encoded and
    typed Variants, `ARRAY<VARIANT>` through the Paimon SerDe, rejection of
    an unsupported primitive at the Paimon hook | only through the generic
    Arrow path, or one row without NULLs and without byte checks |
    | ALTER CATALOG rejects an invalid IANA `hive.parquet.time-zone` and
    accepts `+05:45` | only the rejection of `CST` |
    | A Hive write without `hive.parquet.time-zone` still sends an empty
    time zone | only an explicit `""` |
    | Only a Parquet Hudi MOR table advertises native Parquet slices |
    Parquet only; no ORC or HFile case |
    | Paimon sets `timestamp_is_adjusted_to_utc`, not only its value,
    including on nested timestamps | the value only, at the top level; the
    wall-clock check also passes when the flag is missing |
    | A connector that does not use the Hive INT96 time zone gets none from
    an HMS catalog that sets one | only the opposite case |
    
    **How this PR fixes it.**
    
    - `DataTypeSerDeArrowTest`: `IcebergVariantExtensionAndParquetSchema`
    and `NestedIcebergVariantExtensionsAndParquetSchema`, as in branch-4.x.
    They came with #66620 (Iceberg Variant writes), but they build the Arrow
    schema themselves and test only code master already has.
    - `PaimonArrowBlockConvertorTest` (in
    `data_type_variant_v2_serde_output_test.cpp`):
    `BinaryStructPreservesEncodedAndTypedBytesAndOuterNulls`,
    `NestedArrayUsesPaimonSerdeRecursively` and
    `BinaryStructRejectsUnsupportedPaimonPrimitive`, with master's error
    text for the last one. master's generic-path versions stay.
    -
    
`HMSExternalCatalogParquetTimeZoneTest#testAlterValidatesCandidateTimezoneBeforePublishing`,
    `HiveWritePlanProviderTest` (absent property, `UTC`, `+05:45`),
    `HudiForceJniTest` (ORC and HFile MOR tables),
    `PaimonScanPlanProviderTest#buildSchemaInfoCarriesTimestampSemantics`
    (set flag, nested timestamp, other columns unflagged) and
    
`PluginDrivenScanNodeScanProviderSelectionTest#connectorWithoutHiveInt96SemanticsIgnoresCatalogParquetTimezone`.
    
    Not ported: tests of branch-4.x-only code
    (`NonLocalizableInt32Predicate` from #66802, the `{1,2,3,4}` expectation
    master changed on purpose in #66399, VARBINARY mapping of file TVFs),
    and the Iceberg Variant writer tests that need the Iceberg Variant write
    port (`VParquetWriterTest` Variant cases and the Variant assertions of
    `IcebergBuildsItsOwnSchemaAndMetadata`).
    
    **Results.** The BE suites pass (42 tests, 5 of them new) and the FE
    classes pass. Each new FE assertion fails when the check it guards is
    removed:
    
    | Change to the code under test | Test that fails |
    |---|---|
    | ALTER CATALOG skips the time zone check of the candidate properties |
    `testAlterValidatesCandidateTimezoneBeforePublishing` |
    | A Hive write sets the time zone only when the property is present |
    `parquetTimezoneDistinguishesExplicitWallClockFromOldCoordinator`
    (absent property) |
    | Hudi advertises native Parquet for any input format |
    `lazyMorScanAdvertisesNativeParquetBeforeRangesArrive` (ORC) |
    | Paimon leaves `timestamp_is_adjusted_to_utc` unset for wall-clock
    timestamps | `buildSchemaInfoCarriesTimestampSemantics` |
    | The scan node passes the catalog's Hive time zone without asking the
    provider |
    `connectorWithoutHiveInt96SemanticsIgnoresCatalogParquetTimezone` |
    
    Co-authored-by: Gabriel <[email protected]>
    Co-authored-by: daidai <[email protected]>
    Co-authored-by: Socrates <[email protected]>
---
 .../data_type_serde/data_type_serde_arrow_test.cpp | 255 +++++++++++++++++++++
 .../data_type_variant_v2_serde_output_test.cpp     | 118 ++++++++++
 .../connector/hive/HiveWritePlanProviderTest.java  |  14 +-
 .../doris/connector/hudi/HudiForceJniTest.java     |  27 ++-
 .../paimon/PaimonScanPlanProviderTest.java         |  27 ++-
 .../HMSExternalCatalogParquetTimeZoneTest.java     |  15 ++
 ...ginDrivenScanNodeScanProviderSelectionTest.java |  28 +++
 7 files changed, 468 insertions(+), 16 deletions(-)

diff --git a/be/test/core/data_type_serde/data_type_serde_arrow_test.cpp 
b/be/test/core/data_type_serde/data_type_serde_arrow_test.cpp
index b3b0d0fd4ab..c32d552788d 100644
--- a/be/test/core/data_type_serde/data_type_serde_arrow_test.cpp
+++ b/be/test/core/data_type_serde/data_type_serde_arrow_test.cpp
@@ -22,6 +22,9 @@
 #include <arrow/array/builder_decimal.h>
 #include <arrow/array/builder_nested.h>
 #include <arrow/array/builder_primitive.h>
+#include <arrow/extension/parquet_variant.h>
+#include <arrow/extension_type.h>
+#include <arrow/io/memory.h>
 #include <arrow/record_batch.h>
 #include <arrow/status.h>
 #include <arrow/type.h>
@@ -35,6 +38,8 @@
 #include <gtest/gtest-message.h>
 #include <gtest/gtest-test-part.h>
 #include <gtest/gtest.h>
+#include <parquet/api/reader.h>
+#include <parquet/arrow/writer.h>
 
 #include <array>
 #include <chrono>
@@ -59,6 +64,7 @@
 #include "core/column/column_struct.h"
 #include "core/column/column_varbinary.h"
 #include "core/column/column_vector.h"
+#include "core/column/variant_v2/column_variant_v2.h"
 #include "core/data_type/common_data_type_serder_test.h"
 #include "core/data_type/data_type.h"
 #include "core/data_type/data_type_array.h"
@@ -88,6 +94,7 @@
 #include "core/value/vdatetime_value.h"
 #include "exec/common/arrow_column_to_doris_column.h"
 #include "exprs/function/cast/cast_to_datetimev2_impl.hpp"
+#include "exprs/function/parse/variant_string_parse.h"
 #include "format/arrow/arrow_block_convertor.h"
 #include "format/arrow/arrow_row_batch.h"
 #include "format/table/iceberg/iceberg_arrow_block_convertor.h"
@@ -948,6 +955,254 @@ TEST(DataTypeSerDeArrowTest, 
PlainArrowConverterDoesNotInferIcebergUuid) {
     EXPECT_NE(std::string::npos, status.to_string().find("Plain Arrow writer 
is not bound"));
 }
 
+TEST(DataTypeSerDeArrowTest, IcebergVariantExtensionAndParquetSchema) {
+    auto make_variant_column = []() {
+        JsonStringToVariantEncoder encoder({.max_json_key_length = 1024,
+                                            .throw_on_invalid_json = true,
+                                            .check_duplicate_json_path = 
false});
+        for (std::string_view json :
+             {std::string_view {R"({"name":"doris","n":1})"}, std::string_view 
{"null"},
+              std::string_view {R"([1,true,"x"])"}}) {
+            encoder.add_json({json.data(), json.size()});
+        }
+        auto column = ColumnVariantV2::create();
+        column->insert_encoded_batch(encoder.finish_batch());
+        return column;
+    };
+
+    auto variant_column = make_variant_column();
+    std::vector<std::string> expected_metadata;
+    std::vector<std::string> expected_values;
+    const auto source_view = variant_column->read_view();
+    for (size_t row = 0; row < variant_column->size(); ++row) {
+        const VariantRef value = source_view.value_at(row);
+        expected_metadata.emplace_back(value.metadata.data, 
value.metadata.size);
+        expected_values.emplace_back(value.value.data, value.value.size);
+    }
+
+    auto null_map = ColumnUInt8::create();
+    null_map->get_data().assign({0, 0, 1});
+    auto nullable_variant = ColumnNullable::create(std::move(variant_column), 
std::move(null_map));
+    DataTypePtr variant_type = std::make_shared<DataTypeVariantV2>();
+    DataTypePtr nullable_variant_type = make_nullable(variant_type);
+
+    Block block;
+    block.insert(
+            ColumnWithTypeAndName(nullable_variant->get_ptr(), 
nullable_variant_type, "payload"));
+
+    auto variant_storage = arrow::struct_({
+            arrow::field("metadata", arrow::binary(), false),
+            arrow::field("value", arrow::binary(), false),
+    });
+    auto arrow_variant = arrow::extension::variant(variant_storage);
+    auto field_id_21 = arrow::KeyValueMetadata::Make({"PARQUET:field_id"}, 
{"21"});
+    auto schema = arrow::schema({
+            arrow::field("payload", arrow_variant, true, field_id_21),
+    });
+
+    std::shared_ptr<arrow::RecordBatch> record_batch;
+    cctz::time_zone default_timezone;
+    Status status = convert_to_arrow_batch_for_test(
+            block, schema, arrow::default_memory_pool(), &record_batch, 
default_timezone, 0,
+            block.rows(), 
make_arrow_convertor<iceberg::IcebergArrowBlockConvertor>);
+    ASSERT_TRUE(status.ok()) << status;
+    ASSERT_NE(nullptr, record_batch);
+    ASSERT_TRUE(record_batch->ValidateFull().ok());
+
+    auto extension_array =
+            
std::dynamic_pointer_cast<arrow::ExtensionArray>(record_batch->column(0));
+    ASSERT_NE(nullptr, extension_array);
+    EXPECT_EQ("arrow.parquet.variant", 
extension_array->extension_type()->extension_name());
+    EXPECT_FALSE(extension_array->IsNull(0));
+    EXPECT_FALSE(extension_array->IsNull(1));
+    EXPECT_TRUE(extension_array->IsNull(2));
+    auto storage_array = 
std::static_pointer_cast<arrow::StructArray>(extension_array->storage());
+    auto metadata_array = 
std::static_pointer_cast<arrow::BinaryArray>(storage_array->field(0));
+    auto value_array = 
std::static_pointer_cast<arrow::BinaryArray>(storage_array->field(1));
+    for (int64_t row = 0; row < 2; ++row) {
+        EXPECT_EQ(expected_metadata[row], metadata_array->GetView(row));
+        EXPECT_EQ(expected_values[row], value_array->GetView(row));
+    }
+
+    auto sink_result = arrow::io::BufferOutputStream::Create();
+    ASSERT_TRUE(sink_result.ok()) << sink_result.status();
+    auto sink = std::move(sink_result).ValueUnsafe();
+    auto writer_result =
+            ::parquet::arrow::FileWriter::Open(*schema, 
arrow::default_memory_pool(), sink);
+    ASSERT_TRUE(writer_result.ok()) << writer_result.status();
+    auto writer = std::move(writer_result).ValueUnsafe();
+    ASSERT_TRUE(writer->WriteRecordBatch(*record_batch).ok());
+    ASSERT_TRUE(writer->Close().ok());
+    auto buffer_result = sink->Finish();
+    ASSERT_TRUE(buffer_result.ok()) << buffer_result.status();
+
+    auto reader = ::parquet::ParquetFileReader::Open(
+            
std::make_shared<arrow::io::BufferReader>(std::move(buffer_result).ValueUnsafe()));
+    const auto* root = reader->metadata()->schema()->group_node();
+    ASSERT_EQ(1, root->field_count());
+    const auto& payload_node = root->field(0);
+    ASSERT_NE(nullptr, payload_node->logical_type());
+    EXPECT_TRUE(payload_node->logical_type()->is_variant());
+    EXPECT_EQ(21, payload_node->field_id());
+    const auto& payload_group = static_cast<const 
::parquet::schema::GroupNode&>(*payload_node);
+    ASSERT_EQ(2, payload_group.field_count());
+    EXPECT_EQ("metadata", payload_group.field(0)->name());
+    EXPECT_EQ(-1, payload_group.field(0)->field_id());
+    EXPECT_EQ("value", payload_group.field(1)->name());
+    EXPECT_EQ(-1, payload_group.field(1)->field_id());
+}
+
+TEST(DataTypeSerDeArrowTest, NestedIcebergVariantExtensionsAndParquetSchema) {
+    auto make_variant_column = [](std::initializer_list<std::string_view> 
json_values) {
+        JsonStringToVariantEncoder encoder({.max_json_key_length = 1024,
+                                            .throw_on_invalid_json = true,
+                                            .check_duplicate_json_path = 
false});
+        for (std::string_view json : json_values) {
+            encoder.add_json({json.data(), json.size()});
+        }
+        auto column = ColumnVariantV2::create();
+        column->insert_encoded_batch(encoder.finish_batch());
+        return column;
+    };
+    auto make_null_map = [](std::initializer_list<uint8_t> values) {
+        auto null_map = ColumnUInt8::create();
+        null_map->get_data().assign(values);
+        return null_map;
+    };
+
+    DataTypePtr variant_type = std::make_shared<DataTypeVariantV2>();
+    DataTypePtr nullable_variant_type = make_nullable(variant_type);
+
+    auto array_values =
+            ColumnNullable::create(make_variant_column({R"({"kind":"array"})", 
"null", "{}", "7"}),
+                                   make_null_map({0, 0, 1, 0}));
+    auto array_offsets = ColumnArray::ColumnOffsets::create();
+    array_offsets->get_data().assign({3, 4});
+    auto array_column = ColumnArray::create(std::move(array_values), 
std::move(array_offsets));
+    DataTypePtr array_type = std::make_shared<DataTypeArray>(variant_type);
+
+    auto map_keys_data = ColumnString::create();
+    for (std::string_view key : {std::string_view {"object"}, std::string_view 
{"json_null"},
+                                 std::string_view {"sql_null"}}) {
+        map_keys_data->insert_data(key.data(), key.size());
+    }
+    auto map_keys = ColumnNullable::create(std::move(map_keys_data), 
make_null_map({0, 0, 0}));
+    auto map_values = ColumnNullable::create(
+            make_variant_column({R"({"kind":"map"})", "null", "{}"}), 
make_null_map({0, 0, 1}));
+    auto map_offsets = ColumnArray::ColumnOffsets::create();
+    map_offsets->get_data().assign({3, 3});
+    auto map_column =
+            ColumnMap::create(std::move(map_keys), std::move(map_values), 
std::move(map_offsets));
+    DataTypePtr map_type = std::make_shared<DataTypeMap>(
+            make_nullable(std::make_shared<DataTypeString>()), 
nullable_variant_type);
+
+    auto labels = ColumnString::create();
+    labels->insert_data("first", 5);
+    labels->insert_data("second", 6);
+    auto struct_payloads = ColumnNullable::create(
+            make_variant_column({R"({"kind":"struct"})", "{}"}), 
make_null_map({0, 1}));
+    MutableColumns struct_children;
+    struct_children.emplace_back(std::move(labels));
+    struct_children.emplace_back(std::move(struct_payloads));
+    auto struct_column = ColumnStruct::create(std::move(struct_children));
+    DataTypePtr struct_type = std::make_shared<DataTypeStruct>(
+            DataTypes {std::make_shared<DataTypeString>(), 
nullable_variant_type},
+            Strings {"label", "payload"});
+
+    Block block;
+    block.insert(ColumnWithTypeAndName(std::move(array_column), array_type, 
"events"));
+    block.insert(ColumnWithTypeAndName(std::move(map_column), map_type, 
"attrs"));
+    block.insert(ColumnWithTypeAndName(std::move(struct_column), struct_type, 
"info"));
+
+    auto field_id = [](int id) {
+        return arrow::KeyValueMetadata::Make({"PARQUET:field_id"}, 
{std::to_string(id)});
+    };
+    auto variant_storage = arrow::struct_({
+            arrow::field("metadata", arrow::binary(), false),
+            arrow::field("value", arrow::binary(), false),
+    });
+    auto arrow_variant = arrow::extension::variant(variant_storage);
+    auto schema = arrow::schema({
+            arrow::field("events",
+                         arrow::list(arrow::field("element", arrow_variant, 
true, field_id(31))),
+                         true, field_id(30)),
+            arrow::field("attrs",
+                         std::make_shared<arrow::MapType>(
+                                 arrow::field("key", arrow::utf8(), false, 
field_id(41)),
+                                 arrow::field("value", arrow_variant, true, 
field_id(42))),
+                         true, field_id(40)),
+            arrow::field("info",
+                         arrow::struct_({
+                                 arrow::field("label", arrow::utf8(), true, 
field_id(51)),
+                                 arrow::field("payload", arrow_variant, true, 
field_id(52)),
+                         }),
+                         true, field_id(50)),
+    });
+
+    std::shared_ptr<arrow::RecordBatch> record_batch;
+    cctz::time_zone default_timezone;
+    Status status = convert_to_arrow_batch_for_test(
+            block, schema, arrow::default_memory_pool(), &record_batch, 
default_timezone, 0,
+            block.rows(), 
make_arrow_convertor<iceberg::IcebergArrowBlockConvertor>);
+    ASSERT_TRUE(status.ok()) << status;
+    ASSERT_NE(nullptr, record_batch);
+    ASSERT_TRUE(record_batch->ValidateFull().ok()) << 
record_batch->ValidateFull();
+
+    auto events = 
std::static_pointer_cast<arrow::ListArray>(record_batch->column(0));
+    auto event_values = 
std::dynamic_pointer_cast<arrow::ExtensionArray>(events->values());
+    ASSERT_NE(nullptr, event_values);
+    EXPECT_EQ("arrow.parquet.variant", 
event_values->extension_type()->extension_name());
+    EXPECT_TRUE(event_values->IsNull(2));
+
+    auto attrs = 
std::static_pointer_cast<arrow::MapArray>(record_batch->column(1));
+    auto attr_values = 
std::dynamic_pointer_cast<arrow::ExtensionArray>(attrs->items());
+    ASSERT_NE(nullptr, attr_values);
+    EXPECT_EQ("arrow.parquet.variant", 
attr_values->extension_type()->extension_name());
+    EXPECT_TRUE(attr_values->IsNull(2));
+
+    auto info = 
std::static_pointer_cast<arrow::StructArray>(record_batch->column(2));
+    auto info_payload = 
std::dynamic_pointer_cast<arrow::ExtensionArray>(info->field(1));
+    ASSERT_NE(nullptr, info_payload);
+    EXPECT_EQ("arrow.parquet.variant", 
info_payload->extension_type()->extension_name());
+    EXPECT_TRUE(info_payload->IsNull(1));
+
+    auto sink_result = arrow::io::BufferOutputStream::Create();
+    ASSERT_TRUE(sink_result.ok()) << sink_result.status();
+    auto sink = std::move(sink_result).ValueUnsafe();
+    auto writer_result =
+            ::parquet::arrow::FileWriter::Open(*schema, 
arrow::default_memory_pool(), sink);
+    ASSERT_TRUE(writer_result.ok()) << writer_result.status();
+    auto writer = std::move(writer_result).ValueUnsafe();
+    ASSERT_TRUE(writer->WriteRecordBatch(*record_batch).ok());
+    ASSERT_TRUE(writer->Close().ok());
+    auto buffer_result = sink->Finish();
+    ASSERT_TRUE(buffer_result.ok()) << buffer_result.status();
+
+    auto reader = ::parquet::ParquetFileReader::Open(
+            
std::make_shared<arrow::io::BufferReader>(std::move(buffer_result).ValueUnsafe()));
+    const auto* root = reader->metadata()->schema()->group_node();
+    const auto& events_group = static_cast<const 
::parquet::schema::GroupNode&>(*root->field(0));
+    const auto& event_list =
+            static_cast<const 
::parquet::schema::GroupNode&>(*events_group.field(0));
+    const auto& event_variant = event_list.field(0);
+    ASSERT_NE(nullptr, event_variant->logical_type());
+    EXPECT_TRUE(event_variant->logical_type()->is_variant());
+    EXPECT_EQ(31, event_variant->field_id());
+
+    const auto& attrs_group = static_cast<const 
::parquet::schema::GroupNode&>(*root->field(1));
+    const auto& key_value = static_cast<const 
::parquet::schema::GroupNode&>(*attrs_group.field(0));
+    const auto& map_variant = key_value.field(1);
+    ASSERT_NE(nullptr, map_variant->logical_type());
+    EXPECT_TRUE(map_variant->logical_type()->is_variant());
+    EXPECT_EQ(42, map_variant->field_id());
+
+    const auto& info_group = static_cast<const 
::parquet::schema::GroupNode&>(*root->field(2));
+    const auto& struct_variant = info_group.field(1);
+    ASSERT_NE(nullptr, struct_variant->logical_type());
+    EXPECT_TRUE(struct_variant->logical_type()->is_variant());
+    EXPECT_EQ(52, struct_variant->field_id());
+}
+
 TEST(DataTypeSerDeArrowTest, NestedIcebergUuidStringToFixedSizeBinary) {
     auto block = std::make_shared<Block>();
     DataTypePtr data_type = std::make_shared<DataTypeStruct>(
diff --git 
a/be/test/core/data_type_serde/data_type_variant_v2_serde_output_test.cpp 
b/be/test/core/data_type_serde/data_type_variant_v2_serde_output_test.cpp
index 7f7177a86b9..10697c9645c 100644
--- a/be/test/core/data_type_serde/data_type_variant_v2_serde_output_test.cpp
+++ b/be/test/core/data_type_serde/data_type_variant_v2_serde_output_test.cpp
@@ -32,19 +32,23 @@
 
 #include "core/arena.h"
 #include "core/assert_cast.h"
+#include "core/column/column_array.h"
 #include "core/column/column_const.h"
 #include "core/column/column_nullable.h"
 #include "core/column/column_string.h"
 #include "core/column/column_vector.h"
 #include "core/column/variant_v2/column_variant_v2.h"
+#include "core/data_type/data_type_array.h"
 #include "core/data_type/data_type_date_or_datetime_v2.h"
 #include "core/data_type/data_type_number.h"
 #include "core/data_type/data_type_string.h"
+#include "core/data_type/data_type_variant_v2.h"
 #include "core/data_type_serde/data_type_nullable_serde.h"
 #include "core/data_type_serde/data_type_variant_v2_serde.h"
 #include "core/string_buffer.hpp"
 #include "core/value/variant/variant_parquet_encoding.h"
 #include "exprs/function/parse/variant_string_parse.h"
+#include "format/table/paimon/paimon_arrow_block_convertor.h"
 #include "util/mysql_row_buffer.h"
 
 namespace doris {
@@ -244,6 +248,41 @@ void expect_binary_variant_bytes(const 
DataTypeVariantV2SerDe& serde, const ICol
     }
 }
 
+void expect_paimon_variant_bytes(const IColumn& column, const ColumnVariantV2& 
encoded,
+                                 const NullMap* null_map = nullptr) {
+    auto builder = binary_variant_arrow_builder();
+    DataTypePtr type = std::make_shared<DataTypeVariantV2>();
+    const auto serde = type->get_serde();
+    const auto field = arrow::field("payload", binary_variant_arrow_type(), 
true);
+    const Status status =
+            paimon::PaimonArrowBlockConvertor(arrow::schema({field}), 
cctz::utc_time_zone())
+                    .write_column(type, *serde, column, null_map, field, 
builder.get(), 0,
+                                  column.size(), cctz::utc_time_zone());
+    ASSERT_TRUE(status.ok()) << status;
+
+    std::shared_ptr<arrow::Array> output;
+    ASSERT_TRUE(builder->Finish(&output).ok());
+    const auto& array = assert_cast<const arrow::StructArray&>(*output);
+    const auto& values = assert_cast<const 
arrow::BinaryArray&>(*array.field(0));
+    const auto& metadata = assert_cast<const 
arrow::BinaryArray&>(*array.field(1));
+    ASSERT_EQ(array.length(), static_cast<int64_t>(column.size()));
+    const auto view = encoded.read_view();
+    for (size_t row = 0; row < column.size(); ++row) {
+        const bool expected_null = null_map != nullptr && (*null_map)[row] != 
0;
+        EXPECT_EQ(array.IsNull(row), expected_null);
+        if (expected_null) {
+            continue;
+        }
+        const VariantRef expected = view.value_at(row);
+        const auto actual_value = values.GetView(row);
+        const auto actual_metadata = metadata.GetView(row);
+        EXPECT_EQ(std::string_view(actual_value.data(), actual_value.size()),
+                  std::string_view(expected.value.data, expected.value.size));
+        EXPECT_EQ(std::string_view(actual_metadata.data(), 
actual_metadata.size()),
+                  std::string_view(expected.metadata.data, 
expected.metadata.size));
+    }
+}
+
 // NOLINTNEXTLINE(readability-function-cognitive-complexity) -- GTest macros 
inflate the matrix.
 void expect_text_surfaces(const DataTypeVariantV2SerDe& serde, const IColumn& 
encoded,
                           const ColumnVariantV2& typed,
@@ -489,4 +528,83 @@ TEST(DataTypeVariantV2SerdeOutputTest, 
BinaryStructRejectsUnsupportedPrimitive)
     EXPECT_EQ(arrow_builder->length(), 0);
 }
 
+TEST(PaimonArrowBlockConvertorTest, 
BinaryStructPreservesEncodedAndTypedBytesAndOuterNulls) {
+    auto documents = encoded_json({R"({"a":[1,null,"x"]})", 
R"({"hidden":true})", "null"});
+    NullMap mask {0, 1, 0};
+    expect_paimon_variant_bytes(*documents, *documents, &mask);
+
+    auto typed = typed_strings(
+            {std::string_view("plain"), std::nullopt, 
std::string_view(R"({"text":"value"})")});
+    ColumnPtr encoded = encoded_copy(*typed);
+    expect_paimon_variant_bytes(*typed, assert_cast<const 
ColumnVariantV2&>(*encoded));
+    EXPECT_TRUE(typed->is_typed());
+}
+
+TEST(PaimonArrowBlockConvertorTest, NestedArrayUsesPaimonSerdeRecursively) {
+    auto variants = encoded_json({R"({"id":1})", R"({"hidden":true})", 
R"([true,"x"])", "null"});
+    const auto expected = variants->read_view();
+    auto offsets = ColumnArray::ColumnOffsets::create();
+    offsets->get_data().assign({2, 4});
+    // DataTypeArray always wraps its element type in Nullable, including 
VARIANT.
+    auto nulls = ColumnUInt8::create(4, 0);
+    nulls->get_data()[1] = 1;
+    auto elements = ColumnNullable::create(std::move(variants), 
std::move(nulls));
+    auto array = ColumnArray::create(std::move(elements), std::move(offsets));
+
+    DataTypePtr variant_type = std::make_shared<DataTypeVariantV2>();
+    DataTypePtr array_type = std::make_shared<DataTypeArray>(variant_type);
+    auto arrow_type = arrow::list(binary_variant_arrow_type());
+    auto field = arrow::field("payloads", arrow_type, false);
+    std::unique_ptr<arrow::ArrayBuilder> builder;
+    ASSERT_TRUE(arrow::MakeBuilder(arrow::default_memory_pool(), arrow_type, 
&builder).ok());
+
+    const auto serde = array_type->get_serde();
+    Status status = paimon::PaimonArrowBlockConvertor(arrow::schema({field}), 
cctz::utc_time_zone())
+                            .write_column(array_type, *serde, *array, nullptr, 
field, builder.get(),
+                                          0, array->size(), 
cctz::utc_time_zone());
+    ASSERT_TRUE(status.ok()) << status;
+
+    std::shared_ptr<arrow::Array> output;
+    ASSERT_TRUE(builder->Finish(&output).ok());
+    const auto& lists = assert_cast<const arrow::ListArray&>(*output);
+    const auto& structs = assert_cast<const 
arrow::StructArray&>(*lists.values());
+    const auto& values = assert_cast<const 
arrow::BinaryArray&>(*structs.field(0));
+    const auto& metadata = assert_cast<const 
arrow::BinaryArray&>(*structs.field(1));
+    ASSERT_TRUE(output->ValidateFull().ok());
+    ASSERT_EQ(4, structs.length());
+    EXPECT_TRUE(structs.IsNull(1));
+    EXPECT_FALSE(structs.IsNull(3));
+    for (size_t row : {0, 2, 3}) {
+        const VariantRef value = expected.value_at(row);
+        const auto actual_value = values.GetView(row);
+        const auto actual_metadata = metadata.GetView(row);
+        EXPECT_EQ(std::string_view(actual_value.data(), actual_value.size()),
+                  std::string_view(value.value.data, value.value.size));
+        EXPECT_EQ(std::string_view(actual_metadata.data(), 
actual_metadata.size()),
+                  std::string_view(value.metadata.data, value.metadata.size));
+    }
+}
+
+TEST(PaimonArrowBlockConvertorTest, 
BinaryStructRejectsUnsupportedPaimonPrimitive) {
+    VariantBatchBuilder builder(VariantBatchBuilder::ReserveHint {.rows = 1});
+    auto row = builder.begin_row();
+    row.add_time_ntz_micros(1'500'000);
+    row.finish();
+    auto encoded = ColumnVariantV2::create();
+    encoded->insert_encoded_batch(builder.finish_batch());
+    auto arrow_builder = binary_variant_arrow_builder();
+    DataTypePtr type = std::make_shared<DataTypeVariantV2>();
+    const auto serde = type->get_serde();
+    const auto field = arrow::field("payload", binary_variant_arrow_type(), 
true);
+    const Status status =
+            paimon::PaimonArrowBlockConvertor(arrow::schema({field}), 
cctz::utc_time_zone())
+                    .write_column(type, *serde, *encoded, nullptr, field, 
arrow_builder.get(), 0,
+                                  encoded->size(), cctz::utc_time_zone());
+    EXPECT_EQ(status.code(), ErrorCode::NOT_IMPLEMENTED_ERROR);
+    EXPECT_NE(status.to_string().find(
+                      "Binary Variant V2 Arrow encoding does not support 
primitive id 17"),
+              std::string::npos);
+    EXPECT_EQ(arrow_builder->length(), 0);
+}
+
 } // namespace doris
diff --git 
a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveWritePlanProviderTest.java
 
b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveWritePlanProviderTest.java
index e4594916d66..b98697a085e 100644
--- 
a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveWritePlanProviderTest.java
+++ 
b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveWritePlanProviderTest.java
@@ -135,18 +135,24 @@ public class HiveWritePlanProviderTest {
 
     @Test
     public void 
parquetTimezoneDistinguishesExplicitWallClockFromOldCoordinator() {
-        for (String zone : Arrays.asList("", "8:00", "Asia/Shanghai")) {
+        // null leaves the property out of the catalog entirely.
+        for (String zone : Arrays.asList(null, "", "8:00", "UTC", 
"Asia/Shanghai", "+05:45")) {
             RecordingHmsClient client = new RecordingHmsClient();
             client.table = 
tableBuilder().inputFormat(PARQUET_INPUT_FORMAT).build();
             RecordingConnectorContext ctx = new RecordingConnectorContext();
             Map<String, String> properties = new 
HashMap<>(HiveTestProperties.minimal().getRaw());
-            properties.put("hive.parquet.time-zone", zone);
+            if (zone != null) {
+                properties.put("hive.parquet.time-zone", zone);
+            }
             HiveWritePlanProvider provider = new HiveWritePlanProvider(client, 
HiveCatalogProperties.of(properties), ctx);
             THiveTableSink sink = provider.planWrite(sessionFor(client, ctx,
                     Collections.singletonMap("time_zone", 
"America/Los_Angeles")), handle())
                     .getDataSink().getHiveTableSink();
-            Assertions.assertTrue(sink.isSetHiveParquetTimeZone());
-            Assertions.assertEquals(zone.equals("8:00") ? "+08:00" : zone, 
sink.getHiveParquetTimeZone());
+            // An absent property means wall clock exactly like an explicit 
"": the field must still be set, or
+            // a backend falls back to the session time zone that an older 
coordinator implied.
+            Assertions.assertTrue(sink.isSetHiveParquetTimeZone(), 
String.valueOf(zone));
+            String expected = zone == null ? "" : zone.equals("8:00") ? 
"+08:00" : zone;
+            Assertions.assertEquals(expected, sink.getHiveParquetTimeZone(), 
String.valueOf(zone));
             Assertions.assertTrue(sink.isSupportsDeferredAzureMultipart());
         }
     }
diff --git 
a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiForceJniTest.java
 
b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiForceJniTest.java
index fed1a9cbb76..194b3203395 100644
--- 
a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiForceJniTest.java
+++ 
b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiForceJniTest.java
@@ -41,15 +41,24 @@ public class HudiForceJniTest {
     @Test
     public void lazyMorScanAdvertisesNativeParquetBeforeRangesArrive() {
         HudiScanPlanProvider provider = new 
HudiScanPlanProvider(Collections.emptyMap(), null);
-        HudiTableHandle table = new HudiTableHandle.Builder("db", "t", 
"s3://bucket/t", "MERGE_ON_READ")
-                
.inputFormat("org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat").build();
-        for (boolean forceJni : Arrays.asList(false, true)) {
-            Map<String, String> properties = provider.getScanNodeProperties(
-                    
sessionWithProps(Collections.singletonMap("force_jni_scanner", 
Boolean.toString(forceJni))),
-                    table, Collections.emptyList(), Optional.empty());
-            TFileScanRangeParams params = new TFileScanRangeParams();
-            provider.populateScanLevelParams(params, properties);
-            Assertions.assertEquals(!forceJni, 
params.isContainsNativeParquet());
+        // Only a Parquet table can hold a log-free native Parquet slice; ORC 
and HFile MOR tables must not
+        // advertise the contract (branch-4.x #68441).
+        for (String inputFormat : 
Arrays.asList("org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat",
+                
"org.apache.hudi.hadoop.realtime.HoodieParquetRealtimeInputFormat",
+                "org.apache.hudi.hadoop.realtime.HoodieOrcRealtimeInputFormat",
+                
"org.apache.hudi.hadoop.realtime.HoodieHFileRealtimeInputFormat")) {
+            HudiTableHandle table = new HudiTableHandle.Builder("db", "t", 
"s3://bucket/t", "MERGE_ON_READ")
+                    .inputFormat(inputFormat).build();
+            boolean parquet = inputFormat.contains("Parquet");
+            for (boolean forceJni : Arrays.asList(false, true)) {
+                Map<String, String> properties = 
provider.getScanNodeProperties(
+                        
sessionWithProps(Collections.singletonMap("force_jni_scanner", 
Boolean.toString(forceJni))),
+                        table, Collections.emptyList(), Optional.empty());
+                TFileScanRangeParams params = new TFileScanRangeParams();
+                provider.populateScanLevelParams(params, properties);
+                Assertions.assertEquals(!forceJni && parquet, 
params.isContainsNativeParquet(),
+                        inputFormat + ", force_jni_scanner=" + forceJni);
+            }
         }
     }
 
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java
 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java
index 9223305efbb..3bda0c5eb50 100644
--- 
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java
+++ 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java
@@ -3796,13 +3796,34 @@ public class PaimonScanPlanProviderTest {
     public void buildSchemaInfoCarriesTimestampSemantics() {
         List<DataField> fields = Arrays.asList(
                 new DataField(7, "ts", DataTypes.TIMESTAMP(9)),
-                new DataField(8, "ts_ltz", 
DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(9)));
+                new DataField(8, "ts_ltz", 
DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(9)),
+                new DataField(9, "nested", DataTypes.ROW(DataTypes.FIELD(10, 
"events",
+                        DataTypes.ARRAY(DataTypes.MAP(DataTypes.STRING(), 
DataTypes.ROW(
+                                DataTypes.FIELD(11, "at", 
DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(9)))))))),
+                new DataField(12, "id", DataTypes.INT()));
 
         List<TFieldPtr> top = PaimonScanPlanProvider.buildSchemaInfo(3L, 
fields, false)
                 .getRootField().getFields();
 
-        
Assertions.assertFalse(top.get(0).getFieldPtr().isTimestampIsAdjustedToUtc());
-        
Assertions.assertTrue(top.get(1).getFieldPtr().isTimestampIsAdjustedToUtc());
+        // Paimon writes high-precision TIMESTAMP and TIMESTAMP_LTZ with the 
same unannotated INT96 encoding, so the
+        // flag must be set, false for the wall clock and true for the 
instant; a wall-clock check alone also passes
+        // when the flag is missing.
+        TField wallClock = top.get(0).getFieldPtr();
+        Assertions.assertTrue(wallClock.isSetTimestampIsAdjustedToUtc());
+        Assertions.assertFalse(wallClock.isTimestampIsAdjustedToUtc());
+        TField instant = top.get(1).getFieldPtr();
+        Assertions.assertTrue(instant.isSetTimestampIsAdjustedToUtc());
+        Assertions.assertTrue(instant.isTimestampIsAdjustedToUtc());
+        // The same holds for a timestamp nested in ROW, ARRAY, MAP and ROW 
(branch-4.x #68441).
+        TField nested = 
top.get(2).getFieldPtr().getNestedField().getStructField().getFields().get(0).getFieldPtr()
+                .getNestedField().getArrayField().getItemField().getFieldPtr()
+                .getNestedField().getMapField().getValueField().getFieldPtr()
+                
.getNestedField().getStructField().getFields().get(0).getFieldPtr();
+        Assertions.assertEquals("at", nested.getName());
+        Assertions.assertTrue(nested.isSetTimestampIsAdjustedToUtc());
+        Assertions.assertTrue(nested.isTimestampIsAdjustedToUtc());
+        // Other columns carry no flag.
+        
Assertions.assertFalse(top.get(3).getFieldPtr().isSetTimestampIsAdjustedToUtc());
     }
 
     @Test
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HMSExternalCatalogParquetTimeZoneTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HMSExternalCatalogParquetTimeZoneTest.java
index 02f35a51590..b15e4fa8019 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HMSExternalCatalogParquetTimeZoneTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HMSExternalCatalogParquetTimeZoneTest.java
@@ -91,4 +91,19 @@ public class HMSExternalCatalogParquetTimeZoneTest {
             
Assertions.assertTrue(exception.getMessage().contains(FileFormatConstants.PROP_HIVE_PARQUET_TIME_ZONE));
         }
     }
+
+    @Test
+    public void testAlterValidatesCandidateTimezoneBeforePublishing() throws 
Exception {
+        Map<String, String> current = baseProperties();
+        current.put(FileFormatConstants.PROP_HIVE_PARQUET_TIME_ZONE, "UTC");
+        PluginDrivenExternalCatalog catalog = catalog(current);
+        Map<String, String> updated = new HashMap<>();
+        updated.put(FileFormatConstants.PROP_HIVE_PARQUET_TIME_ZONE, 
"Not/A_Timezone");
+        // ALTER must validate its candidate map, not the still-valid live 
catalog properties.
+        Assertions.assertThrows(DdlException.class,
+                () -> catalog.validatePropertiesBeforeUpdate(current, 
updated));
+        Assertions.assertEquals("UTC", 
catalog.getProperties().get(FileFormatConstants.PROP_HIVE_PARQUET_TIME_ZONE));
+        updated.put(FileFormatConstants.PROP_HIVE_PARQUET_TIME_ZONE, "+05:45");
+        Assertions.assertTrue(catalog.validatePropertiesBeforeUpdate(current, 
updated));
+    }
 }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeScanProviderSelectionTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeScanProviderSelectionTest.java
index 6345c869de1..99b07330767 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeScanProviderSelectionTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeScanProviderSelectionTest.java
@@ -174,4 +174,32 @@ public class PluginDrivenScanNodeScanProviderSelectionTest 
{
         Assertions.assertEquals("Asia/Shanghai",
                 Deencapsulation.invoke(node, "getHiveParquetTimeZone"));
     }
+
+    @Test
+    public void 
connectorWithoutHiveInt96SemanticsIgnoresCatalogParquetTimezone() throws 
Exception {
+        Map<String, String> properties = new HashMap<>();
+        properties.put(FileFormatConstants.PROP_HIVE_PARQUET_TIME_ZONE, 
"Asia/Shanghai");
+        PluginDrivenExternalCatalog catalog = 
Mockito.mock(PluginDrivenExternalCatalog.class);
+        Mockito.when(catalog.getType()).thenReturn("hms");
+        Mockito.when(catalog.getProperties()).thenReturn(properties);
+        PluginDrivenExternalTable table =
+                Mockito.mock(PluginDrivenExternalTable.class, 
Mockito.CALLS_REAL_METHODS);
+        Deencapsulation.setField(table, "catalog", catalog);
+
+        ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class);
+        ConnectorScanPlanProvider provider = 
Mockito.mock(ConnectorScanPlanProvider.class);
+        
Mockito.when(provider.usesHiveParquetInt96TimeZone()).thenReturn(false);
+        Connector connector = Mockito.mock(Connector.class);
+        
Mockito.when(connector.getScanPlanProvider(handle)).thenReturn(provider);
+
+        TupleDescriptor descriptor = new TupleDescriptor(new TupleId(0));
+        descriptor.setTable(table);
+        PluginDrivenScanNode node = new PluginDrivenScanNode(
+                new PlanNodeId(0), descriptor, false, new SessionVariable(), 
ScanContext.EMPTY,
+                connector, Mockito.mock(ConnectorSession.class), handle);
+
+        // A Paimon or Iceberg table behind an HMS catalog keeps its own 
timestamp semantics: the catalog's Hive
+        // INT96 timezone must not reach a provider that does not use it 
(branch-4.x #68441).
+        Assertions.assertEquals("", Deencapsulation.invoke(node, 
"getHiveParquetTimeZone"));
+    }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to