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

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/dev/pr-12969-8b93585ef7c06a0af3db96e50451d193340d093c
in repository https://gitbox.apache.org/repos/asf/seatunnel.git

commit 65ccf3615c18e6f6eae6c0be2457b7d2aeefe009
Author: Sepuri Sai Krishna <[email protected]>
AuthorDate: Tue Oct 27 10:28:57 2026 +0000

    [Fix][Connector-V2] Read a null struct or map cell as null (#12969)
---
 .../introduction/concepts/incompatible-changes.md  |   7 +
 .../introduction/concepts/incompatible-changes.md  |   7 +
 .../source/arrow/converter/MapConverter.java       |   3 +
 .../source/arrow/converter/StructConverter.java    |   3 +
 .../ArrowToSeatunnelRowReaderNullCellTest.java     | 231 +++++++++++++++++++++
 5 files changed, 251 insertions(+)

diff --git a/docs/en/introduction/concepts/incompatible-changes.md 
b/docs/en/introduction/concepts/incompatible-changes.md
index 50d813457f..29958c414f 100644
--- a/docs/en/introduction/concepts/incompatible-changes.md
+++ b/docs/en/introduction/concepts/incompatible-changes.md
@@ -5,6 +5,13 @@ You need to check this document before you upgrade to related 
version.
 
 ## dev
 
+### Doris and StarRocks sources: a null MAP or ROW column no longer reads as 
an empty map or fails the job
+
+- **Behavior change: a null complex cell from an Arrow stream now reads as 
`null`**
+  - **Description**: `ArrowToSeatunnelRowReader`, which the Doris and 
StarRocks sources read with, sends a MAP column to `MapConverter` and a ROW 
column to `StructConverter`. Neither checked the Arrow validity bit in the path 
that converts element types, so a null MAP cell was returned as an empty map 
and a null ROW cell ended the read with `NullPointerException: Cannot invoke 
"java.util.Map.entrySet()" because "valueMap" is null`. Both now return `null`, 
the same as `ListConverter`, `La [...]
+  - **Impact**: A nullable MAP column now yields `null` for a row that holds 
no map, where it previously yielded an empty map. A row that holds a genuinely 
empty map still yields an empty map, so absent and empty are no longer the same 
value. A nullable ROW column is now read instead of failing the whole split.
+  - **Migration Guide**: Consumers that treated the previous empty map as "no 
value" need no change. A consumer that assumed every row carries a non-null map 
must handle `null` for that column.
+
 ### Zeta: runtime metric counters restart from zero after a pipeline failover 
restore
 
 - **Behavior change: runtime counters no longer accumulate values from a 
failed pipeline attempt**
diff --git a/docs/zh/introduction/concepts/incompatible-changes.md 
b/docs/zh/introduction/concepts/incompatible-changes.md
index 2c444d5b85..f00ffe9c47 100644
--- a/docs/zh/introduction/concepts/incompatible-changes.md
+++ b/docs/zh/introduction/concepts/incompatible-changes.md
@@ -4,6 +4,13 @@
 
 ## dev
 
+### Doris 与 StarRocks Source:MAP 或 ROW 列为空值时不再读成空 Map 或导致作业失败
+
+- **行为变更:Arrow 流中为空的复杂类型单元格现在读为 `null`**
+  - **描述**:Doris 和 StarRocks Source 使用的 `ArrowToSeatunnelRowReader` 会把 MAP 列交给 
`MapConverter`、ROW 列交给 `StructConverter`。这两个转换器在处理元素类型的分支中没有检查 Arrow 
的有效性位,因此为空的 MAP 单元格被返回为空 Map,为空的 ROW 单元格则以 `NullPointerException: Cannot invoke 
"java.util.Map.entrySet()" because "valueMap" is null` 结束整个读取。现在两者都返回 `null`,与自 
Arrow Reader 引入起就有此判断的 `ListConverter`、`LargeListConverter` 和 
`FixedSizeListConverter` 保持一致。
+  - **影响**:可空的 MAP 列在该行没有 Map 时现在返回 `null`,此前返回空 Map。确实存放空 Map 的行仍然返回空 
Map,因此“不存在”和“为空”不再是同一个值。可空的 ROW 列现在可以正常读取,而不会使整个分片失败。
+  - **迁移指南**:此前把空 Map 当作“无值”处理的下游无需改动。如果下游假定每行都有非空的 Map,则需要处理该列为 `null` 的情况。
+
 ### Zeta:管道故障转移恢复后运行时计数器从零重新计数
 
 - **行为变更:运行时计数器不再累计失败管道执行的数值**
diff --git 
a/seatunnel-connectors-v2/connector-common/src/main/java/org/apache/seatunnel/connectors/seatunnel/common/source/arrow/converter/MapConverter.java
 
b/seatunnel-connectors-v2/connector-common/src/main/java/org/apache/seatunnel/connectors/seatunnel/common/source/arrow/converter/MapConverter.java
index fd4aeb01a6..ee6a435eea 100644
--- 
a/seatunnel-connectors-v2/connector-common/src/main/java/org/apache/seatunnel/connectors/seatunnel/common/source/arrow/converter/MapConverter.java
+++ 
b/seatunnel-connectors-v2/connector-common/src/main/java/org/apache/seatunnel/connectors/seatunnel/common/source/arrow/converter/MapConverter.java
@@ -40,6 +40,9 @@ public class MapConverter implements Converter<MapVector> {
     @Override
     public Object convert(
             int rowIndex, MapVector fieldVector, Map<String, Function> 
genericsConverters) {
+        if (fieldVector.isNull(rowIndex)) {
+            return null;
+        }
         UnionMapReader reader = fieldVector.getReader();
         reader.setPosition(rowIndex);
         Map<Object, Object> mapValue = new HashMap<>();
diff --git 
a/seatunnel-connectors-v2/connector-common/src/main/java/org/apache/seatunnel/connectors/seatunnel/common/source/arrow/converter/StructConverter.java
 
b/seatunnel-connectors-v2/connector-common/src/main/java/org/apache/seatunnel/connectors/seatunnel/common/source/arrow/converter/StructConverter.java
index 2646df7b2b..dd74f266a6 100644
--- 
a/seatunnel-connectors-v2/connector-common/src/main/java/org/apache/seatunnel/connectors/seatunnel/common/source/arrow/converter/StructConverter.java
+++ 
b/seatunnel-connectors-v2/connector-common/src/main/java/org/apache/seatunnel/connectors/seatunnel/common/source/arrow/converter/StructConverter.java
@@ -39,6 +39,9 @@ public class StructConverter implements 
Converter<StructVector> {
     @Override
     public Object convert(
             int rowIndex, StructVector fieldVector, Map<String, Function> 
genericsConverters) {
+        if (fieldVector.isNull(rowIndex)) {
+            return null;
+        }
         Map<String, ?> valueMap = fieldVector.getObject(rowIndex);
         return valueMap.entrySet().stream()
                 .collect(
diff --git 
a/seatunnel-connectors-v2/connector-common/src/test/java/org/apache/seatunnel/connectors/seatunnel/common/source/arrow/reader/ArrowToSeatunnelRowReaderNullCellTest.java
 
b/seatunnel-connectors-v2/connector-common/src/test/java/org/apache/seatunnel/connectors/seatunnel/common/source/arrow/reader/ArrowToSeatunnelRowReaderNullCellTest.java
new file mode 100644
index 0000000000..9e6e5182f4
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-common/src/test/java/org/apache/seatunnel/connectors/seatunnel/common/source/arrow/reader/ArrowToSeatunnelRowReaderNullCellTest.java
@@ -0,0 +1,231 @@
+/*
+ * 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.seatunnel.connectors.seatunnel.common.source.arrow.reader;
+
+import org.apache.seatunnel.shade.org.apache.arrow.memory.BufferAllocator;
+import org.apache.seatunnel.shade.org.apache.arrow.memory.RootAllocator;
+import org.apache.seatunnel.shade.org.apache.arrow.vector.FieldVector;
+import org.apache.seatunnel.shade.org.apache.arrow.vector.IntVector;
+import org.apache.seatunnel.shade.org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.seatunnel.shade.org.apache.arrow.vector.complex.MapVector;
+import org.apache.seatunnel.shade.org.apache.arrow.vector.complex.StructVector;
+import 
org.apache.seatunnel.shade.org.apache.arrow.vector.complex.impl.NullableStructWriter;
+import 
org.apache.seatunnel.shade.org.apache.arrow.vector.complex.impl.UnionMapWriter;
+import 
org.apache.seatunnel.shade.org.apache.arrow.vector.ipc.ArrowStreamWriter;
+
+import org.apache.seatunnel.api.table.type.BasicType;
+import org.apache.seatunnel.api.table.type.MapType;
+import org.apache.seatunnel.api.table.type.SeaTunnelDataType;
+import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+import org.apache.seatunnel.api.table.type.SeaTunnelRowType;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.channels.Channels;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Verifies that a null cell in a complex column reads back as null.
+ *
+ * <p>The list converters guard the generics convert overload with an isNull 
check, but
+ * StructConverter and MapConverter did not, so a nullable ROW column aborted 
the read with a
+ * NullPointerException and a nullable MAP column silently became an empty 
map. Both reach the
+ * production path through ArrowToSeatunnelRowReader, which is what the Doris 
and StarRocks sources
+ * read with.
+ *
+ * <p>Every stream here holds a single record batch, so a failure points at 
the converter dispatch
+ * and not at a batch boundary. Each fixture also carries a plain "id" column, 
which must keep its
+ * value on the null row to prove a null complex cell does not blank the rest 
of the row.
+ */
+public class ArrowToSeatunnelRowReaderNullCellTest {
+
+    /**
+     * A null ROW cell must read as null rather than throwing.
+     *
+     * <p>Before the guard, StructConverter called entrySet() on the null 
result of getObject() and
+     * the whole read failed, so one null struct cell cost the entire split 
rather than one value.
+     */
+    @Test
+    public void testNullStructCellReadsAsNull() throws Exception {
+        List<SeaTunnelRow> rows = readAll(writeStructStream(), 
structRowType());
+
+        Assertions.assertEquals(2, rows.size());
+        Assertions.assertEquals(
+                Collections.singletonMap("age", 30),
+                rows.get(0).getField(1),
+                "the populated struct cell must still convert through the 
generics overload");
+        Assertions.assertNull(
+                rows.get(1).getField(1), "a null struct cell must read as 
null, not throw");
+        Assertions.assertEquals(
+                2, rows.get(1).getField(0), "a null struct must not blank the 
scalars in its row");
+    }
+
+    /**
+     * A null MAP cell must read as null rather than as an empty map.
+     *
+     * <p>MapConverter positions the reader and loops on next(), which yields 
nothing for a null
+     * cell, so the method returned an empty map.
+     */
+    @Test
+    public void testNullMapCellReadsAsNull() throws Exception {
+        List<SeaTunnelRow> rows = readAll(writeMapStream(), mapRowType());
+
+        Assertions.assertEquals(3, rows.size());
+        Assertions.assertEquals(
+                Collections.singletonMap(1, 10),
+                rows.get(0).getField(1),
+                "the populated map cell must still convert through the 
generics overload");
+        Assertions.assertNull(
+                rows.get(1).getField(1), "a null map cell must read as null, 
not as an empty map");
+        Assertions.assertEquals(
+                2, rows.get(1).getField(0), "a null map must not blank the 
scalars in its row");
+    }
+
+    /**
+     * A cell holding a genuinely empty map must still read as an empty map.
+     *
+     * <p>This is the other half of the null guard: the guard must separate 
absent from empty rather
+     * than collapse both to null, so a row that really holds no entries keeps 
reading as an empty
+     * map while only a null cell reads as null.
+     */
+    @Test
+    public void testEmptyMapCellStaysAnEmptyMap() throws Exception {
+        List<SeaTunnelRow> rows = readAll(writeMapStream(), mapRowType());
+
+        Assertions.assertEquals(3, rows.size());
+        Assertions.assertEquals(
+                Collections.emptyMap(),
+                rows.get(2).getField(1),
+                "an empty map cell must stay an empty map and stay distinct 
from a null cell");
+        Assertions.assertEquals(
+                3, rows.get(2).getField(0), "an empty map must not blank the 
scalars in its row");
+    }
+
+    /**
+     * Writes a single-batch stream with an "id" column and a "profile" struct 
column whose second
+     * row is null.
+     */
+    private byte[] writeStructStream() throws IOException {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        try (BufferAllocator allocator = new RootAllocator(Integer.MAX_VALUE);
+                IntVector idVector = new IntVector("id", allocator);
+                StructVector profileVector = StructVector.empty("profile", 
allocator)) {
+            fillIds(idVector, 2);
+
+            NullableStructWriter profileWriter = profileVector.getWriter();
+            profileWriter.setPosition(0);
+            profileWriter.start();
+            profileWriter.integer("age").writeInt(30);
+            profileWriter.end();
+            profileVector.setNull(1);
+            profileVector.setValueCount(2);
+
+            writeBatch(out, 2, idVector, profileVector);
+        }
+        return out.toByteArray();
+    }
+
+    /**
+     * Writes a single-batch stream with an "id" column and a "labels" map 
column holding one
+     * populated row, one null row and one empty-map row, in that order.
+     */
+    private byte[] writeMapStream() throws IOException {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        try (BufferAllocator allocator = new RootAllocator(Integer.MAX_VALUE);
+                IntVector idVector = new IntVector("id", allocator);
+                MapVector labelsVector = MapVector.empty("labels", allocator, 
false)) {
+            fillIds(idVector, 3);
+
+            UnionMapWriter labelsWriter = labelsVector.getWriter();
+            labelsWriter.setPosition(0);
+            labelsWriter.startMap();
+            labelsWriter.startEntry();
+            labelsWriter.key().integer().writeInt(1);
+            labelsWriter.value().integer().writeInt(10);
+            labelsWriter.endEntry();
+            labelsWriter.endMap();
+            labelsVector.setNull(1);
+            labelsWriter.setPosition(2);
+            labelsWriter.startMap();
+            labelsWriter.endMap();
+            labelsVector.setValueCount(3);
+
+            writeBatch(out, 3, idVector, labelsVector);
+        }
+        return out.toByteArray();
+    }
+
+    /** Populates the shared "id" column with 1-based row numbers, so every 
row has a sentinel. */
+    private void fillIds(IntVector idVector, int rowCount) {
+        idVector.allocateNew(rowCount);
+        for (int i = 0; i < rowCount; i++) {
+            idVector.set(i, i + 1);
+        }
+        idVector.setValueCount(rowCount);
+    }
+
+    /** Writes the given vectors as one record batch. */
+    private void writeBatch(ByteArrayOutputStream out, int rowCount, 
FieldVector... vectors)
+            throws IOException {
+        try (VectorSchemaRoot writerRoot = VectorSchemaRoot.of(vectors)) {
+            writerRoot.setRowCount(rowCount);
+            try (ArrowStreamWriter writer =
+                    new ArrowStreamWriter(writerRoot, null, 
Channels.newChannel(out))) {
+                writer.start();
+                writer.writeBatch();
+                writer.end();
+            }
+        }
+    }
+
+    /** Drains the reader through the production byte-array constructor. */
+    private List<SeaTunnelRow> readAll(byte[] payload, SeaTunnelRowType 
rowType) {
+        List<SeaTunnelRow> rows = new ArrayList<>();
+        try (ArrowToSeatunnelRowReader reader =
+                new ArrowToSeatunnelRowReader(payload, rowType).readArrow()) {
+            while (reader.hasNext()) {
+                rows.add(reader.next());
+            }
+        }
+        return rows;
+    }
+
+    /** Schema of an int column plus a ROW column, which dispatches to 
StructConverter. */
+    private SeaTunnelRowType structRowType() {
+        SeaTunnelRowType profileType =
+                new SeaTunnelRowType(
+                        new String[] {"age"}, new SeaTunnelDataType[] 
{BasicType.INT_TYPE});
+        return new SeaTunnelRowType(
+                new String[] {"id", "profile"},
+                new SeaTunnelDataType[] {BasicType.INT_TYPE, profileType});
+    }
+
+    /** Schema of an int column plus a MAP column, which dispatches to 
MapConverter. */
+    private SeaTunnelRowType mapRowType() {
+        MapType<Integer, Integer> labelsType =
+                new MapType<>(BasicType.INT_TYPE, BasicType.INT_TYPE);
+        return new SeaTunnelRowType(
+                new String[] {"id", "labels"},
+                new SeaTunnelDataType[] {BasicType.INT_TYPE, labelsType});
+    }
+}

Reply via email to