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 8b0cafd9b80 [fix](arrow) correct the Flight SQL GetTables schema and 
the TIMESTAMPTZ arrow reader (#66344)
8b0cafd9b80 is described below

commit 8b0cafd9b800f9b9c5152b300586632c6d1c0011
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Sun Aug 2 08:11:13 2026 +0800

    [fix](arrow) correct the Flight SQL GetTables schema and the TIMESTAMPTZ 
arrow reader (#66344)
    
    ### What problem does this PR solve?
    
    Related Issue: #65615
    
    Problem Summary:
    
    Two places where Doris says one thing about an Arrow value and does
    another. Both were found
    while reading Doris over Arrow Flight SQL, and both reproduce on master
    on their own.
    
    **1. `CommandGetTables` describes types the batches do not carry**
    
    `FlightSqlSchemaHelper.getArrowType` is documented as mirroring
    `convert_to_arrow_type` in the
    backend -- the schema a client reads from `GetTables` is what it types
    its columns by. Two of the
    mappings had drifted from what BE actually writes:
    
    - A `DATEV2` column was described as `Date(MILLISECOND)` -- Arrow date64
    -- while BE writes
    `arrow::Date32Type`, a day number. A client that trusts the schema
    renders and compares the
      column as a datetime and then fails on the first batch with
      `not support convert to datetimev2 from arrow type: 16`.
    - A complex column was described with a placeholder child: an array's
    element type was the Null
    type, a map's pair was a bare list, a struct had no fields at all. An
    Arrow ARRAY/MAP/STRUCT
    carries its element types in its children and nowhere else, so this said
    "array of nothing"
    about every array in the catalog while BE emitted `ListType(item)`,
    `MapType(key, value)` and
      `StructType(fields)` in the data.
    
    `describeTables` already reports the whole tree --
    `Column.createChildrenColumn` even names an
    array's element `item` and a map's pair `key`/`value`, which is what
    Arrow calls them -- so the
    children are now built from it, recursively. A descriptor that reports
    no children keeps the old
    placeholders: a source that cannot describe its nested types is no worse
    off than before.
    
    **2. A TIMESTAMPTZ column read from Arrow was decoded by copying its
    bits**
    
    `DataTypeTimeStampTzSerDe` never overrode `read_column_from_arrow`, so
    the one it inherited from
    `DataTypeNumberSerDe<TYPE_TIMESTAMPTZ>` ran: its fixed-width path
    `memcpy`s the array's buffer
    straight into the column. Arrow hands it int64 EPOCH values; the column
    stores PACKED date/time
    values. Both are eight bytes wide, so the width check passes, the scan
    succeeds, and every row is
    silently wrong -- a Doris datetime read back over Arrow Flight SQL
    rendered as `+08:05` with no
    date in it.
    
    This is reachable today through the `remote_doris` catalog. Its schema
    is the remote table's
    `Column` objects deserialized verbatim
    (`RemoteDorisRestClient.parseColumns`), so a remote
    TIMESTAMPTZ column stays TIMESTAMPTZ locally; `RemoteDorisScanNode`
    plans the scan as
    `FORMAT_ARROW`; and `remote_doris_reader` materializes it through this
    serde. The remote side
    writes the epochs with the serde's own `write_column_to_arrow`, so both
    halves of that round trip
    are Doris code and the mismatch is entirely internal.
    
    The other Arrow readers -- `arrow_stream_reader` for the `arrow` file
    format,
    `python_udtf_function`, `paimon_cpp_reader` -- share the same serde and
    are covered by the same
    change; whether each of them can present a TIMESTAMPTZ column today is
    not something this PR
    claims either way.
    
    The reader converts by unit into the microseconds the column stores.
    NANO is divided rather than
    refused, since no Doris datetime type keeps sub-microsecond digits, and
    the division floors so a
    pre-1970 instant does not move forward by one microsecond. The value is
    read as an instant on the
    UTC line, the inverse of what `write_column_to_arrow` emits, so the
    round trip is exact.
    
    Two cases are deliberate. A null slot is not converted: the bytes under
    it are whatever the source
    left there, and running a garbage epoch through the range check would
    fail a batch whose rows are
    all well-formed. An Arrow type this serde cannot decode is an error
    rather than a fallback --
    accepting anything eight bytes wide is how the corruption above stayed
    invisible.
---
 .../data_type_timestamptz_serde.cpp                |  88 +++++++++
 .../data_type_serde/data_type_timestamptz_serde.h  |   5 +
 .../data_type_serde_timestamptz_test.cpp           |  85 +++++++++
 .../service/arrowflight/FlightSqlSchemaHelper.java | 103 +++++++---
 .../FlightSqlSchemaHelperArrowTypeTest.java        | 209 +++++++++++++++++++++
 5 files changed, 460 insertions(+), 30 deletions(-)

diff --git a/be/src/core/data_type_serde/data_type_timestamptz_serde.cpp 
b/be/src/core/data_type_serde/data_type_timestamptz_serde.cpp
index d1ae23835c7..e9968e0bc87 100644
--- a/be/src/core/data_type_serde/data_type_timestamptz_serde.cpp
+++ b/be/src/core/data_type_serde/data_type_timestamptz_serde.cpp
@@ -20,7 +20,9 @@
 #include <arrow/builder.h>
 #include <cctz/time_zone.h>
 
+#include "common/config.h"
 #include "core/data_type/primitive_type.h"
+#include "core/data_type_serde/arrow_validation.h"
 #include "core/data_type_serde/decoded_column_view.h"
 #include "core/data_type_serde/parquet_decode_source.h"
 #include "core/data_type_serde/parquet_timestamp.h"
@@ -357,6 +359,92 @@ Status 
DataTypeTimeStampTzSerDe::write_column_to_arrow(const IColumn& column,
     return Status::OK();
 }
 
+/**
+ * Reads an Arrow timestamp array into a TIMESTAMPTZ column.
+ *
+ * <p>Without this the base DataTypeNumberSerDe<TYPE_TIMESTAMPTZ> reader runs 
instead, and its
+ * fixed-width path memcpy's the array's int64 epoch values straight into the 
column -- whose element
+ * is a PACKED date/time value, not an epoch. Both are 8 bytes wide, so no 
check catches it: the scan
+ * succeeds and every row is silently wrong. That is why an unreadable Arrow 
type below is an error
+ * rather than a fallback.
+ *
+ * <p>The value is read as an instant on the UTC line, which is the inverse of 
what
+ * write_column_to_arrow emits (it converts with cctz::utc_time_zone(), not 
with ctz). ctz is
+ * therefore unused here: an Arrow timestamp's zone -- whether it names one or 
not -- describes how
+ * to DISPLAY the instant, and TIMESTAMPTZ stores the instant itself.
+ */
+Status DataTypeTimeStampTzSerDe::read_column_from_arrow(IColumn& column,
+                                                        const arrow::Array* 
arrow_array,
+                                                        int64_t start, int64_t 
end,
+                                                        const cctz::time_zone& 
ctz) const {
+    if (config::enable_arrow_input_validation) {
+        check_arrow_no_offset(*arrow_array);
+    }
+    if (arrow_array->type()->id() != arrow::Type::TIMESTAMP) {
+        LOG(WARNING) << "not support convert to timestamptz from arrow type:"
+                     << arrow_array->type()->id();
+        return Status::InternalError("not support convert to timestamptz from 
arrow type: {}",
+                                     arrow_array->type()->id());
+    }
+    const auto* concrete_array = assert_cast<const 
arrow::TimestampArray*>(arrow_array);
+    const auto type = 
std::static_pointer_cast<arrow::TimestampType>(arrow_array->type());
+    // Scale each unit to the microseconds the column stores. NANO is divided 
rather than refused:
+    // sub-microsecond precision is beyond what any Doris datetime type keeps, 
and rejecting the
+    // column over a digit would make whole tables unreadable.
+    int64_t multiplier = 1;
+    int64_t divisor = 1;
+    switch (type->unit()) {
+    case arrow::TimeUnit::type::SECOND:
+        multiplier = 1000000;
+        break;
+    case arrow::TimeUnit::type::MILLI:
+        multiplier = 1000;
+        break;
+    case arrow::TimeUnit::type::MICRO:
+        break;
+    case arrow::TimeUnit::type::NANO:
+        divisor = 1000;
+        break;
+    default:
+        LOG(WARNING) << "not support convert to timestamptz from time_unit:" 
<< type->unit();
+        return Status::InvalidArgument("not support convert to timestamptz 
from time_unit: {}",
+                                       type->unit());
+    }
+
+    auto& col_data = assert_cast<ColumnTimeStampTz&>(column).get_data();
+    const auto* base_ptr = reinterpret_cast<const 
uint8_t*>(concrete_array->raw_values());
+    const size_t element_size = sizeof(int64_t);
+    for (auto value_i = start; value_i < end; ++value_i) {
+        // One value per row including the null ones: the caller 
(DataTypeNullableSerDe) has already
+        // taken the validity bitmap and hands the whole range down. The value 
under a null slot is
+        // whatever the source left there, so it must not be converted -- a 
garbage epoch would fail
+        // the range check below and take a well-formed batch down with it.
+        if (concrete_array->IsNull(value_i)) {
+            col_data.push_back(TimestampTzValue());
+            continue;
+        }
+        const uint8_t* raw_byte_ptr = base_ptr + value_i * element_size;
+        auto value = unaligned_load<int64_t>(raw_byte_ptr);
+        int64_t timestamp_micros = 0;
+        if (__builtin_mul_overflow(value, multiplier, &timestamp_micros)) {
+            return Status::DataQualityError(
+                    "Arrow timestamp {} in unit {} overflows the microsecond 
range of TIMESTAMPTZ",
+                    value, static_cast<int>(type->unit()));
+        }
+        if (divisor != 1) {
+            // Floor, not truncate: C++ integer division rounds toward zero, 
which would move a
+            // pre-1970 instant forward by up to one microsecond.
+            int64_t remainder = timestamp_micros % divisor;
+            timestamp_micros /= divisor;
+            if (remainder < 0) {
+                --timestamp_micros;
+            }
+        }
+        RETURN_IF_ERROR(append_timestamptz_from_utc_epoch_micros(col_data, 
timestamp_micros));
+    }
+    return Status::OK();
+}
+
 Status DataTypeTimeStampTzSerDe::write_column_to_orc(const std::string& 
timezone,
                                                      const IColumn& column, 
const NullMap* null_map,
                                                      orc::ColumnVectorBatch* 
orc_col_batch,
diff --git a/be/src/core/data_type_serde/data_type_timestamptz_serde.h 
b/be/src/core/data_type_serde/data_type_timestamptz_serde.h
index 28b51dd3093..ce858c31c06 100644
--- a/be/src/core/data_type_serde/data_type_timestamptz_serde.h
+++ b/be/src/core/data_type_serde/data_type_timestamptz_serde.h
@@ -68,6 +68,11 @@ public:
                                  arrow::ArrayBuilder* array_builder, int64_t 
start, int64_t end,
                                  const cctz::time_zone& ctz) const override;
 
+    // Overridden rather than inherited: DataTypeNumberSerDe's reader would 
memcpy Arrow's epoch
+    // integers over this column's packed values, same width and no error. See 
the definition.
+    Status read_column_from_arrow(IColumn& column, const arrow::Array* 
arrow_array, int64_t start,
+                                  int64_t end, const cctz::time_zone& ctz) 
const override;
+
     Status write_column_to_orc(const std::string& timezone, const IColumn& 
column,
                                const NullMap* null_map, 
orc::ColumnVectorBatch* orc_col_batch,
                                int64_t start, int64_t end, Arena& arena,
diff --git a/be/test/core/data_type_serde/data_type_serde_timestamptz_test.cpp 
b/be/test/core/data_type_serde/data_type_serde_timestamptz_test.cpp
index b88601a02ad..584106cc635 100644
--- a/be/test/core/data_type_serde/data_type_serde_timestamptz_test.cpp
+++ b/be/test/core/data_type_serde/data_type_serde_timestamptz_test.cpp
@@ -24,7 +24,9 @@
 
 #include <cstddef>
 #include <iostream>
+#include <limits>
 #include <type_traits>
+#include <vector>
 
 #include "core/assert_cast.h"
 #include "core/column/column.h"
@@ -274,4 +276,87 @@ TEST_F(DataTypeTimeStampTzSerDeTest, binary_roundtrip) {
     test_func(*serde_tz_3, column_tz_3, 3);
     test_func(*serde_tz_6, column_tz_6, 6);
 }
+
+// Roundtrip through Arrow. Same failure shape as binary_roundtrip above: 
read_column_from_arrow
+// was inherited from DataTypeNumberSerDe, whose fixed-width path memcpy'd 
Arrow's int64 EPOCH
+// values over this column's PACKED values. Both are 8 bytes, so nothing 
failed -- the read
+// succeeded with every row wrong. Comparing the value that comes back against 
the one that went
+// out is what makes deleting the override fail here instead of in a user's 
result set.
+TEST_F(DataTypeTimeStampTzSerDeTest, ArrowRoundTrip) {
+    auto test_func = [&](const DataTypeTimeStampTzSerDe& serde, 
arrow::TimeUnit::type unit,
+                         uint16_t year, uint8_t month, uint8_t day, uint8_t 
hour, uint8_t minute,
+                         uint8_t second, uint32_t microsecond, int64_t 
expected_arrow_value) {
+        auto source_column = ColumnTimeStampTz::create();
+        TimestampTzValue source_value;
+        source_value.unchecked_set_time(year, month, day, hour, minute, 
second, microsecond);
+        source_column->insert_value(source_value);
+
+        arrow::TimestampBuilder builder(arrow::timestamp(unit), 
arrow::default_memory_pool());
+        ASSERT_TRUE(serde.write_column_to_arrow(*source_column, nullptr, 
&builder, 0,
+                                                source_column->size(), 
cctz::utc_time_zone())
+                            .ok());
+        std::shared_ptr<arrow::Array> array;
+        ASSERT_TRUE(builder.Finish(&array).ok());
+        // Pinned so the roundtrip cannot pass by both halves sharing one 
wrong encoding: what
+        // crosses the wire is an epoch count in the unit, not the column's 
packed representation.
+        ASSERT_EQ(expected_arrow_value,
+                  assert_cast<const 
arrow::TimestampArray*>(array.get())->Value(0));
+
+        auto dest_column = ColumnTimeStampTz::create();
+        ASSERT_TRUE(serde.read_column_from_arrow(*dest_column, array.get(), 0, 
array->length(),
+                                                 cctz::utc_time_zone())
+                            .ok());
+        ASSERT_EQ(1, dest_column->size());
+        EXPECT_EQ(source_value, dest_column->get_element(0));
+    };
+
+    // Before the epoch, so a division that truncates toward zero instead of 
flooring lands one
+    // unit late rather than agreeing by accident.
+    test_func(*serde_tz_6, arrow::TimeUnit::MICRO, 1969, 12, 31, 23, 59, 59, 
123456, -876544);
+    test_func(*serde_tz_3, arrow::TimeUnit::MILLI, 1969, 12, 31, 23, 59, 59, 
123000, -877);
+    test_func(*serde_tz_0, arrow::TimeUnit::SECOND, 1969, 12, 31, 23, 59, 59, 
0, -1);
+    test_func(*serde_tz_6, arrow::TimeUnit::MICRO, 2023, 1, 2, 3, 4, 5, 
123456, 1672628645123456);
+}
+
+// An Arrow type this serde cannot decode must fail the scan, not produce 
values. The inherited
+// reader accepted anything 8 bytes wide, which is how the corruption above 
stayed invisible.
+TEST_F(DataTypeTimeStampTzSerDeTest, ReadArrowRejectsNonTimestampType) {
+    arrow::Int64Builder builder;
+    ASSERT_TRUE(builder.Append(1672628645123456).ok());
+    std::shared_ptr<arrow::Array> array;
+    ASSERT_TRUE(builder.Finish(&array).ok());
+
+    auto dest_column = ColumnTimeStampTz::create();
+    auto st = serde_tz_6->read_column_from_arrow(*dest_column, array.get(), 0, 
array->length(),
+                                                 cctz::utc_time_zone());
+    EXPECT_FALSE(st.ok());
+    EXPECT_EQ(0, dest_column->size());
+}
+
+// A null slot's value buffer holds whatever the source left there. Converting 
it would range-check
+// garbage and fail a batch whose rows are all well-formed, so nulls take a 
default value instead.
+TEST_F(DataTypeTimeStampTzSerDeTest, ReadArrowIgnoresValuesUnderNulls) {
+    TimestampTzValue source_value;
+    source_value.unchecked_set_time(2023, 1, 2, 3, 4, 5, 123456);
+
+    std::vector<int64_t> values = {1672628645123456, 
std::numeric_limits<int64_t>::max()};
+    // Bit 0 set, bit 1 clear: row 0 valid, row 1 null.
+    std::vector<uint8_t> validity = {0b01};
+    auto data = 
arrow::ArrayData::Make(arrow::timestamp(arrow::TimeUnit::MICRO), values.size(),
+                                       {arrow::Buffer::Wrap(validity.data(), 
validity.size()),
+                                        arrow::Buffer::Wrap(values.data(), 
values.size())},
+                                       /*null_count=*/1);
+    std::shared_ptr<arrow::Array> array = arrow::MakeArray(data);
+    ASSERT_TRUE(array->IsNull(1));
+
+    auto dest_column = ColumnTimeStampTz::create();
+    ASSERT_TRUE(serde_tz_6
+                        ->read_column_from_arrow(*dest_column, array.get(), 0, 
array->length(),
+                                                 cctz::utc_time_zone())
+                        .ok());
+    // One value per row, nulls included: the caller took the validity bitmap 
and expects the
+    // nested column to have grown by the full range.
+    ASSERT_EQ(2, dest_column->size());
+    EXPECT_EQ(source_value, dest_column->get_element(0));
+}
 } // namespace doris
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/FlightSqlSchemaHelper.java
 
b/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/FlightSqlSchemaHelper.java
index 04389e0feee..b89abf16e83 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/FlightSqlSchemaHelper.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/FlightSqlSchemaHelper.java
@@ -121,7 +121,11 @@ public class FlightSqlSchemaHelper {
             case VARIANT:
                 return new ArrowType.Utf8();
             case DATEV2:
-                return new ArrowType.Date(DateUnit.MILLISECOND);
+                // DAY, not MILLISECOND: BE writes a DATEV2 column as 
arrow::Date32Type (a day number),
+                // so a MILLISECOND unit here describes the metadata as date64 
while the data that
+                // follows is date32. A client that trusts this schema -- one 
reading through the ADBC
+                // Flight SQL driver does -- then types the column as a 
datetime and fails the read.
+                return new ArrowType.Date(DateUnit.DAY);
             case DATETIMEV2:
                 if (scale > 3) {
                     return new ArrowType.Timestamp(TimeUnit.MICROSECOND, 
timeZone);
@@ -285,41 +289,80 @@ public class FlightSqlSchemaHelper {
             Integer tableOffset = 
describeTablesResult.getTablesOffset().get(tableIndex);
             for (; columnIndex < tableOffset; columnIndex++) {
                 TColumnDef columnDef = 
describeTablesResult.getColumns().get(columnIndex);
-                TColumnDesc columnDesc = columnDef.getColumnDesc();
-                final ArrowType columnArrowType = 
columnDescToArrowType(columnDesc);
-
-                List<Field> columnArrowTypeChildren;
-                // Arrow complex types may require children fields for parsing 
the schema on C++
-                switch (columnArrowType.getTypeID()) {
-                    case List:
-                    case LargeList:
-                    case FixedSizeList:
-                        columnArrowTypeChildren = Collections.singletonList(
-                                
Field.notNullable(BaseRepeatedValueVector.DATA_VECTOR_NAME,
-                                        
ZeroVector.INSTANCE.getField().getType()));
-                        break;
-                    case Map:
-                        columnArrowTypeChildren = Collections.singletonList(
-                                Field.notNullable(MapVector.DATA_VECTOR_NAME, 
new ArrowType.List()));
-                        break;
-                    case Struct:
-                        columnArrowTypeChildren = Collections.emptyList();
-                        break;
-                    default:
-                        columnArrowTypeChildren = null;
-                        break;
-                }
-
-                final Field field = new Field(columnDesc.getColumnName(),
-                        new FieldType(columnDesc.isIsAllowNull(), 
columnArrowType, null,
-                                createFlightSqlColumnMetadata(dbName, 
tableName, columnDesc)), columnArrowTypeChildren);
-                fields.add(field);
+                fields.add(buildField(dbName, tableName, 
columnDef.getColumnDesc()));
             }
             tableToFields.put(tableName, fields);
         }
         return tableToFields;
     }
 
+    /** One column, with its nested types described down to the leaves. */
+    private static Field buildField(String dbName, String tableName, 
TColumnDesc desc) {
+        ArrowType arrowType = columnDescToArrowType(desc);
+        return new Field(desc.getColumnName(),
+                new FieldType(desc.isIsAllowNull(), arrowType, null,
+                        createFlightSqlColumnMetadata(dbName, tableName, 
desc)),
+                arrowChildren(dbName, tableName, desc, arrowType));
+    }
+
+    /**
+     * The Arrow children of a complex column, built from the descriptor's own 
children.
+     *
+     * <p>These are not decoration. An Arrow ARRAY/MAP/STRUCT type carries its 
element types in its
+     * children and nowhere else, so a placeholder child says the column is an 
array OF NOTHING --
+     * and BE emits the real element type in the data ({@code 
convert_to_arrow_type}: ListType(item),
+     * MapType(key, value), StructType(fields)), which leaves the schema 
describing one thing and the
+     * batch carrying another. A client that types its columns from this 
schema (one reading through
+     * the ADBC Flight SQL driver does) then rejects the column outright.
+     *
+     * <p>{@code describeTables} already reports the tree -- {@code 
Column.createChildrenColumn} names
+     * an array's element "item" and a map's pair "key"/"value", which is what 
Arrow calls them too.
+     * When it reports none, the old placeholders are kept rather than an 
empty child list: a source
+     * that cannot describe its nested types is no worse off than before.
+     */
+    private static List<Field> arrowChildren(String dbName, String tableName, 
TColumnDesc desc,
+            ArrowType arrowType) {
+        List<TColumnDesc> children = desc.isSetChildren() ? desc.getChildren() 
: Collections.emptyList();
+        switch (arrowType.getTypeID()) {
+            case List:
+            case LargeList:
+            case FixedSizeList:
+                if (children.size() != 1) {
+                    return Collections.singletonList(
+                            
Field.notNullable(BaseRepeatedValueVector.DATA_VECTOR_NAME,
+                                    ZeroVector.INSTANCE.getField().getType()));
+                }
+                return Collections.singletonList(buildField(dbName, tableName, 
children.get(0)));
+            case Map:
+                // Arrow spells a map as list<entries: struct<key, value>>, 
with the entries struct and
+                // the key both non-nullable -- the descriptor's key 
nullability is not carried over,
+                // because an Arrow map with a nullable key is not a valid 
schema.
+                if (children.size() != 2) {
+                    return Collections.singletonList(
+                            Field.notNullable(MapVector.DATA_VECTOR_NAME, new 
ArrowType.List()));
+                }
+                Field key = buildField(dbName, tableName, children.get(0));
+                Field value = buildField(dbName, tableName, children.get(1));
+                Field entries = new Field(MapVector.DATA_VECTOR_NAME,
+                        new FieldType(false, new ArrowType.Struct(), null),
+                        Arrays.asList(new Field(key.getName(),
+                                        new FieldType(false, key.getType(), 
null), key.getChildren()),
+                                value));
+                return Collections.singletonList(entries);
+            case Struct:
+                if (children.isEmpty()) {
+                    return Collections.emptyList();
+                }
+                List<Field> structFields = new ArrayList<>(children.size());
+                for (TColumnDesc child : children) {
+                    structFields.add(buildField(dbName, tableName, child));
+                }
+                return structFields;
+            default:
+                return null;
+        }
+    }
+
     /**
      * for FlightSqlProducer Schemas.GET_CATALOGS_SCHEMA
      */
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/service/arrowflight/FlightSqlSchemaHelperArrowTypeTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/service/arrowflight/FlightSqlSchemaHelperArrowTypeTest.java
new file mode 100644
index 00000000000..147a52aa55e
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/service/arrowflight/FlightSqlSchemaHelperArrowTypeTest.java
@@ -0,0 +1,209 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.service.arrowflight;
+
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.thrift.TColumnDesc;
+import org.apache.doris.thrift.TPrimitiveType;
+
+import org.apache.arrow.vector.complex.BaseRepeatedValueVector;
+import org.apache.arrow.vector.complex.MapVector;
+import org.apache.arrow.vector.ipc.ReadChannel;
+import org.apache.arrow.vector.ipc.message.MessageSerializer;
+import org.apache.arrow.vector.types.DateUnit;
+import org.apache.arrow.vector.types.pojo.ArrowType;
+import org.apache.arrow.vector.types.pojo.Field;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.channels.Channels;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * What {@code CommandGetTables} says a column is, against what the query that 
follows actually
+ * carries.
+ *
+ * <p><b>Why these assertions matter.</b> A Flight SQL client is entitled to 
type its columns from
+ * the schema in {@code GetTables} and then read the batches without 
re-deriving anything -- that is
+ * what the schema is for, and {@code getArrowType} is documented as mirroring
+ * {@code convert_to_arrow_type} in the backend. When the two disagree the 
client does not get a
+ * degraded answer, it gets a failed read: it decodes the batch as the type 
the metadata promised.
+ * So each case below pins the Arrow type BE emits, not merely "some" type.
+ *
+ * <p>The descriptors are built the way {@code 
FrontendServiceImpl.getColumnDesc} builds them --
+ * a complex column carries its element types as {@link TColumnDesc} children, 
named "item" for an
+ * array and "key"/"value" for a map by {@code Column.createChildrenColumn}.
+ */
+public class FlightSqlSchemaHelperArrowTypeTest {
+
+    private static final String DB = "test_db";
+    private static final String TABLE = "test_tbl";
+
+    private static TColumnDesc desc(String name, TPrimitiveType type) {
+        TColumnDesc columnDesc = new TColumnDesc(name, type);
+        // Nullable, so that a place where the mapping must force NOT NULL is 
proved to force it
+        // rather than to inherit it.
+        columnDesc.setIsAllowNull(true);
+        return columnDesc;
+    }
+
+    private static TColumnDesc desc(String name, TPrimitiveType type, 
TColumnDesc... children) {
+        TColumnDesc columnDesc = desc(name, type);
+        columnDesc.setChildren(Arrays.asList(children));
+        return columnDesc;
+    }
+
+    private static Field buildField(TColumnDesc columnDesc) {
+        return Deencapsulation.invoke(FlightSqlSchemaHelper.class, 
"buildField", DB, TABLE, columnDesc);
+    }
+
+    /**
+     * BE writes a DATEV2 column as {@code arrow::Date32Type} -- a day number. 
{@link DateUnit#MILLISECOND}
+     * is date64, a different width and a different meaning, and a client that 
believes it renders and
+     * compares the column as a datetime, then fails the read on the first 
batch with "not support convert
+     * to datetimev2 from arrow type: 16".
+     */
+    @Test
+    public void dateV2IsDescribedAsDate32() {
+        Assertions.assertEquals(new ArrowType.Date(DateUnit.DAY),
+                buildField(desc("d", TPrimitiveType.DATEV2)).getType());
+    }
+
+    /**
+     * An Arrow list carries its element type in its child and nowhere else, 
so the placeholder child this
+     * replaced ({@code ZeroVector}'s Null type) described every array in the 
catalog as an array OF
+     * NOTHING while BE emitted {@code ListType(item)} in the data.
+     */
+    @Test
+    public void arrayDescribesItsElementType() {
+        Field array = buildField(desc("a", TPrimitiveType.ARRAY, desc("item", 
TPrimitiveType.INT)));
+
+        Assertions.assertEquals(ArrowType.ArrowTypeID.List, 
array.getType().getTypeID());
+        Assertions.assertEquals(1, array.getChildren().size());
+        Field item = array.getChildren().get(0);
+        Assertions.assertEquals("item", item.getName());
+        Assertions.assertEquals(new ArrowType.Int(32, true), item.getType());
+    }
+
+    /**
+     * Arrow spells a map as {@code list<entries: struct<key, value>>}. Both 
the entries struct and the key
+     * are non-nullable in a valid Arrow schema, so the descriptor's 
nullability must not be carried over to
+     * the key even though it is carried over everywhere else.
+     */
+    @Test
+    public void mapDescribesKeyAndValue() {
+        Field map = buildField(desc("m", TPrimitiveType.MAP,
+                desc("key", TPrimitiveType.VARCHAR), desc("value", 
TPrimitiveType.INT)));
+
+        Assertions.assertEquals(ArrowType.ArrowTypeID.Map, 
map.getType().getTypeID());
+        Assertions.assertEquals(1, map.getChildren().size());
+
+        Field entries = map.getChildren().get(0);
+        Assertions.assertEquals(MapVector.DATA_VECTOR_NAME, entries.getName());
+        Assertions.assertEquals(ArrowType.ArrowTypeID.Struct, 
entries.getType().getTypeID());
+        Assertions.assertFalse(entries.isNullable(), "an arrow map's entries 
struct is never nullable");
+
+        List<Field> pair = entries.getChildren();
+        Assertions.assertEquals(2, pair.size());
+        Assertions.assertEquals("key", pair.get(0).getName());
+        Assertions.assertEquals(new ArrowType.Utf8(), pair.get(0).getType());
+        Assertions.assertFalse(pair.get(0).isNullable(), "an arrow map with a 
nullable key is not a valid schema");
+        Assertions.assertEquals("value", pair.get(1).getName());
+        Assertions.assertEquals(new ArrowType.Int(32, true), 
pair.get(1).getType());
+    }
+
+    /** A struct with no fields is not "a struct", it is a column the client 
cannot read at all. */
+    @Test
+    public void structDescribesItsFields() {
+        Field struct = buildField(desc("s", TPrimitiveType.STRUCT,
+                desc("f1", TPrimitiveType.INT), desc("f2", 
TPrimitiveType.STRING)));
+
+        Assertions.assertEquals(ArrowType.ArrowTypeID.Struct, 
struct.getType().getTypeID());
+        Assertions.assertEquals(2, struct.getChildren().size());
+        Assertions.assertEquals("f1", struct.getChildren().get(0).getName());
+        Assertions.assertEquals(new ArrowType.Int(32, true), 
struct.getChildren().get(0).getType());
+        Assertions.assertEquals("f2", struct.getChildren().get(1).getName());
+        Assertions.assertEquals(new ArrowType.Utf8(), 
struct.getChildren().get(1).getType());
+    }
+
+    /** Nesting is where a per-column fix would have stopped: the descriptor's 
tree is walked to the leaves. */
+    @Test
+    public void nestedComplexTypesAreDescribedToTheLeaves() {
+        Field outer = buildField(desc("a", TPrimitiveType.ARRAY,
+                desc("item", TPrimitiveType.MAP,
+                        desc("key", TPrimitiveType.VARCHAR),
+                        desc("value", TPrimitiveType.ARRAY, desc("item", 
TPrimitiveType.BIGINT)))));
+
+        Field innerMap = outer.getChildren().get(0);
+        Assertions.assertEquals(ArrowType.ArrowTypeID.Map, 
innerMap.getType().getTypeID());
+        List<Field> pair = innerMap.getChildren().get(0).getChildren();
+        Assertions.assertEquals(new ArrowType.Utf8(), pair.get(0).getType());
+
+        Field innerArray = pair.get(1);
+        Assertions.assertEquals(ArrowType.ArrowTypeID.List, 
innerArray.getType().getTypeID());
+        Assertions.assertEquals(new ArrowType.Int(64, true), 
innerArray.getChildren().get(0).getType());
+    }
+
+    /**
+     * A descriptor that reports no children keeps the placeholders rather 
than an empty child list: a source
+     * that cannot describe its nested types is no worse off than it was 
before this mapping existed.
+     */
+    @Test
+    public void complexColumnWithoutChildrenKeepsThePlaceholder() {
+        Field array = buildField(desc("a", TPrimitiveType.ARRAY));
+        Assertions.assertEquals(1, array.getChildren().size());
+        Assertions.assertEquals(BaseRepeatedValueVector.DATA_VECTOR_NAME, 
array.getChildren().get(0).getName());
+        Assertions.assertEquals(ArrowType.ArrowTypeID.Null, 
array.getChildren().get(0).getType().getTypeID());
+
+        Field map = buildField(desc("m", TPrimitiveType.MAP));
+        Assertions.assertEquals(1, map.getChildren().size());
+        Assertions.assertEquals(MapVector.DATA_VECTOR_NAME, 
map.getChildren().get(0).getName());
+        Assertions.assertEquals(ArrowType.ArrowTypeID.List, 
map.getChildren().get(0).getType().getTypeID());
+
+        Assertions.assertTrue(buildField(desc("s", 
TPrimitiveType.STRUCT)).getChildren().isEmpty());
+    }
+
+    /** A scalar column has no children to describe, and gaining one would 
change how it is read. */
+    @Test
+    public void scalarColumnHasNoChildren() {
+        Assertions.assertTrue(buildField(desc("i", 
TPrimitiveType.INT)).getChildren().isEmpty());
+    }
+
+    /**
+     * The client does not see the {@link Field} objects, it sees the 
serialized schema in the
+     * {@code table_schema} column of {@code GetTables}. Asserting after a 
round trip through that encoding
+     * is what proves the element types actually reach it.
+     */
+    @Test
+    public void theSerializedSchemaCarriesTheChildren() throws IOException {
+        byte[] serialized = 
FlightSqlSchemaHelper.getSerializedSchema(Collections.singletonList(
+                buildField(desc("a", TPrimitiveType.ARRAY, desc("item", 
TPrimitiveType.INT)))));
+
+        Schema schema = MessageSerializer.deserializeSchema(
+                new ReadChannel(Channels.newChannel(new 
ByteArrayInputStream(serialized))));
+
+        Field array = schema.getFields().get(0);
+        Assertions.assertEquals(ArrowType.ArrowTypeID.List, 
array.getType().getTypeID());
+        Assertions.assertEquals(new ArrowType.Int(32, true), 
array.getChildren().get(0).getType());
+    }
+}


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

Reply via email to