Skip to content

Commit 6f0069b

Browse files
authored
Implement #601: allow requiring quoting of "null value" (CsvReadFeature.ONLY_UNQUOTED_NULL_VALUES_AS_NULL) (#602)
1 parent 564ccf1 commit 6f0069b

File tree

6 files changed

+183
-5
lines changed

6 files changed

+183
-5
lines changed

csv/src/main/java/tools/jackson/dataformat/csv/CsvParser.java

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,11 @@ public class CsvParser
152152
*/
153153
protected boolean _cfgEmptyUnquotedStringAsNull;
154154

155+
/**
156+
* @since 3.1
157+
*/
158+
protected boolean _cfgOnlyUnquotedNullValuesAsNull;
159+
155160
/*
156161
/**********************************************************************
157162
/* State
@@ -252,6 +257,7 @@ public CsvParser(ObjectReadContext readCtxt, IOContext ioCtxt,
252257
_setSchema(schema);
253258
_cfgEmptyStringAsNull = CsvReadFeature.EMPTY_STRING_AS_NULL.enabledIn(csvFeatures);
254259
_cfgEmptyUnquotedStringAsNull = CsvReadFeature.EMPTY_UNQUOTED_STRING_AS_NULL.enabledIn(csvFeatures);
260+
_cfgOnlyUnquotedNullValuesAsNull = CsvReadFeature.ONLY_UNQUOTED_NULL_VALUES_AS_NULL.enabledIn(csvFeatures);
255261
}
256262

257263
/*
@@ -1235,12 +1241,15 @@ protected void _startArray(CsvSchema.Column column)
12351241
protected boolean _isNullValue(String value) {
12361242
if (_nullValue != null) {
12371243
if (_nullValue.equals(value)) {
1238-
return true;
1244+
// [dataformats-text#601]: If `ONLY_UNQUOTED_NULL_VALUES_AS_NULL` is enabled,
1245+
// only treat unquoted values as null
1246+
return !_cfgOnlyUnquotedNullValuesAsNull || !_reader.isCurrentTokenQuoted();
12391247
}
12401248
}
1241-
if (_cfgEmptyStringAsNull && value.isEmpty()) {
1242-
return true;
1249+
if (value.isEmpty()) {
1250+
return _cfgEmptyStringAsNull
1251+
|| (_cfgEmptyUnquotedStringAsNull && !_reader.isCurrentTokenQuoted());
12431252
}
1244-
return _cfgEmptyUnquotedStringAsNull && value.isEmpty() && !_reader.isCurrentTokenQuoted();
1253+
return false;
12451254
}
12461255
}

csv/src/main/java/tools/jackson/dataformat/csv/CsvReadFeature.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,26 @@ public enum CsvReadFeature
142142
* Feature is disabled by default for backwards compatibility.
143143
*/
144144
EMPTY_UNQUOTED_STRING_AS_NULL(false),
145+
146+
/**
147+
* Feature that enables treating only un-quoted values matching the configured
148+
* "null value" String (see {@link CsvSchema#getNullValueString()}) as {@code null},
149+
* but not quoted values:
150+
* differentiating between a quoted null value String (like {@code "null"})
151+
* which remains as a String, and an unquoted null value (like {@code null})
152+
* which becomes {@code null}.
153+
*<p>
154+
* This is similar to {@link #EMPTY_UNQUOTED_STRING_AS_NULL} but applies to the
155+
* explicitly configured null value rather than empty strings.
156+
*<p>
157+
* Note: This feature only has an effect if a null value is configured via
158+
* {@link CsvSchema.Builder#setNullValue(String)}.
159+
*<p>
160+
* Feature is disabled by default for backwards compatibility.
161+
*
162+
* @since 3.1
163+
*/
164+
ONLY_UNQUOTED_NULL_VALUES_AS_NULL(false),
145165
;
146166

147167
private final boolean _defaultState;
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package tools.jackson.dataformat.csv.deser;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
6+
7+
import tools.jackson.databind.MappingIterator;
8+
import tools.jackson.databind.ObjectReader;
9+
import tools.jackson.dataformat.csv.*;
10+
11+
import static org.junit.jupiter.api.Assertions.*;
12+
13+
/**
14+
* Tests for {@code CsvReadFeature.NULL_VALUE_UNQUOTED_AS_NULL}
15+
* (see [dataformats-text#601])
16+
*/
17+
public class NullValueUnquotedAsNullTest extends ModuleTestBase
18+
{
19+
@JsonPropertyOrder({"firstName", "middleName", "lastName"})
20+
static class TestUser {
21+
public String firstName, middleName, lastName;
22+
}
23+
24+
private final CsvMapper MAPPER = mapperForCsv();
25+
26+
// Default behavior: both quoted and unquoted null values become null
27+
@Test
28+
public void testDefaultBothQuotedAndUnquotedAsNull() throws Exception {
29+
CsvSchema schema = MAPPER.schemaFor(TestUser.class).withNullValue("N/A");
30+
ObjectReader reader = MAPPER.readerFor(TestUser.class).with(schema);
31+
32+
// Unquoted N/A -> null
33+
TestUser user1 = reader.readValue("Grace,N/A,Hopper");
34+
assertEquals("Grace", user1.firstName);
35+
assertNull(user1.middleName);
36+
assertEquals("Hopper", user1.lastName);
37+
38+
// Quoted "N/A" -> also null (default behavior)
39+
TestUser user2 = reader.readValue("Grace,\"N/A\",Hopper");
40+
assertEquals("Grace", user2.firstName);
41+
assertNull(user2.middleName);
42+
assertEquals("Hopper", user2.lastName);
43+
}
44+
45+
// With feature enabled: only unquoted null values become null
46+
@Test
47+
public void testUnquotedNullValueAsNull() throws Exception {
48+
CsvSchema schema = MAPPER.schemaFor(TestUser.class).withNullValue("N/A");
49+
ObjectReader reader = MAPPER.readerFor(TestUser.class)
50+
.with(schema)
51+
.with(CsvReadFeature.ONLY_UNQUOTED_NULL_VALUES_AS_NULL);
52+
53+
// Unquoted N/A -> null
54+
TestUser user1 = reader.readValue("Grace,N/A,Hopper");
55+
assertEquals("Grace", user1.firstName);
56+
assertNull(user1.middleName);
57+
assertEquals("Hopper", user1.lastName);
58+
59+
// Quoted "N/A" -> remains as string "N/A"
60+
TestUser user2 = reader.readValue("Grace,\"N/A\",Hopper");
61+
assertEquals("Grace", user2.firstName);
62+
assertEquals("N/A", user2.middleName);
63+
assertEquals("Hopper", user2.lastName);
64+
}
65+
66+
// Test with array binding (non-POJO)
67+
@Test
68+
public void testUnquotedNullValueAsNullWithArrays() throws Exception {
69+
CsvSchema schema = CsvSchema.emptySchema().withNullValue("null");
70+
ObjectReader reader = MAPPER.reader()
71+
.with(schema)
72+
.with(CsvReadFeature.ONLY_UNQUOTED_NULL_VALUES_AS_NULL)
73+
.with(CsvReadFeature.WRAP_AS_ARRAY);
74+
75+
// Unquoted null -> null
76+
try (MappingIterator<String[]> it = reader.forType(String[].class)
77+
.readValues("a,null,b")) {
78+
String[] arr = it.next();
79+
assertEquals(3, arr.length);
80+
assertEquals("a", arr[0]);
81+
assertNull(arr[1]);
82+
assertEquals("b", arr[2]);
83+
}
84+
85+
// Quoted "null" -> string "null"
86+
try (MappingIterator<String[]> it = reader.forType(String[].class)
87+
.readValues("a,\"null\",b")) {
88+
String[] arr = it.next();
89+
assertEquals(3, arr.length);
90+
assertEquals("a", arr[0]);
91+
assertEquals("null", arr[1]);
92+
assertEquals("b", arr[2]);
93+
}
94+
}
95+
96+
// Test with Object[] binding
97+
@Test
98+
public void testUnquotedNullValueAsNullWithObjectArrays() throws Exception {
99+
CsvSchema schema = CsvSchema.emptySchema().withNullValue("NULL");
100+
ObjectReader reader = MAPPER.reader()
101+
.with(schema)
102+
.with(CsvReadFeature.ONLY_UNQUOTED_NULL_VALUES_AS_NULL)
103+
.with(CsvReadFeature.WRAP_AS_ARRAY);
104+
105+
// Unquoted NULL -> null
106+
try (MappingIterator<Object[]> it = reader.forType(Object[].class)
107+
.readValues("first,NULL,last")) {
108+
Object[] arr = it.next();
109+
assertEquals(3, arr.length);
110+
assertEquals("first", arr[0]);
111+
assertNull(arr[1]);
112+
assertEquals("last", arr[2]);
113+
}
114+
115+
// Quoted "NULL" -> string "NULL"
116+
try (MappingIterator<Object[]> it = reader.forType(Object[].class)
117+
.readValues("first,\"NULL\",last")) {
118+
Object[] arr = it.next();
119+
assertEquals(3, arr.length);
120+
assertEquals("first", arr[0]);
121+
assertEquals("NULL", arr[1]);
122+
assertEquals("last", arr[2]);
123+
}
124+
}
125+
126+
// Test feature disabled has no effect (same as default)
127+
@Test
128+
public void testFeatureDisabledSameAsDefault() throws Exception {
129+
CsvSchema schema = MAPPER.schemaFor(TestUser.class).withNullValue("N/A");
130+
ObjectReader reader = MAPPER.readerFor(TestUser.class)
131+
.with(schema)
132+
.without(CsvReadFeature.ONLY_UNQUOTED_NULL_VALUES_AS_NULL);
133+
134+
// Both quoted and unquoted should become null
135+
TestUser user1 = reader.readValue("Grace,N/A,Hopper");
136+
assertNull(user1.middleName);
137+
138+
TestUser user2 = reader.readValue("Grace,\"N/A\",Hopper");
139+
assertNull(user2.middleName);
140+
}
141+
}

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040

4141
<properties>
4242
<!-- for Reproducible Builds -->
43-
<project.build.outputTimestamp>2026-01-19T22:44:40Z</project.build.outputTimestamp>
43+
<project.build.outputTimestamp>2026-01-19T22:31:55Z</project.build.outputTimestamp>
4444
</properties>
4545

4646
<dependencies>

release-notes/CREDITS

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,9 @@ Théo SZANTO (@indyteo)
2020
* Contributed #596: (yaml) Port `YAMLAnchorReplayingFactory` from 2.x and improve it
2121
to handle nested anchors
2222
(3.1.0)
23+
24+
Dmitry Bufistov (@dmitry-workato)
25+
26+
* Requested #601: (csv) Reader should allow separating plain `nullValue`
27+
and quoted value `"nullValue"`
28+
(3.1.0)

release-notes/VERSION

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ implementations)
2828
#596: (yaml) Port `YAMLAnchorReplayingFactory` from 2.x and improve it
2929
to handle nested anchors
3030
(contributed by Théo S)
31+
#601: (csv) Reader should allow separating plain `nullValue` and quoted value `"nullValue"`
32+
(requested by Dmitry B)
3133

3234
3.0.3 (28-Nov-2025)
3335
3.0.2 (07-Nov-2025)

0 commit comments

Comments
 (0)