Skip to content

Commit f9e7d79

Browse files
committed
Keep an escaped value that equals the null string (#626).
PR plus fixes to the PR to pass the build.
1 parent 9c19a2f commit f9e7d79

6 files changed

Lines changed: 46 additions & 5 deletions

File tree

‎src/changes/changes.xml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
<action type="fix" dev="ggregory" due-to="Gary Gregory, Naveed Khan">Fix quadratic CSVFormat.printWithEscapes(Reader) delimiter look-ahead (#631).</action>
7777
<action type="fix" dev="ggregory" due-to="Gary Gregory">General Javadoc improvements.</action>
7878
<action type="fix" dev="ggregory" due-to="Gary Gregory, Naveed Khan">Validate CSVFormat invariants when deserializing (#632).</action>
79+
<action type="fix" dev="ggregory" due-to="Gary Gregory, saleem malik">Keep an escaped value that equals the null string (#626).</action>
7980
<!-- ADD -->
8081
<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>
8182
<action type="add" dev="ggregory" due-to="Ruiqi Dong, Gary Gregory" issue="CSV-325">Add CSVParser.Builder.setByteOffset(long) (#604).</action>

‎src/main/java/org/apache/commons/csv/CSVParser.java‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -819,8 +819,11 @@ private String handleNull(final String input) {
819819
final String nullString = format.getNullString();
820820
final boolean strictQuoteMode = isStrictQuoteMode();
821821
if (input.equals(nullString)) {
822+
// A token that needed an escape translation cannot be the null marker: printing null emits the null
823+
// string without escaping it (it may be quoted, but never escaped), so an escaped "\N" for nullString
824+
// "\N" can only have come from a field whose value really is "\N".
822825
// nullString = NULL(String), distinguish between "NULL" and NULL in ALL_NON_NULL or NON_NUMERIC quote mode
823-
return strictQuoteMode && isQuoted ? input : null;
826+
return reusableToken.isEscaped || strictQuoteMode && isQuoted ? input : null;
824827
}
825828
// don't set nullString, distinguish between "" and ,, (absent values) in All_NON_NULL or NON_NUMERIC quote mode
826829
return strictQuoteMode && nullString == null && input.isEmpty() && !isQuoted ? null : input;

‎src/main/java/org/apache/commons/csv/Lexer.java‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,15 @@ final class Lexer implements Closeable {
7676
private void appendNextEscapedCharacterToToken(final Token token) throws IOException {
7777
if (isEscapeDelimiter()) {
7878
token.content.append(delimiter);
79+
token.isEscaped = true;
7980
} else {
8081
final int unescaped = readEscape();
8182
if (unescaped == EOF) { // unexpected char after escape
83+
// The escape character is kept verbatim, so nothing was translated.
8284
token.content.append((char) escape).append((char) reader.getLastChar());
8385
} else {
8486
token.content.append((char) unescaped);
87+
token.isEscaped = true;
8588
}
8689
}
8790
}

‎src/main/java/org/apache/commons/csv/Token.java‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,15 @@ enum Type {
6161

6262
boolean isQuoted;
6363

64+
/** True when an escape sequence in the input was translated while building {@link #content}. */
65+
boolean isEscaped;
66+
6467
void reset() {
6568
content.setLength(0);
6669
type = INVALID;
6770
isReady = false;
6871
isQuoted = false;
72+
isEscaped = false;
6973
}
7074

7175
/**

‎src/test/java/org/apache/commons/csv/CSVParserTest.java‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,24 @@ void testEndOfFileBehaviorExcel() throws Exception {
662662
}
663663
}
664664

665+
@ParameterizedTest
666+
@EnumSource(value = CSVFormat.Predefined.class, names = { "MySQL", "PostgreSQLCsv", "PostgreSQLText", "Oracle" })
667+
void testEscapedNullStringIsAValue(final CSVFormat.Predefined predefined) throws Exception {
668+
// "\N" is the null string for MySQL, PostgreSQL Text and Oracle; PostgreSQL CSV uses an empty null
669+
// string. In every case a field whose value equals "\N" must round trip as that value, not as null.
670+
final String valueEqualToNullString = "\\N";
671+
final CSVFormat format = predefined.getFormat();
672+
final StringWriter writer = new StringWriter();
673+
try (CSVPrinter printer = new CSVPrinter(writer, format)) {
674+
printer.printRecord(valueEqualToNullString, null);
675+
}
676+
try (CSVParser parser = CSVParser.parse(writer.toString(), format)) {
677+
final CSVRecord record = parser.nextRecord();
678+
assertEquals(valueEqualToNullString, record.get(0));
679+
assertNull(record.get(1));
680+
}
681+
}
682+
665683
@Test
666684
void testExcelFormat1() throws IOException {
667685
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";

‎src/test/java/org/apache/commons/csv/CSVPrinterTest.java‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,13 +158,18 @@ private void doRandom(final CSVFormat format, final int iter) throws Exception {
158158
}
159159

160160
/**
161-
* Converts an input CSV array into expected output values, including NULLs. NULL strings are converted to null values because the parser will convert
162-
* these strings to null.
161+
* 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
162+
* 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
163+
* as the value it is.
163164
*/
164-
private <T> T[] expectNulls(final T[] original, final CSVFormat csvFormat) {
165+
private <T> T[] expectNulls(final T[] original, final CSVFormat csvFormat) throws IOException {
165166
final T[] fixed = original.clone();
167+
final String nullString = csvFormat.getNullString();
168+
if (nullString == null || !printsVerbatim(csvFormat, nullString)) {
169+
return fixed;
170+
}
166171
for (int i = 0; i < fixed.length; i++) {
167-
if (Objects.equals(csvFormat.getNullString(), fixed[i])) {
172+
if (Objects.equals(nullString, fixed[i])) {
168173
fixed[i] = null;
169174
}
170175
}
@@ -196,6 +201,13 @@ private String printNullRecord(final CSVFormat format) throws IOException {
196201
return sw.toString();
197202
}
198203

204+
/** Tests whether the format prints the given value unchanged, in which case the parser cannot tell it from the null string. */
205+
private boolean printsVerbatim(final CSVFormat csvFormat, final String value) throws IOException {
206+
final StringBuilder sb = new StringBuilder();
207+
csvFormat.print(value, sb, true);
208+
return value.contentEquals(sb);
209+
}
210+
199211
private CSVPrinter printWithHeaderComments(final StringWriter sw, final Date now, final CSVFormat baseFormat) throws IOException {
200212
// Use withHeaderComments first to test CSV-145
201213
// @formatter:off

0 commit comments

Comments
 (0)