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

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


The following commit(s) were added to refs/heads/dev by this push:
     new 861d741f2d [Fix][Format] Reject incompatible JSON array and map shapes 
(#12962)
861d741f2d is described below

commit 861d741f2d6488e8eff31760aa3c6aff553362fd
Author: hutiefang76 <[email protected]>
AuthorDate: Sat Oct 24 02:37:39 2026 +0000

    [Fix][Format] Reject incompatible JSON array and map shapes (#12962)
---
 .../introduction/concepts/incompatible-changes.md  |   8 +
 .../introduction/concepts/incompatible-changes.md  |   8 +
 .../seatunnel/format/json/JsonToRowConverters.java |   8 +
 .../format/json/JsonRowDataSerDeSchemaTest.java    | 269 +++++++++++++++++++++
 4 files changed, 293 insertions(+)

diff --git a/docs/en/introduction/concepts/incompatible-changes.md 
b/docs/en/introduction/concepts/incompatible-changes.md
index 856f75963a..0decce7cd3 100644
--- a/docs/en/introduction/concepts/incompatible-changes.md
+++ b/docs/en/introduction/concepts/incompatible-changes.md
@@ -5,6 +5,14 @@ You need to check this document before you upgrade to related 
version.
 
 ## dev
 
+### JSON format: `ARRAY` and `MAP` columns reject values of the wrong 
container type
+
+When a field is declared as `ARRAY`, a JSON value that is not an array (a 
string, a number, a boolean or an object) previously produced an empty array 
`[]`, or an array holding `null` for every member of the object, without any 
warning. When a field is declared as `MAP`, a JSON value that is not an object 
silently produced an empty map `{}`. Both happened with `ignore-parse-errors` 
disabled as well as enabled.
+
+Such a value now causes a parse error that identifies the field and the 
expected container type; with `ignore-parse-errors = true` only that container 
value is set to `null`, and every other value of the row is kept. The same 
holds for a wrong container type nested inside an array or a map: only the 
offending nested value becomes `null` while its siblings and the enclosing 
container are retained, and empty arrays and objects of the declared container 
type, `null` and missing values, and  [...]
+
+The same strictness applies wherever the shared JSON-to-row conversion is used 
without ignoring parse errors: the `JsonPath` transform (column `dest_type`), 
the `Python` transform (declared output columns and their declared types) and 
the Assert sink's `equals_to` check for `ARRAY` and `MAP` values. These callers 
do not consult the JSON format's `ignore-parse-errors` option, so that switch 
does not apply to them: a value that does not match the declared `ARRAY` or 
`MAP` type still fails  [...]
+
 ### File sink: XML applies `date_format`, `datetime_format` and `time_format`
 
 - **Behavior change: the XML writer of the file sinks formats DATE, TIME and 
TIMESTAMP values with the configured formats**
diff --git a/docs/zh/introduction/concepts/incompatible-changes.md 
b/docs/zh/introduction/concepts/incompatible-changes.md
index f1de528596..aca33c6a98 100644
--- a/docs/zh/introduction/concepts/incompatible-changes.md
+++ b/docs/zh/introduction/concepts/incompatible-changes.md
@@ -4,6 +4,14 @@
 
 ## dev
 
+### JSON 格式:`ARRAY` 与 `MAP` 字段拒绝容器类型不匹配的值
+
+当字段声明为 `ARRAY` 时,此前若 JSON 值不是数组(字符串、数字、布尔值或对象),会静默得到空数组 `[]`,若是对象则得到每个成员都为 
`null` 的数组;当字段声明为 `MAP` 时,非对象的 JSON 值会静默得到空 Map `{}`。无论 `ignore-parse-errors` 
关闭还是开启,行为都是如此。
+
+现在这类值会报告解析错误,指出字段名和预期的容器类型;配置 `ignore-parse-errors = true` 时只把该容器值置为 
`null`,同一行的其余值仍然保留。嵌套在数组或 Map 中的类似值同样如此:只有出错的那个嵌套值变为 
`null`,其兄弟值和外层容器仍然保留;与声明类型匹配的空数组、空对象,以及 `null` 与缺失值,以及元素和键的转换行为保持不变。如需迁移,请为 
`ARRAY` 字段提供真正的 JSON 数组、为 `MAP` 字段提供真正的 JSON 对象,或开启 `ignore-parse-errors = 
true`,使格式不合法的容器值变成 `null`而不是让作业失败。
+
+同样的严格校验也适用于未忽略解析错误而使用共享 JSON 转行转换的地方:`JsonPath` 转换(列的 `dest_type`)、`Python` 
转换(声明的输出列及其声明类型),以及 Assert 的 `equals_to` 对 `ARRAY`、`MAP` 值的比较。这些调用方不会读取 JSON 
格式的 `ignore-parse-errors` 选项,因此该开关对它们不生效:与声明的 `ARRAY` 或 `MAP` 
类型不匹配的值在那里仍会失败,而这些转换插件自身的错误处理行为保持不变。
+
 ### File Sink:XML 应用 `date_format`、`datetime_format` 和 `time_format`
 
 - **行为变更:文件类 Sink 的 XML 写入器按配置的格式输出 DATE、TIME 和 TIMESTAMP**
diff --git 
a/seatunnel-formats/seatunnel-format-json/src/main/java/org/apache/seatunnel/format/json/JsonToRowConverters.java
 
b/seatunnel-formats/seatunnel-format-json/src/main/java/org/apache/seatunnel/format/json/JsonToRowConverters.java
index 040329a869..850705877e 100644
--- 
a/seatunnel-formats/seatunnel-format-json/src/main/java/org/apache/seatunnel/format/json/JsonToRowConverters.java
+++ 
b/seatunnel-formats/seatunnel-format-json/src/main/java/org/apache/seatunnel/format/json/JsonToRowConverters.java
@@ -455,6 +455,10 @@ public class JsonToRowConverters implements Serializable {
         return new JsonToObjectConverter() {
             @Override
             public Object convert(JsonNode jsonNode, String fieldName) {
+                if (!jsonNode.isArray()) {
+                    throw new JsonParseException(
+                            String.format("Field '%s' expects an array for 
ARRAY.", fieldName));
+                }
                 Object arr =
                         
Array.newInstance(type.getElementType().getTypeClass(), jsonNode.size());
                 for (int i = 0; i < jsonNode.size(); i++) {
@@ -471,6 +475,10 @@ public class JsonToRowConverters implements Serializable {
         return new JsonToObjectConverter() {
             @Override
             public Object convert(JsonNode jsonNode, String fieldName) {
+                if (!jsonNode.isObject()) {
+                    throw new JsonParseException(
+                            String.format("Field '%s' expects an object for 
MAP.", fieldName));
+                }
                 Map<Object, Object> value = new HashMap<>();
                 jsonNode.fields()
                         .forEachRemaining(
diff --git 
a/seatunnel-formats/seatunnel-format-json/src/test/java/org/apache/seatunnel/format/json/JsonRowDataSerDeSchemaTest.java
 
b/seatunnel-formats/seatunnel-format-json/src/test/java/org/apache/seatunnel/format/json/JsonRowDataSerDeSchemaTest.java
index 25d5053b8f..b719b5b814 100644
--- 
a/seatunnel-formats/seatunnel-format-json/src/test/java/org/apache/seatunnel/format/json/JsonRowDataSerDeSchemaTest.java
+++ 
b/seatunnel-formats/seatunnel-format-json/src/test/java/org/apache/seatunnel/format/json/JsonRowDataSerDeSchemaTest.java
@@ -26,6 +26,7 @@ import 
org.apache.seatunnel.shade.com.fasterxml.jackson.databind.node.ObjectNode
 
 import org.apache.seatunnel.api.table.catalog.CatalogTable;
 import org.apache.seatunnel.api.table.catalog.CatalogTableUtil;
+import org.apache.seatunnel.api.table.type.ArrayType;
 import org.apache.seatunnel.api.table.type.DecimalType;
 import org.apache.seatunnel.api.table.type.LocalTimeType;
 import org.apache.seatunnel.api.table.type.MapType;
@@ -76,6 +77,27 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 public class JsonRowDataSerDeSchemaTest {
 
+    private static final SeaTunnelRowType CONTAINER_ARRAY_ROW_TYPE =
+            new SeaTunnelRowType(
+                    new String[] {"id", "arr"},
+                    new SeaTunnelDataType<?>[] {INT_TYPE, INT_ARRAY_TYPE});
+
+    private static final SeaTunnelRowType CONTAINER_MAP_ROW_TYPE =
+            new SeaTunnelRowType(
+                    new String[] {"id", "m"},
+                    new SeaTunnelDataType<?>[] {INT_TYPE, new 
MapType<>(STRING_TYPE, INT_TYPE)});
+
+    private static final ArrayType<Map<String, Integer>[], Map<String, 
Integer>> MAP_ARRAY_TYPE =
+            ArrayType.of(new MapType<>(STRING_TYPE, INT_TYPE));
+
+    private static final MapType<String, Integer[]> ARRAY_MAP_TYPE =
+            new MapType<>(STRING_TYPE, INT_ARRAY_TYPE);
+
+    private static final SeaTunnelRowType NESTED_CONTAINER_ROW_TYPE =
+            new SeaTunnelRowType(
+                    new String[] {"id", "outer", "nested"},
+                    new SeaTunnelDataType<?>[] {INT_TYPE, MAP_ARRAY_TYPE, 
ARRAY_MAP_TYPE});
+
     @Test
     public void testTimestampTzSerializedAsSessionWallClock() {
         // Issue #10795: with serializeTimestampTzAsLocal=true (used by the 
Doris
@@ -906,4 +928,251 @@ public class JsonRowDataSerDeSchemaTest {
                 LocalDateTime.of(2022, 9, 24, 22, 45, 0, 123_000_000),
                 fractionalPrecisionRow.getField(0));
     }
+
+    @Test
+    public void testArrayFieldRejectsNonArrayNodeInStrictMode() {
+        JsonDeserializationSchema schema =
+                new JsonDeserializationSchema(false, false, 
CONTAINER_ARRAY_ROW_TYPE);
+        final String[] payloads = {
+            "{\"id\":7,\"arr\":\"abc\"}",
+            "{\"id\":7,\"arr\":42}",
+            "{\"id\":7,\"arr\":true}",
+            "{\"id\":7,\"arr\":{\"k\":1}}"
+        };
+        Assertions.assertAll(
+                "ARRAY field must reject non-array JSON nodes in strict mode",
+                () ->
+                        assertContainerTypeRejectedInStrictMode(
+                                schema, payloads[0], "'arr'", "ARRAY"),
+                () ->
+                        assertContainerTypeRejectedInStrictMode(
+                                schema, payloads[1], "'arr'", "ARRAY"),
+                () ->
+                        assertContainerTypeRejectedInStrictMode(
+                                schema, payloads[2], "'arr'", "ARRAY"),
+                () ->
+                        assertContainerTypeRejectedInStrictMode(
+                                schema, payloads[3], "'arr'", "ARRAY"));
+    }
+
+    @Test
+    public void testArrayFieldRejectsNonArrayNodeInLenientMode() throws 
Exception {
+        JsonDeserializationSchema schema =
+                new JsonDeserializationSchema(false, true, 
CONTAINER_ARRAY_ROW_TYPE);
+        final String[] payloads = {
+            "{\"id\":7,\"arr\":\"abc\"}",
+            "{\"id\":7,\"arr\":42}",
+            "{\"id\":7,\"arr\":true}",
+            "{\"id\":7,\"arr\":{\"k\":1}}"
+        };
+        Assertions.assertAll(
+                "ARRAY field must be null for non-array JSON nodes in lenient 
mode",
+                () -> assertContainerFieldNullInLenientMode(schema, 
payloads[0]),
+                () -> assertContainerFieldNullInLenientMode(schema, 
payloads[1]),
+                () -> assertContainerFieldNullInLenientMode(schema, 
payloads[2]),
+                () -> assertContainerFieldNullInLenientMode(schema, 
payloads[3]));
+    }
+
+    @Test
+    public void testArrayFieldAcceptsArrayNodeInBothModes() throws Exception {
+        String[] payloads = {
+            "{\"id\":7,\"arr\":[]}",
+            "{\"id\":\"7\",\"arr\":[1,2]}",
+            "{\"id\":7,\"arr\":[1,null]}",
+            "{\"id\":7,\"arr\":null}"
+        };
+        Object[] expected = {new Integer[0], new Integer[] {1, 2}, new 
Integer[] {1, null}, null};
+        for (boolean ignoreErrors : new boolean[] {false, true}) {
+            JsonDeserializationSchema schema =
+                    new JsonDeserializationSchema(false, ignoreErrors, 
CONTAINER_ARRAY_ROW_TYPE);
+            for (int i = 0; i < payloads.length; i++) {
+                String payload = payloads[i];
+                String message = payload + " (ignoreErrors=" + ignoreErrors + 
")";
+                SeaTunnelRow rowFromBytes =
+                        
schema.deserialize(payload.getBytes(StandardCharsets.UTF_8));
+                SeaTunnelRow rowFromString = schema.deserialize(payload);
+                Assertions.assertNotNull(rowFromBytes, message);
+                Assertions.assertNotNull(rowFromString, message);
+                assertEquals(7, rowFromBytes.getField(0), message);
+                assertEquals(rowFromBytes.getField(0), 
rowFromString.getField(0), message);
+                if (expected[i] == null) {
+                    assertNull(rowFromBytes.getField(1), message);
+                    assertNull(rowFromString.getField(1), message);
+                } else {
+                    assertArrayEquals(
+                            (Integer[]) expected[i], (Integer[]) 
rowFromBytes.getField(1), message);
+                    assertArrayEquals(
+                            (Integer[]) expected[i],
+                            (Integer[]) rowFromString.getField(1),
+                            message);
+                }
+            }
+        }
+    }
+
+    @Test
+    public void testMapFieldRejectsNonObjectNodeInStrictMode() {
+        JsonDeserializationSchema schema =
+                new JsonDeserializationSchema(false, false, 
CONTAINER_MAP_ROW_TYPE);
+        final String[] payloads = {
+            "{\"id\":7,\"m\":\"abc\"}",
+            "{\"id\":7,\"m\":42}",
+            "{\"id\":7,\"m\":true}",
+            "{\"id\":7,\"m\":[1,2]}"
+        };
+        Assertions.assertAll(
+                "MAP field must reject non-object JSON nodes in strict mode",
+                () -> assertContainerTypeRejectedInStrictMode(schema, 
payloads[0], "'m'", "MAP"),
+                () -> assertContainerTypeRejectedInStrictMode(schema, 
payloads[1], "'m'", "MAP"),
+                () -> assertContainerTypeRejectedInStrictMode(schema, 
payloads[2], "'m'", "MAP"),
+                () -> assertContainerTypeRejectedInStrictMode(schema, 
payloads[3], "'m'", "MAP"));
+    }
+
+    @Test
+    public void testMapFieldRejectsNonObjectNodeInLenientMode() throws 
Exception {
+        JsonDeserializationSchema schema =
+                new JsonDeserializationSchema(false, true, 
CONTAINER_MAP_ROW_TYPE);
+        final String[] payloads = {
+            "{\"id\":7,\"m\":\"abc\"}",
+            "{\"id\":7,\"m\":42}",
+            "{\"id\":7,\"m\":true}",
+            "{\"id\":7,\"m\":[1,2]}"
+        };
+        Assertions.assertAll(
+                "MAP field must be null for non-object JSON nodes in lenient 
mode",
+                () -> assertContainerFieldNullInLenientMode(schema, 
payloads[0]),
+                () -> assertContainerFieldNullInLenientMode(schema, 
payloads[1]),
+                () -> assertContainerFieldNullInLenientMode(schema, 
payloads[2]),
+                () -> assertContainerFieldNullInLenientMode(schema, 
payloads[3]));
+    }
+
+    @Test
+    public void testMapFieldAcceptsObjectNodeInBothModes() throws Exception {
+        Map<Object, Object> oneEntry = new HashMap<>();
+        oneEntry.put("a", 1);
+        oneEntry.put("b", null);
+        String[] payloads = {
+            "{\"id\":7,\"m\":{}}", "{\"id\":7,\"m\":{\"a\":1,\"b\":null}}", 
"{\"id\":7,\"m\":null}"
+        };
+        Object[] expected = {new HashMap<Object, Object>(), oneEntry, null};
+        for (boolean ignoreErrors : new boolean[] {false, true}) {
+            JsonDeserializationSchema schema =
+                    new JsonDeserializationSchema(false, ignoreErrors, 
CONTAINER_MAP_ROW_TYPE);
+            for (int i = 0; i < payloads.length; i++) {
+                String payload = payloads[i];
+                String message = payload + " (ignoreErrors=" + ignoreErrors + 
")";
+                SeaTunnelRow rowFromBytes =
+                        
schema.deserialize(payload.getBytes(StandardCharsets.UTF_8));
+                SeaTunnelRow rowFromString = schema.deserialize(payload);
+                Assertions.assertNotNull(rowFromBytes, message);
+                Assertions.assertNotNull(rowFromString, message);
+                assertEquals(7, rowFromBytes.getField(0), message);
+                assertEquals(rowFromBytes.getField(0), 
rowFromString.getField(0), message);
+                if (expected[i] == null) {
+                    assertNull(rowFromBytes.getField(1), message);
+                    assertNull(rowFromString.getField(1), message);
+                } else {
+                    assertEquals(expected[i], rowFromBytes.getField(1), 
message);
+                    assertEquals(rowFromBytes.getField(1), 
rowFromString.getField(1), message);
+                }
+            }
+        }
+    }
+
+    @Test
+    public void 
testNestedContainerMismatchKeepsEnclosingContainerInLenientMode() throws 
Exception {
+        JsonDeserializationSchema lenientSchema =
+                new JsonDeserializationSchema(false, true, 
NESTED_CONTAINER_ROW_TYPE);
+        JsonDeserializationSchema strictSchema =
+                new JsonDeserializationSchema(false, false, 
NESTED_CONTAINER_ROW_TYPE);
+
+        Map<Object, Object> firstElement = new HashMap<>();
+        firstElement.put("a", 1);
+        Map<Object, Object> secondElement = new HashMap<>();
+        secondElement.put("b", 2);
+        String outerPayload = "{\"id\":7,\"outer\":[{\"a\":1},42,{\"b\":2}]}";
+        SeaTunnelRow[] outerRows = {
+            
lenientSchema.deserialize(outerPayload.getBytes(StandardCharsets.UTF_8)),
+            lenientSchema.deserialize(outerPayload)
+        };
+        for (SeaTunnelRow row : outerRows) {
+            Assertions.assertNotNull(row, outerPayload);
+            assertEquals(7, row.getField(0), outerPayload);
+            Object[] outer = (Object[]) row.getField(1);
+            Assertions.assertNotNull(outer, outerPayload);
+            assertEquals(3, outer.length, outerPayload);
+            assertEquals(firstElement, outer[0], outerPayload);
+            assertNull(outer[1], outerPayload);
+            assertEquals(secondElement, outer[2], outerPayload);
+        }
+
+        String nestedPayload = 
"{\"id\":7,\"nested\":{\"good\":[1,null],\"bad\":true}}";
+        SeaTunnelRow[] nestedRows = {
+            
lenientSchema.deserialize(nestedPayload.getBytes(StandardCharsets.UTF_8)),
+            lenientSchema.deserialize(nestedPayload)
+        };
+        for (SeaTunnelRow row : nestedRows) {
+            Assertions.assertNotNull(row, nestedPayload);
+            assertEquals(7, row.getField(0), nestedPayload);
+            @SuppressWarnings("unchecked")
+            Map<Object, Object> nested = (Map<Object, Object>) row.getField(2);
+            Assertions.assertNotNull(nested, nestedPayload);
+            assertEquals(2, nested.size(), nestedPayload);
+            assertArrayEquals(
+                    new Integer[] {1, null}, (Integer[]) nested.get("good"), 
nestedPayload);
+            assertTrue(nested.containsKey("bad"), nestedPayload);
+            assertNull(nested.get("bad"), nestedPayload);
+        }
+
+        assertContainerTypeRejectedInStrictMode(strictSchema, outerPayload, 
"'outer'", "MAP");
+        assertContainerTypeRejectedInStrictMode(strictSchema, nestedPayload, 
"nested", "ARRAY");
+    }
+
+    private static void assertContainerFieldNullInLenientMode(
+            JsonDeserializationSchema schema, String payload) throws Exception 
{
+        SeaTunnelRow rowFromBytes = 
schema.deserialize(payload.getBytes(StandardCharsets.UTF_8));
+        SeaTunnelRow rowFromString = schema.deserialize(payload);
+        Assertions.assertNotNull(rowFromBytes, payload);
+        Assertions.assertNotNull(rowFromString, payload);
+        assertEquals(7, rowFromBytes.getField(0), payload);
+        assertEquals(7, rowFromString.getField(0), payload);
+        assertNull(rowFromBytes.getField(1), payload);
+        assertNull(rowFromString.getField(1), payload);
+    }
+
+    private static void assertContainerTypeRejectedInStrictMode(
+            JsonDeserializationSchema schema, String payload, String 
fieldName, String typeToken) {
+        SeaTunnelRuntimeException fromBytes =
+                assertThrows(
+                        SeaTunnelRuntimeException.class,
+                        () -> 
schema.deserialize(payload.getBytes(StandardCharsets.UTF_8)),
+                        payload);
+        SeaTunnelRuntimeException fromString =
+                assertThrows(
+                        SeaTunnelRuntimeException.class,
+                        () -> schema.deserialize(payload),
+                        payload);
+        assertCauseChainMentions(fromBytes, fieldName, typeToken, payload);
+        assertCauseChainMentions(fromString, fieldName, typeToken, payload);
+    }
+
+    private static void assertCauseChainMentions(
+            Throwable failure, String fieldName, String typeToken, String 
payload) {
+        Throwable current = failure;
+        while (current != null) {
+            String message = current.getMessage();
+            if (message != null && message.contains(fieldName) && 
message.contains(typeToken)) {
+                return;
+            }
+            current = current.getCause();
+        }
+        throw new AssertionError(
+                payload
+                        + " must fail with a cause mentioning "
+                        + fieldName
+                        + " and "
+                        + typeToken
+                        + ", but was: "
+                        + failure);
+    }
 }

Reply via email to