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 604b99e2cb [format] Preserve quotes and escapes in unquoted CSV fields 
(#10474)
604b99e2cb is described below

commit 604b99e2cba27f34f00cd588df786b7c7cf63223
Author: Zouxxyy <[email protected]>
AuthorDate: Sat Oct 10 14:06:43 2026 +0800

    [format] Preserve quotes and escapes in unquoted CSV fields (#10474)
---
 .../apache/paimon/format/csv/CsvFormatWriter.java  |   5 +-
 .../org/apache/paimon/format/csv/CsvParser.java    |  40 +++---
 .../paimon/format/csv/CsvFileFormatTest.java       |  44 ++++++-
 .../apache/paimon/format/csv/CsvParserTest.java    | 137 +++++++++++++++++++++
 4 files changed, 204 insertions(+), 22 deletions(-)

diff --git 
a/paimon-format/src/main/java/org/apache/paimon/format/csv/CsvFormatWriter.java 
b/paimon-format/src/main/java/org/apache/paimon/format/csv/CsvFormatWriter.java
index b9a7d37fff..cd9b950274 100644
--- 
a/paimon-format/src/main/java/org/apache/paimon/format/csv/CsvFormatWriter.java
+++ 
b/paimon-format/src/main/java/org/apache/paimon/format/csv/CsvFormatWriter.java
@@ -140,9 +140,8 @@ public class CsvFormatWriter extends AbstractTextFileWriter 
{
             return field;
         }
 
-        // Only escape if needed. The escape character goes first: CsvParser 
drops an escape
-        // character that is not followed by a quote or another escape, and 
escaping the quotes
-        // first would double the escape characters inserted for them.
+        // Escape the escape character first so that quoting the field 
preserves it. Escaping
+        // quotes first would double the escape characters inserted for them.
         String escaped = field.replace(escape, escape + escape);
         return quote + escaped.replace(quote, escape + quote) + quote;
     }
diff --git 
a/paimon-format/src/main/java/org/apache/paimon/format/csv/CsvParser.java 
b/paimon-format/src/main/java/org/apache/paimon/format/csv/CsvParser.java
index d17b84d3b4..82635c4607 100644
--- a/paimon-format/src/main/java/org/apache/paimon/format/csv/CsvParser.java
+++ b/paimon-format/src/main/java/org/apache/paimon/format/csv/CsvParser.java
@@ -108,30 +108,39 @@ public class CsvParser {
         int position = 0;
         while (position < line.length() && columnIndex < rowValues.length) {
             char c = line.charAt(position);
-            if (c == escapeChar) {
-                // if the next character is special, process it here as to not 
trigger the special
-                // handling
-                if (inQuotes || inField) {
-                    int nextCharacter = peekNextCharacter(line, position);
-                    if (nextCharacter == quoteChar || nextCharacter == 
escapeChar) {
-                        buffer.append(line.charAt(position + 1));
-                        position++;
-                    }
+            // Only a quote at the start of a field enables CSV quoting and 
escaping.
+            // Quotes and backslashes in an unquoted field (for example JSON 
in TSV)
+            // belong to the field value and must not be interpreted as CSV 
syntax.
+            // A single NUL quote or escape is still used by the writer. Only 
disable
+            // quoting and escaping when both characters are NUL.
+            if (!inField
+                    && buffer.length() == 0
+                    && (quoteChar != '\0' || escapeChar != '\0')
+                    && c == quoteChar) {
+                rowQuoted[columnIndex] = true;
+            }
+            if (rowQuoted[columnIndex] && c == escapeChar) {
+                int nextCharacter = peekNextCharacter(line, position);
+                if ((inQuotes || inField)
+                        && (nextCharacter == quoteChar || nextCharacter == 
escapeChar)) {
+                    buffer.append(line.charAt(position + 1));
+                    position++;
+                } else {
+                    // An escape before an ordinary character is part of the 
value.
+                    buffer.append(c);
                 }
-            } else if (c == quoteChar) {
+            } else if (rowQuoted[columnIndex] && c == quoteChar) {
                 // a quote character can be escaped with another quote 
character
                 if ((inQuotes || inField) && peekNextCharacter(line, position) 
== quoteChar) {
                     buffer.append(line.charAt(position + 1));
                     position++;
                 } else {
-                    // the tricky case of an embedded quote in the middle: 
a,bc"d"ef,g
-                    // Embedded quote is not for first 3 characters of the 
line, and is not allowed
-                    // immediately before a separator
+                    // Preserve the existing handling of unescaped quotes in 
quoted fields.
                     if (position > 2
                             && line.charAt(position - 1) != separatorChar
                             && line.length() > (position + 1)
                             && line.charAt(position + 1) != separatorChar) {
-                        // if field starts begins whitespace, skip the 
whitespace and quote
+                        // Discard whitespace accumulated before an unescaped 
quote.
                         if (buffer.length() != 0 && isAllWhitespace(buffer)) {
                             buffer.setLength(0);
                         } else {
@@ -139,9 +148,6 @@ public class CsvParser {
                             buffer.append(c);
                         }
                     }
-                    if (!inQuotes && buffer.length() == 0) {
-                        rowQuoted[columnIndex] = true;
-                    }
                     inQuotes = !inQuotes;
                 }
                 inField = !inField;
diff --git 
a/paimon-format/src/test/java/org/apache/paimon/format/csv/CsvFileFormatTest.java
 
b/paimon-format/src/test/java/org/apache/paimon/format/csv/CsvFileFormatTest.java
index 435df580dc..64418583d3 100644
--- 
a/paimon-format/src/test/java/org/apache/paimon/format/csv/CsvFileFormatTest.java
+++ 
b/paimon-format/src/test/java/org/apache/paimon/format/csv/CsvFileFormatTest.java
@@ -45,6 +45,8 @@ import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.dataformat.csv.Csv
 import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.dataformat.csv.CsvSchema;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import java.io.IOException;
 import java.math.BigDecimal;
@@ -383,6 +385,45 @@ public class CsvFileFormatTest extends FormatReadWriteTest 
{
         }
     }
 
+    @ParameterizedTest
+    @ValueSource(booleans = {true, false})
+    public void testSingleNulQuoteOrEscapeWriteRead(boolean nulQuote) throws 
IOException {
+        Options options = new Options();
+        options.set(CsvOptions.QUOTE_CHARACTER, nulQuote ? "\0" : "\"");
+        options.set(CsvOptions.ESCAPE_CHARACTER, nulQuote ? "\\" : "\0");
+        RowType rowType = DataTypes.ROW(DataTypes.STRING(), 
DataTypes.STRING(), DataTypes.STRING());
+        List<InternalRow> testData = new ArrayList<>();
+        for (String value :
+                new String[] {
+                    "a,b",
+                    "say \"hello\"",
+                    "C:\\Users\\test\\",
+                    "\0start",
+                    "middle\0value",
+                    "end\0",
+                    "plain"
+                }) {
+            testData.add(
+                    GenericRow.of(fromString(value), fromString("middle"), 
fromString("tail")));
+            testData.add(GenericRow.of(fromString("first"), fromString(value), 
fromString("tail")));
+            testData.add(
+                    GenericRow.of(fromString("first"), fromString("middle"), 
fromString(value)));
+        }
+
+        // Use the real writer and reader: a NUL quote/escape is still emitted 
when only
+        // one of the two characters is NUL. Read multiple rows to exercise 
parser reuse.
+        List<InternalRow> result =
+                writeThenRead(options, rowType, rowType, testData, 
"single_nul_" + nulQuote);
+        assertThat(result).hasSize(testData.size());
+        for (int i = 0; i < testData.size(); i++) {
+            for (int column = 0; column < rowType.getFieldCount(); column++) {
+                assertThat(result.get(i).getString(column))
+                        .as("row %s, column %s", i, column)
+                        .isEqualTo(testData.get(i).getString(column));
+            }
+        }
+    }
+
     @Test
     public void testCsvIncludeHeaderWriteRead() throws IOException {
         RowType rowType =
@@ -896,8 +937,7 @@ public class CsvFileFormatTest extends FormatReadWriteTest {
     @Test
     public void testFieldsContainingTheEscapeCharacterRoundTrip() throws 
IOException {
         RowType rowType = DataTypes.ROW(DataTypes.INT().notNull(), 
DataTypes.STRING());
-        // every one of these is written unquoted or half-quoted unless the 
escape character is
-        // itself escaped, and CsvParser then drops it
+        // Quoted fields must escape both quotes and the escape character to 
round-trip.
         String[] inputs = {
             "Special\\Characters", "trailing\\", "a,b\\", "\\\\double", 
"\\\"quoteAfterEscape"
         };
diff --git 
a/paimon-format/src/test/java/org/apache/paimon/format/csv/CsvParserTest.java 
b/paimon-format/src/test/java/org/apache/paimon/format/csv/CsvParserTest.java
new file mode 100644
index 0000000000..ea2219a7c5
--- /dev/null
+++ 
b/paimon-format/src/test/java/org/apache/paimon/format/csv/CsvParserTest.java
@@ -0,0 +1,137 @@
+/*
+ * 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.format.csv;
+
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.apache.paimon.data.BinaryString.fromString;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests CSV parsing independently of the writer that produces escaped 
fields. */
+class CsvParserTest {
+
+    private static final RowType ROW_TYPE =
+            DataTypes.ROW(DataTypes.STRING(), DataTypes.STRING(), 
DataTypes.STRING());
+
+    @ParameterizedTest
+    @ValueSource(
+            strings = {
+                "{\"userid\":\"1\",\"memo\":\"a\\nb\"}",
+                "{\"userid\":\"2\",\"memo\":\"C:\\\\Users\\\\test\"}",
+                "{\"userid\":\"3\",\"memo\":\"a\\tb\"}",
+                "{\"userid\":\"4\",\"memo\":\"a\\\"b\"}",
+                "{\"userid\":\"5\",\"memo\":\"\\u871c\\u96ea\"}",
+                "{\"userid\":\"6\",\"memo\":\"\"}",
+                "unquoted\\path\\",
+                "\\\\server\\path",
+                "embedded\"quote",
+                "doubled\"\"quotes",
+                "leading \\\"quote",
+                "  \"value\"",
+                "\\\"leading"
+            })
+    void testUnquotedFieldsPreserveQuotesAndEscapes(String value) {
+        CsvParser parser = parser("\t", "\"", "\\");
+        // Exercise each column and reuse the parser across rows.
+        assertThat(parser.parse(value + 
"\tsecond\tlast")).isEqualTo(row(value, "second", "last"));
+        assertThat(parser.parse("first\t" + value + "\tlast"))
+                .isEqualTo(row("first", value, "last"));
+        assertThat(parser.parse("first\tsecond\t" + value))
+                .isEqualTo(row("first", "second", value));
+    }
+
+    @Test
+    void testQuotedFieldsDecodeEscapesAndContainDelimiters() {
+        CsvParser parser = parser(",", "\"", "\\");
+        assertThat(parser.parse("\"a,b\",\"C:\\\\Users\\\\test\",\"say 
\\\"hello\\\"\""))
+                .isEqualTo(row("a,b", "C:\\Users\\test", "say \"hello\""));
+        // Preserve Paimon's existing doubled-quote behavior; Spark's default 
differs here.
+        assertThat(parser.parse("\"a\"\"b\",unquoted\\path,\"c\\\\d\""))
+                .isEqualTo(row("a\"b", "unquoted\\path", "c\\d"));
+    }
+
+    @Test
+    void testUnknownEscapesInQuotedFieldsArePreserved() {
+        assertThat(parser(",", "\"", 
"\\").parse("\"a\\nb\",\"\\u871c\",\"path\\q\""))
+                .isEqualTo(row("a\\nb", "\\u871c", "path\\q"));
+    }
+
+    @Test
+    void testEmptyQuotedFieldsResetQuotingState() {
+        CsvParser parser = parser(",", "\"", "\\");
+        assertThat(parser.parse("\"\",plain\\value,\"\"")).isEqualTo(row("", 
"plain\\value", ""));
+        assertThat(parser.parse("\"first\",,last\\value"))
+                .isEqualTo(GenericRow.of(fromString("first"), null, 
fromString("last\\value")));
+    }
+
+    @Test
+    void testEmbeddedQuoteDoesNotProtectDelimiter() {
+        assertThat(parser(",", "\"", "\\").parse("a\"b,c\"d,last"))
+                .isEqualTo(row("a\"b", "c\"d", "last"));
+    }
+
+    @Test
+    void testCustomQuoteAndEscapeCharacters() {
+        CsvParser parser = parser("|", "'", "/");
+        assertThat(parser.parse("raw//path/'quote|'a/'b//c'|'x|y'"))
+                .isEqualTo(row("raw//path/'quote", "a'b/c", "x|y"));
+    }
+
+    @Test
+    void testDisabledQuotingAndEscapingPreserveNullCharacters() {
+        assertThat(parser(",", "\0", "\0").parse("a\0b,\0start,end\0"))
+                .isEqualTo(row("a\0b", "\0start", "end\0"));
+    }
+
+    @Test
+    void testUnterminatedQuotedFieldRemainsNull() {
+        assertThat(parser(",", "\"", 
"\\").parse("first,second,\"unterminated"))
+                .isEqualTo(GenericRow.of(fromString("first"), 
fromString("second"), null));
+    }
+
+    @Test
+    void testQuotedNullLiteralWithProjectionAndParserReuse() {
+        Options options = new Options();
+        options.set(CsvOptions.NULL_LITERAL, "NULL");
+        CsvParser parser = new CsvParser(ROW_TYPE, new int[] {1, 0, 2}, new 
CsvOptions(options));
+        assertThat(parser.parse("NULL,\"NULL\",\"\""))
+                .isEqualTo(GenericRow.of(fromString("NULL"), null, 
fromString("")));
+        assertThat(parser.parse("\"NULL\",NULL,plain\\value"))
+                .isEqualTo(GenericRow.of(null, fromString("NULL"), 
fromString("plain\\value")));
+    }
+
+    private static CsvParser parser(String delimiter, String quote, String 
escape) {
+        Options options = new Options();
+        options.set(CsvOptions.FIELD_DELIMITER, delimiter);
+        options.set(CsvOptions.QUOTE_CHARACTER, quote);
+        options.set(CsvOptions.ESCAPE_CHARACTER, escape);
+        return new CsvParser(ROW_TYPE, new int[] {0, 1, 2}, new 
CsvOptions(options));
+    }
+
+    private static GenericRow row(String first, String second, String third) {
+        return GenericRow.of(fromString(first), fromString(second), 
fromString(third));
+    }
+}

Reply via email to