From 1022094157af387b1db5738dd3f4e6a03f43950d Mon Sep 17 00:00:00 2001 From: Naveed Khan Date: Tue, 28 Jul 2026 13:37:13 +0530 Subject: [PATCH 1/2] quote null value that starts a record in minimal quote mode a null value bypasses `printWithQuotes`, so the empty-value encapsulation minimal mode does at the start of a record never runs and a single-column null record prints as a blank line that `ignoreEmptyLines` drops on read. --- .../org/apache/commons/csv/CSVFormat.java | 21 ++++++++++- .../org/apache/commons/csv/CSVFormatTest.java | 2 +- .../apache/commons/csv/CSVPrinterTest.java | 35 +++++++++++++++++-- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/apache/commons/csv/CSVFormat.java b/src/main/java/org/apache/commons/csv/CSVFormat.java index 5de046aca..18965a6cf 100644 --- a/src/main/java/org/apache/commons/csv/CSVFormat.java +++ b/src/main/java/org/apache/commons/csv/CSVFormat.java @@ -2134,6 +2134,15 @@ public boolean isEscapeCharacterSet() { return escapeCharacter != null; } + /** + * Tests whether the quote policy encapsulates values only when needed, which is also the policy when none is set. + * + * @return {@code true} if the {@link QuoteMode} is {@link QuoteMode#MINIMAL} or unset. + */ + private boolean isMinimalQuoteMode() { + return quoteMode == null || quoteMode == QuoteMode.MINIMAL; + } + /** * Tests whether a null string has been defined. * @@ -2272,7 +2281,17 @@ private void print(final Object object, final CharSequence value, final Appendab out.append(getDelimiterString()); } if (object == null) { - out.append(value); + if (len == 0 && newRecord && isQuoteCharacterSet() && isMinimalQuoteMode()) { + // Encapsulate like printWithQuotes does for an empty value that starts a record: an + // unquoted one makes the whole line empty, and a parser with ignoreEmptyLines enabled + // then drops the record. ALL_NON_NULL and NON_NUMERIC are excluded because they encode + // null as the bare empty field. + final char quoteChar = quoteCharacter.charValue(); // Explicit unboxing is intentional + out.append(quoteChar); + out.append(quoteChar); + } else { + out.append(value); + } } else if (isQuoteCharacterSet()) { // The original object is needed so can check for Number printWithQuotes(object, value, out, newRecord); diff --git a/src/test/java/org/apache/commons/csv/CSVFormatTest.java b/src/test/java/org/apache/commons/csv/CSVFormatTest.java index 2411e53f0..999527cdb 100644 --- a/src/test/java/org/apache/commons/csv/CSVFormatTest.java +++ b/src/test/java/org/apache/commons/csv/CSVFormatTest.java @@ -1557,7 +1557,7 @@ void testWithHeaderComments() { assertNotEquals(csvFormat, csvFormatTwo); // CSV-244 - should not be equal assertNotEquals(csvFormatTwo, csvFormat); // CSV-244 - should not be equal - assertEquals(",,,,,,,", string); + assertEquals("\"\",,,,,,,", string); } diff --git a/src/test/java/org/apache/commons/csv/CSVPrinterTest.java b/src/test/java/org/apache/commons/csv/CSVPrinterTest.java index 83c43d421..be4ce5e11 100644 --- a/src/test/java/org/apache/commons/csv/CSVPrinterTest.java +++ b/src/test/java/org/apache/commons/csv/CSVPrinterTest.java @@ -188,6 +188,14 @@ private Connection getH2Connection() throws SQLException, ClassNotFoundException return DriverManager.getConnection("jdbc:h2:mem:my_test;", "sa", ""); } + private String printNullRecord(final CSVFormat format) throws IOException { + final StringWriter sw = new StringWriter(); + try (CSVPrinter printer = new CSVPrinter(sw, format)) { + printer.printRecord((Object) null); + } + return sw.toString(); + } + private CSVPrinter printWithHeaderComments(final StringWriter sw, final Date now, final CSVFormat baseFormat) throws IOException { // Use withHeaderComments first to test CSV-145 // @formatter:off @@ -1721,6 +1729,29 @@ void testPrinter7() throws IOException { } } + @Test + void testPrintNullValueStartingRecord() throws IOException { + final StringWriter sw = new StringWriter(); + try (CSVPrinter printer = new CSVPrinter(sw, CSVFormat.DEFAULT)) { + printer.printRecord("a"); + printer.printRecord((Object) null); + printer.printRecord("b"); + } + final String csv = sw.toString(); + assertEquals("a" + RECORD_SEPARATOR + "\"\"" + RECORD_SEPARATOR + "b" + RECORD_SEPARATOR, csv); + try (CSVParser parser = CSVParser.parse(csv, CSVFormat.DEFAULT)) { + final List records = parser.getRecords(); + assertEquals(3, records.size()); + assertArrayEquals(new String[] { "" }, records.get(1).values()); + } + // An explicit MINIMAL quote mode encapsulates it too. + assertEquals("\"\"" + RECORD_SEPARATOR, printNullRecord(CSVFormat.DEFAULT.builder().setQuoteMode(QuoteMode.MINIMAL).get())); + // ALL_NON_NULL encodes null as the bare empty field, so it must stay unquoted. + assertEquals(RECORD_SEPARATOR, printNullRecord(CSVFormat.DEFAULT.builder().setQuoteMode(QuoteMode.ALL_NON_NULL).get())); + // Without a quote character there is nothing to encapsulate with. + assertEquals(RECORD_SEPARATOR, printNullRecord(CSVFormat.DEFAULT.builder().setQuote(null).get())); + } + @Test void testPrintNullValues() throws IOException { final StringWriter sw = new StringWriter(); @@ -1854,8 +1885,8 @@ void testPrintRecordsWithObjectArray() throws IOException { printer.printRecords(objectArray); assertEquals(objectArray.length, printer.getRecordCount()); } - assertEquals(6, charArrayWriter.size()); - assertEquals("\n\n\n\n\n\n", charArrayWriter.toString()); + assertEquals(16, charArrayWriter.size()); + assertEquals("\"\"\n\"\"\n\"\"\n\n\"\"\n\"\"\n", charArrayWriter.toString()); } @Test From 4026d3a789f0761d12a2b31405e429bda76a7a4b Mon Sep 17 00:00:00 2001 From: Naveed Khan Date: Wed, 29 Jul 2026 16:24:43 +0530 Subject: [PATCH 2/2] pin ALL null encoding in test and drop magic size number QuoteMode.ALL intentionally writes null as the bare empty field to keep it distinct from a quoted empty string (CSV-203, JiraCsv203Test), so it stays excluded from the leading-null encapsulation; the test now pins that. The size assertion in testPrintRecordsWithObjectArray is now derived from the expected string. --- src/main/java/org/apache/commons/csv/CSVFormat.java | 4 ++-- src/test/java/org/apache/commons/csv/CSVPrinterTest.java | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/apache/commons/csv/CSVFormat.java b/src/main/java/org/apache/commons/csv/CSVFormat.java index 18965a6cf..4d8ca9643 100644 --- a/src/main/java/org/apache/commons/csv/CSVFormat.java +++ b/src/main/java/org/apache/commons/csv/CSVFormat.java @@ -2284,8 +2284,8 @@ private void print(final Object object, final CharSequence value, final Appendab if (len == 0 && newRecord && isQuoteCharacterSet() && isMinimalQuoteMode()) { // Encapsulate like printWithQuotes does for an empty value that starts a record: an // unquoted one makes the whole line empty, and a parser with ignoreEmptyLines enabled - // then drops the record. ALL_NON_NULL and NON_NUMERIC are excluded because they encode - // null as the bare empty field. + // then drops the record. ALL, ALL_NON_NULL, and NON_NUMERIC are excluded because they + // encode null as the bare empty field, distinct from a quoted empty string (CSV-203). final char quoteChar = quoteCharacter.charValue(); // Explicit unboxing is intentional out.append(quoteChar); out.append(quoteChar); diff --git a/src/test/java/org/apache/commons/csv/CSVPrinterTest.java b/src/test/java/org/apache/commons/csv/CSVPrinterTest.java index be4ce5e11..ff3b2b6dc 100644 --- a/src/test/java/org/apache/commons/csv/CSVPrinterTest.java +++ b/src/test/java/org/apache/commons/csv/CSVPrinterTest.java @@ -1746,6 +1746,8 @@ void testPrintNullValueStartingRecord() throws IOException { } // An explicit MINIMAL quote mode encapsulates it too. assertEquals("\"\"" + RECORD_SEPARATOR, printNullRecord(CSVFormat.DEFAULT.builder().setQuoteMode(QuoteMode.MINIMAL).get())); + // ALL encodes null as the bare empty field, distinct from a quoted empty string (CSV-203). + assertEquals(RECORD_SEPARATOR, printNullRecord(CSVFormat.DEFAULT.builder().setQuoteMode(QuoteMode.ALL).get())); // ALL_NON_NULL encodes null as the bare empty field, so it must stay unquoted. assertEquals(RECORD_SEPARATOR, printNullRecord(CSVFormat.DEFAULT.builder().setQuoteMode(QuoteMode.ALL_NON_NULL).get())); // Without a quote character there is nothing to encapsulate with. @@ -1885,8 +1887,9 @@ void testPrintRecordsWithObjectArray() throws IOException { printer.printRecords(objectArray); assertEquals(objectArray.length, printer.getRecordCount()); } - assertEquals(16, charArrayWriter.size()); - assertEquals("\"\"\n\"\"\n\"\"\n\n\"\"\n\"\"\n", charArrayWriter.toString()); + final String expected = "\"\"\n\"\"\n\"\"\n\n\"\"\n\"\"\n"; + assertEquals(expected.length(), charArrayWriter.size()); + assertEquals(expected, charArrayWriter.toString()); } @Test