This is an automated email from the ASF dual-hosted git repository.
garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-csv.git
The following commit(s) were added to refs/heads/master by this push:
new f9e7d792 Keep an escaped value that equals the null string (#626).
f9e7d792 is described below
commit f9e7d792dc6cb8a46f3e164e5cc50e38dc65264b
Author: Gary Gregory <[email protected]>
AuthorDate: Sat Aug 29 08:04:18 2026 -0400
Keep an escaped value that equals the null string (#626).
PR plus fixes to the PR to pass the build.
---
src/changes/changes.xml | 1 +
src/main/java/org/apache/commons/csv/CSVParser.java | 5 ++++-
src/main/java/org/apache/commons/csv/Lexer.java | 3 +++
src/main/java/org/apache/commons/csv/Token.java | 4 ++++
.../java/org/apache/commons/csv/CSVParserTest.java | 18 ++++++++++++++++++
.../java/org/apache/commons/csv/CSVPrinterTest.java | 20 ++++++++++++++++----
6 files changed, 46 insertions(+), 5 deletions(-)
diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 463ad12e..f3189baa 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -76,6 +76,7 @@
<action type="fix" dev="ggregory" due-to="Gary Gregory, Naveed Khan">Fix
quadratic CSVFormat.printWithEscapes(Reader) delimiter look-ahead
(#631).</action>
<action type="fix" dev="ggregory" due-to="Gary Gregory">General Javadoc
improvements.</action>
<action type="fix" dev="ggregory" due-to="Gary Gregory, Naveed
Khan">Validate CSVFormat invariants when deserializing (#632).</action>
+ <action type="fix" dev="ggregory" due-to="Gary Gregory, saleem
malik">Keep an escaped value that equals the null string (#626).</action>
<!-- ADD -->
<action type="add" dev="ggregory" due-to="Gary Gregory, Indy, Sylvia van
Os" issue="CSV-307">Add an "Android Compatibility" section to the web
site.</action>
<action type="add" dev="ggregory" due-to="Ruiqi Dong, Gary Gregory"
issue="CSV-325">Add CSVParser.Builder.setByteOffset(long) (#604).</action>
diff --git a/src/main/java/org/apache/commons/csv/CSVParser.java
b/src/main/java/org/apache/commons/csv/CSVParser.java
index 6d2a3b99..eb4c0d32 100644
--- a/src/main/java/org/apache/commons/csv/CSVParser.java
+++ b/src/main/java/org/apache/commons/csv/CSVParser.java
@@ -819,8 +819,11 @@ public final class CSVParser implements
Iterable<CSVRecord>, Closeable {
final String nullString = format.getNullString();
final boolean strictQuoteMode = isStrictQuoteMode();
if (input.equals(nullString)) {
+ // A token that needed an escape translation cannot be the null
marker: printing null emits the null
+ // string without escaping it (it may be quoted, but never
escaped), so an escaped "\N" for nullString
+ // "\N" can only have come from a field whose value really is "\N".
// nullString = NULL(String), distinguish between "NULL" and NULL
in ALL_NON_NULL or NON_NUMERIC quote mode
- return strictQuoteMode && isQuoted ? input : null;
+ return reusableToken.isEscaped || strictQuoteMode && isQuoted ?
input : null;
}
// don't set nullString, distinguish between "" and ,, (absent values)
in All_NON_NULL or NON_NUMERIC quote mode
return strictQuoteMode && nullString == null && input.isEmpty() &&
!isQuoted ? null : input;
diff --git a/src/main/java/org/apache/commons/csv/Lexer.java
b/src/main/java/org/apache/commons/csv/Lexer.java
index c78d1c7b..12f1aa06 100644
--- a/src/main/java/org/apache/commons/csv/Lexer.java
+++ b/src/main/java/org/apache/commons/csv/Lexer.java
@@ -76,12 +76,15 @@ final class Lexer implements Closeable {
private void appendNextEscapedCharacterToToken(final Token token) throws
IOException {
if (isEscapeDelimiter()) {
token.content.append(delimiter);
+ token.isEscaped = true;
} else {
final int unescaped = readEscape();
if (unescaped == EOF) { // unexpected char after escape
+ // The escape character is kept verbatim, so nothing was
translated.
token.content.append((char) escape).append((char)
reader.getLastChar());
} else {
token.content.append((char) unescaped);
+ token.isEscaped = true;
}
}
}
diff --git a/src/main/java/org/apache/commons/csv/Token.java
b/src/main/java/org/apache/commons/csv/Token.java
index 42c4e3bd..bde790a6 100644
--- a/src/main/java/org/apache/commons/csv/Token.java
+++ b/src/main/java/org/apache/commons/csv/Token.java
@@ -61,11 +61,15 @@ final class Token {
boolean isQuoted;
+ /** True when an escape sequence in the input was translated while
building {@link #content}. */
+ boolean isEscaped;
+
void reset() {
content.setLength(0);
type = INVALID;
isReady = false;
isQuoted = false;
+ isEscaped = false;
}
/**
diff --git a/src/test/java/org/apache/commons/csv/CSVParserTest.java
b/src/test/java/org/apache/commons/csv/CSVParserTest.java
index 5300a63a..50a6ae03 100644
--- a/src/test/java/org/apache/commons/csv/CSVParserTest.java
+++ b/src/test/java/org/apache/commons/csv/CSVParserTest.java
@@ -662,6 +662,24 @@ class CSVParserTest {
}
}
+ @ParameterizedTest
+ @EnumSource(value = CSVFormat.Predefined.class, names = { "MySQL",
"PostgreSQLCsv", "PostgreSQLText", "Oracle" })
+ void testEscapedNullStringIsAValue(final CSVFormat.Predefined predefined)
throws Exception {
+ // "\N" is the null string for MySQL, PostgreSQL Text and Oracle;
PostgreSQL CSV uses an empty null
+ // string. In every case a field whose value equals "\N" must round
trip as that value, not as null.
+ final String valueEqualToNullString = "\\N";
+ final CSVFormat format = predefined.getFormat();
+ final StringWriter writer = new StringWriter();
+ try (CSVPrinter printer = new CSVPrinter(writer, format)) {
+ printer.printRecord(valueEqualToNullString, null);
+ }
+ try (CSVParser parser = CSVParser.parse(writer.toString(), format)) {
+ final CSVRecord record = parser.nextRecord();
+ assertEquals(valueEqualToNullString, record.get(0));
+ assertNull(record.get(1));
+ }
+ }
+
@Test
void testExcelFormat1() throws IOException {
final String code = "value1,value2,value3,value4\r\na,b,c,d\r\n
x,,,\r\n\r\n\"\"\"hello\"\"\",\" \"\"world\"\"\",\"abc\ndef\",\r\n";
diff --git a/src/test/java/org/apache/commons/csv/CSVPrinterTest.java
b/src/test/java/org/apache/commons/csv/CSVPrinterTest.java
index 1c25821e..f1f910bb 100644
--- a/src/test/java/org/apache/commons/csv/CSVPrinterTest.java
+++ b/src/test/java/org/apache/commons/csv/CSVPrinterTest.java
@@ -158,13 +158,18 @@ class CSVPrinterTest {
}
/**
- * Converts an input CSV array into expected output values, including
NULLs. NULL strings are converted to null values because the parser will convert
- * these strings to null.
+ * Converts an input CSV array into expected output values, including
NULLs. A value equal to the null string is expected back as null only when the
+ * printer writes it verbatim (neither escaped nor quoted); once it is
escaped or quoted the parser can tell it apart from a real null and reads it
back
+ * as the value it is.
*/
- private <T> T[] expectNulls(final T[] original, final CSVFormat csvFormat)
{
+ private <T> T[] expectNulls(final T[] original, final CSVFormat csvFormat)
throws IOException {
final T[] fixed = original.clone();
+ final String nullString = csvFormat.getNullString();
+ if (nullString == null || !printsVerbatim(csvFormat, nullString)) {
+ return fixed;
+ }
for (int i = 0; i < fixed.length; i++) {
- if (Objects.equals(csvFormat.getNullString(), fixed[i])) {
+ if (Objects.equals(nullString, fixed[i])) {
fixed[i] = null;
}
}
@@ -196,6 +201,13 @@ class CSVPrinterTest {
return sw.toString();
}
+ /** Tests whether the format prints the given value unchanged, in which
case the parser cannot tell it from the null string. */
+ private boolean printsVerbatim(final CSVFormat csvFormat, final String
value) throws IOException {
+ final StringBuilder sb = new StringBuilder();
+ csvFormat.print(value, sb, true);
+ return value.contentEquals(sb);
+ }
+
private CSVPrinter printWithHeaderComments(final StringWriter sw, final
Date now, final CSVFormat baseFormat) throws IOException {
// Use withHeaderComments first to test CSV-145
// @formatter:off