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

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


The following commit(s) were added to refs/heads/master by this push:
     new 2d0db9efe5 [common] Make quoting mean literal in array and row string 
cast rules (#9769)
2d0db9efe5 is described below

commit 2d0db9efe5527194764f305104149cdc3f21c7ac
Author: YangJie <[email protected]>
AuthorDate: Sun Sep 13 22:38:51 2026 -0400

    [common] Make quoting mean literal in array and row string cast rules 
(#9769)
---
 .../paimon/casting/StringToArrayCastRule.java      |  49 +-----
 .../apache/paimon/casting/StringToRowCastRule.java |  57 +------
 .../org/apache/paimon/casting/TokenSplitter.java   | 141 +++++++++++++++++
 .../apache/paimon/casting/CastExecutorTest.java    | 169 +++++++++++++++++++++
 4 files changed, 323 insertions(+), 93 deletions(-)

diff --git 
a/paimon-common/src/main/java/org/apache/paimon/casting/StringToArrayCastRule.java
 
b/paimon-common/src/main/java/org/apache/paimon/casting/StringToArrayCastRule.java
index 2e48e0b825..45a1cb1d97 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/casting/StringToArrayCastRule.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/casting/StringToArrayCastRule.java
@@ -26,11 +26,9 @@ import org.apache.paimon.types.DataType;
 import org.apache.paimon.types.DataTypeFamily;
 import org.apache.paimon.types.DataTypeRoot;
 import org.apache.paimon.types.VarCharType;
-import org.apache.paimon.utils.StringUtils;
 
 import java.util.ArrayList;
 import java.util.List;
-import java.util.Stack;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -114,52 +112,15 @@ class StringToArrayCastRule extends 
AbstractCastRule<BinaryString, InternalArray
     private List<Object> parseArrayElements(
             String content, CastExecutor<BinaryString, Object> 
elementCastExecutor) {
         List<Object> elements = new ArrayList<>();
-        for (String token : splitArrayElements(content)) {
-            String trimmedToken = token.trim();
+        for (TokenSplitter.Token token : TokenSplitter.split(content)) {
+            String value = token.value();
+            // only an unquoted null is the null element; "null" is the 
four-character string
             Object element =
-                    "null".equals(trimmedToken)
+                    !token.literal() && "null".equals(value)
                             ? null
-                            : 
elementCastExecutor.cast(BinaryString.fromString(trimmedToken));
+                            : 
elementCastExecutor.cast(BinaryString.fromString(value));
             elements.add(element);
         }
         return elements;
     }
-
-    private List<String> splitArrayElements(String content) {
-        List<String> elements = new ArrayList<>();
-        StringBuilder current = new StringBuilder();
-        Stack<Character> bracketStack = new Stack<>();
-        boolean inQuotes = false;
-        boolean escaped = false;
-
-        for (char c : content.toCharArray()) {
-            if (escaped) {
-                escaped = false;
-            } else if (c == '\\') {
-                escaped = true;
-            } else if (c == '"') {
-                inQuotes = !inQuotes;
-            } else if (!inQuotes) {
-                if (StringUtils.isOpenBracket(c)) {
-                    bracketStack.push(c);
-                } else if (StringUtils.isCloseBracket(c) && 
!bracketStack.isEmpty()) {
-                    bracketStack.pop();
-                } else if (c == ',' && bracketStack.isEmpty()) {
-                    addCurrentElement(elements, current);
-                    continue;
-                }
-            }
-            current.append(c);
-        }
-
-        addCurrentElement(elements, current);
-        return elements;
-    }
-
-    private void addCurrentElement(List<String> elements, StringBuilder 
current) {
-        if (current.length() > 0) {
-            elements.add(current.toString());
-            current.setLength(0);
-        }
-    }
 }
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/casting/StringToRowCastRule.java
 
b/paimon-common/src/main/java/org/apache/paimon/casting/StringToRowCastRule.java
index 5a2379d273..1ceaf1c2bc 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/casting/StringToRowCastRule.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/casting/StringToRowCastRule.java
@@ -26,11 +26,8 @@ import org.apache.paimon.types.DataTypeFamily;
 import org.apache.paimon.types.DataTypeRoot;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.types.VarCharType;
-import org.apache.paimon.utils.StringUtils;
 
-import java.util.ArrayList;
 import java.util.List;
-import java.util.Stack;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -93,7 +90,7 @@ class StringToRowCastRule extends 
AbstractCastRule<BinaryString, InternalRow> {
             if (content.isEmpty()) {
                 return createNullRow(fieldCount);
             }
-            List<String> fieldValues = splitRowFields(content);
+            List<TokenSplitter.Token> fieldValues = 
TokenSplitter.split(content);
             if (fieldValues.size() != fieldCount) {
                 throw new RuntimeException(
                         "Row field count mismatch. Expected: "
@@ -137,60 +134,22 @@ class StringToRowCastRule extends 
AbstractCastRule<BinaryString, InternalRow> {
     }
 
     private GenericRow createRowFromFields(
-            List<String> fieldValues,
+            List<TokenSplitter.Token> fieldValues,
             CastExecutor<BinaryString, Object>[] fieldCastExecutors,
             int fieldCount) {
         GenericRow row = new GenericRow(fieldCount);
         for (int i = 0; i < fieldCount; i++) {
-            String fieldValue = fieldValues.get(i).trim();
-            Object value = parseFieldValue(fieldValue, fieldCastExecutors[i]);
-            row.setField(i, value);
+            row.setField(i, parseFieldValue(fieldValues.get(i), 
fieldCastExecutors[i]));
         }
         return row;
     }
 
     private Object parseFieldValue(
-            String fieldValue, CastExecutor<BinaryString, Object> 
castExecutor) {
-        return "null".equals(fieldValue)
+            TokenSplitter.Token token, CastExecutor<BinaryString, Object> 
castExecutor) {
+        String value = token.value();
+        // only an unquoted null is the null field; "null" is the 
four-character string
+        return !token.literal() && "null".equals(value)
                 ? null
-                : castExecutor.cast(BinaryString.fromString(fieldValue));
-    }
-
-    private List<String> splitRowFields(String content) {
-        List<String> fields = new ArrayList<>();
-        StringBuilder current = new StringBuilder();
-        Stack<Character> bracketStack = new Stack<>();
-        boolean inQuotes = false;
-        boolean escaped = false;
-
-        for (char c : content.toCharArray()) {
-            if (escaped) {
-                escaped = false;
-            } else if (c == '\\') {
-                escaped = true;
-            } else if (c == '"') {
-                inQuotes = !inQuotes;
-            } else if (!inQuotes) {
-                if (StringUtils.isOpenBracket(c)) {
-                    bracketStack.push(c);
-                } else if (StringUtils.isCloseBracket(c) && 
!bracketStack.isEmpty()) {
-                    bracketStack.pop();
-                } else if (c == ',' && bracketStack.isEmpty()) {
-                    addCurrentField(fields, current);
-                    continue;
-                }
-            }
-            current.append(c);
-        }
-
-        addCurrentField(fields, current);
-        return fields;
-    }
-
-    private void addCurrentField(List<String> fields, StringBuilder current) {
-        if (current.length() > 0) {
-            fields.add(current.toString());
-            current.setLength(0);
-        }
+                : castExecutor.cast(BinaryString.fromString(value));
     }
 }
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/casting/TokenSplitter.java 
b/paimon-common/src/main/java/org/apache/paimon/casting/TokenSplitter.java
new file mode 100644
index 0000000000..54059675db
--- /dev/null
+++ b/paimon-common/src/main/java/org/apache/paimon/casting/TokenSplitter.java
@@ -0,0 +1,141 @@
+/*
+ * 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.paimon.casting;
+
+import org.apache.paimon.utils.StringUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Stack;
+
+/**
+ * Splits the comma-separated body of an array or row literal into its tokens, 
honouring quotes,
+ * escapes and nesting.
+ *
+ * <p>A separator only separates outside quotes and at bracket depth zero, so 
{@code "a,b"} and
+ * {@code [a, b]} each stay one token.
+ *
+ * <p>Quotes and backslashes are this level's syntax, so they are removed at 
depth zero and kept
+ * verbatim inside a nested literal, where they are the inner level's syntax 
and the rule for that
+ * element parses them again. Whether they appeared at depth zero is 
remembered: quoting or escaping
+ * is how the literal text {@code null} and the empty string are written, 
which are otherwise
+ * unrepresentable.
+ *
+ * <p>Whitespace around a token is dropped; whitespace inside quotes is kept.
+ */
+class TokenSplitter {
+
+    /** One token of a literal body, plus whether its value was written as a 
literal. */
+    static class Token {
+
+        private final String value;
+        private final boolean literal;
+
+        Token(String value, boolean literal) {
+            this.value = value;
+            this.literal = literal;
+        }
+
+        String value() {
+            return value;
+        }
+
+        /** Whether quotes or an escape made this a literal string rather than 
a bare word. */
+        boolean literal() {
+            return literal;
+        }
+    }
+
+    private TokenSplitter() {}
+
+    static List<Token> split(String content) {
+        List<Token> tokens = new ArrayList<>();
+        StringBuilder current = new StringBuilder();
+        Stack<Character> bracketStack = new Stack<>();
+        boolean inQuotes = false;
+        boolean escaped = false;
+        boolean literal = false;
+        // length of current up to the last character that was not unquoted 
whitespace
+        int end = 0;
+
+        for (char c : content.toCharArray()) {
+            boolean nested = !bracketStack.isEmpty();
+            if (escaped) {
+                // the escapee stands for itself and is never read as syntax
+                escaped = false;
+                current.append(c);
+                end = current.length();
+                continue;
+            }
+            if (c == '\\') {
+                escaped = true;
+                if (nested) {
+                    // the inner rule has to see the escape to protect its own 
separators
+                    current.append(c);
+                    end = current.length();
+                } else {
+                    literal = true;
+                }
+                continue;
+            }
+            if (c == '"') {
+                inQuotes = !inQuotes;
+                if (nested) {
+                    current.append(c);
+                    end = current.length();
+                } else {
+                    literal = true;
+                }
+                continue;
+            }
+            if (!inQuotes) {
+                if (StringUtils.isOpenBracket(c)) {
+                    bracketStack.push(c);
+                } else if (StringUtils.isCloseBracket(c) && 
!bracketStack.isEmpty()) {
+                    bracketStack.pop();
+                } else if (c == ',' && bracketStack.isEmpty()) {
+                    addToken(tokens, current, end, literal);
+                    current.setLength(0);
+                    end = 0;
+                    literal = false;
+                    continue;
+                } else if (Character.isWhitespace(c) && end == 0) {
+                    // leading whitespace outside quotes is not part of the 
token
+                    continue;
+                }
+            }
+            current.append(c);
+            if (inQuotes || !Character.isWhitespace(c)) {
+                end = current.length();
+            }
+        }
+
+        addToken(tokens, current, end, literal);
+        return tokens;
+    }
+
+    private static void addToken(
+            List<Token> tokens, StringBuilder current, int end, boolean 
literal) {
+        // whitespace is not part of a token, so one made only of whitespace 
was never written;
+        // quoting is how an empty value is written
+        if (end > 0 || literal) {
+            tokens.add(new Token(current.substring(0, end), literal));
+        }
+    }
+}
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/casting/CastExecutorTest.java 
b/paimon-common/src/test/java/org/apache/paimon/casting/CastExecutorTest.java
index 31a776c87d..8325687c21 100644
--- 
a/paimon-common/src/test/java/org/apache/paimon/casting/CastExecutorTest.java
+++ 
b/paimon-common/src/test/java/org/apache/paimon/casting/CastExecutorTest.java
@@ -975,6 +975,175 @@ public class CastExecutorTest {
                 BinaryString.fromString("{1, {2025-01-06, {1 -> [1, null, 2]}, 
null}}"));
     }
 
+    @Test
+    public void testStringToArrayQuotingAndEscaping() {
+        ArrayType arrayType = new ArrayType(DataTypes.STRING());
+        CastExecutor<BinaryString, InternalArray> cast =
+                (CastExecutor<BinaryString, InternalArray>)
+                        CastExecutors.resolve(VarCharType.STRING_TYPE, 
arrayType);
+
+        // quotes group a token across the separator and do not survive into 
the value
+        compareCastResult(
+                cast,
+                BinaryString.fromString("[\"a,b\", c]"),
+                new GenericArray(
+                        new Object[] {
+                            BinaryString.fromString("a,b"), 
BinaryString.fromString("c")
+                        }));
+
+        // quoting is how an empty string is written, so the element must be 
kept
+        compareCastResult(
+                cast,
+                BinaryString.fromString("[\"\", a]"),
+                new GenericArray(
+                        new Object[] {BinaryString.fromString(""), 
BinaryString.fromString("a")}));
+
+        // an unquoted null is the null element; a quoted one is the 
four-character string
+        compareCastResult(
+                cast,
+                BinaryString.fromString("[null, \"null\"]"),
+                new GenericArray(new Object[] {null, 
BinaryString.fromString("null")}));
+
+        // a backslash escapes the next character and is itself syntax
+        compareCastResult(
+                cast,
+                BinaryString.fromString("[a\\,b, c]"),
+                new GenericArray(
+                        new Object[] {
+                            BinaryString.fromString("a,b"), 
BinaryString.fromString("c")
+                        }));
+    }
+
+    @Test
+    public void testStringToRowQuotingAndEscaping() {
+        RowType rowType =
+                DataTypes.ROW(
+                        DataTypes.FIELD(0, "f0", DataTypes.STRING()),
+                        DataTypes.FIELD(1, "f1", DataTypes.INT()));
+        CastExecutor<BinaryString, InternalRow> cast =
+                (CastExecutor<BinaryString, InternalRow>)
+                        CastExecutors.resolve(VarCharType.STRING_TYPE, 
rowType);
+
+        compareCastResult(
+                cast,
+                BinaryString.fromString("{\"a,b\", 2}"),
+                GenericRow.of(BinaryString.fromString("a,b"), 2));
+
+        // an empty quoted field stays a field, so the field count still 
matches
+        compareCastResult(
+                cast,
+                BinaryString.fromString("{\"\", 2}"),
+                GenericRow.of(BinaryString.fromString(""), 2));
+
+        // a quoted null is the string, an unquoted one is SQL NULL
+        compareCastResult(
+                cast,
+                BinaryString.fromString("{\"null\", 2}"),
+                GenericRow.of(BinaryString.fromString("null"), 2));
+        compareCastResult(cast, BinaryString.fromString("{null, 2}"), 
GenericRow.of(null, 2));
+
+        compareCastResult(
+                cast,
+                BinaryString.fromString("{a\\,b, 2}"),
+                GenericRow.of(BinaryString.fromString("a,b"), 2));
+    }
+
+    @Test
+    public void testStringToNestedArrayKeepsInnerSyntax() {
+        // quotes and escapes belong to whichever level wrote them: the outer 
split must leave a
+        // nested literal's own syntax in place for the element rule to parse 
again, or the inner
+        // separator stops being protected and the element count changes
+        ArrayType nested = new ArrayType(new ArrayType(DataTypes.STRING()));
+        CastExecutor<BinaryString, InternalArray> cast =
+                (CastExecutor<BinaryString, InternalArray>)
+                        CastExecutors.resolve(VarCharType.STRING_TYPE, nested);
+
+        assertNestedElements(cast, "[[\"a,b\"], [c]]", new String[] {"a,b"}, 
new String[] {"c"});
+        assertNestedElements(cast, "[[a\\,b], [c]]", new String[] {"a,b"}, new 
String[] {"c"});
+        assertNestedElements(cast, "[[\"null\"], [a]]", new String[] {"null"}, 
new String[] {"a"});
+        assertNestedElements(cast, "[[\"\"], [a]]", new String[] {""}, new 
String[] {"a"});
+        assertNestedElements(cast, "[[\" a \"], [b]]", new String[] {" a "}, 
new String[] {"b"});
+        assertNestedElements(cast, "[[1, 2], [3]]", new String[] {"1", "2"}, 
new String[] {"3"});
+    }
+
+    private static void assertNestedElements(
+            CastExecutor<BinaryString, InternalArray> cast, String literal, 
String[]... expected) {
+        InternalArray outer = cast.cast(BinaryString.fromString(literal));
+        assertThat(outer.size()).as("outer size of %s", 
literal).isEqualTo(expected.length);
+        for (int i = 0; i < expected.length; i++) {
+            InternalArray inner = outer.getArray(i);
+            assertThat(inner.size())
+                    .as("inner size of %s at %s", literal, i)
+                    .isEqualTo(expected[i].length);
+            for (int j = 0; j < expected[i].length; j++) {
+                assertThat(inner.getString(j).toString())
+                        .as("element %s.%s of %s", i, j, literal)
+                        .isEqualTo(expected[i][j]);
+            }
+        }
+    }
+
+    @Test
+    public void testStringToArrayEscapedNullIsALiteral() {
+        ArrayType arrayType = new ArrayType(DataTypes.STRING());
+        CastExecutor<BinaryString, InternalArray> cast =
+                (CastExecutor<BinaryString, InternalArray>)
+                        CastExecutors.resolve(VarCharType.STRING_TYPE, 
arrayType);
+
+        // escaping, like quoting, says the token is written text rather than 
the null literal
+        compareCastResult(
+                cast,
+                BinaryString.fromString("[\\null, x]"),
+                new GenericArray(
+                        new Object[] {
+                            BinaryString.fromString("null"), 
BinaryString.fromString("x")
+                        }));
+    }
+
+    @Test
+    public void testStringToRowWhitespaceOnlyFieldIsNotAField() {
+        RowType rowType =
+                DataTypes.ROW(
+                        DataTypes.FIELD(0, "f0", DataTypes.STRING()),
+                        DataTypes.FIELD(1, "f1", DataTypes.STRING()),
+                        DataTypes.FIELD(2, "f2", DataTypes.STRING()));
+        CastExecutor<BinaryString, InternalRow> cast =
+                (CastExecutor<BinaryString, InternalRow>)
+                        CastExecutors.resolve(VarCharType.STRING_TYPE, 
rowType);
+
+        // whitespace is not part of a token, so a field made only of 
whitespace was never
+        // written, and where it sits does not change that
+        for (String literal : new String[] {"{a,  ,b}", "{ ,a,b}", "{a,b, }"}) 
{
+            assertThatThrownBy(() -> 
cast.cast(BinaryString.fromString(literal)))
+                    .as("%s", literal)
+                    .hasMessageContaining("Row field count mismatch. Expected: 
3, Actual: 2");
+        }
+
+        // quoting is how an empty field is written
+        compareCastResult(
+                cast,
+                BinaryString.fromString("{a, \"\", b}"),
+                GenericRow.of(
+                        BinaryString.fromString("a"),
+                        BinaryString.fromString(""),
+                        BinaryString.fromString("b")));
+    }
+
+    @Test
+    public void testStringToArraySkipsAnEmptyElement() {
+        ArrayType arrayType = new ArrayType(DataTypes.INT());
+        CastExecutor<BinaryString, InternalArray> cast =
+                (CastExecutor<BinaryString, InternalArray>)
+                        CastExecutors.resolve(VarCharType.STRING_TYPE, 
arrayType);
+
+        // an element written as nothing, with or without whitespace, is no 
element: handing the
+        // empty string to the int cast instead would fail the whole array
+        compareCastResult(
+                cast, BinaryString.fromString("[1,,3]"), new GenericArray(new 
Integer[] {1, 3}));
+        compareCastResult(
+                cast, BinaryString.fromString("[1, , 3]"), new 
GenericArray(new Integer[] {1, 3}));
+    }
+
     @Test
     public void testSplitMapEntriesWithQuotes() {
         String content = "1, \"abc\"";

Reply via email to