Skip to content

Commit 1529baf

Browse files
authored
Merge pull request #2932 from ClickHouse/polyglot/enum-null-non-nullable-npe
fix(client-v2): clear error for null in non-nullable Enum column
2 parents 1a1e0cc + ea311ea commit 1529baf

5 files changed

Lines changed: 192 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@
2828
a `String`/`Boolean`) through `Number#floatValue()`/`Number#doubleValue()`, so the float columns accept
2929
the same value types the integer columns already did. (https://github.com/ClickHouse/clickhouse-java/issues/2930)
3030

31+
- **[client-v2]** Fixed a `NullPointerException` when serializing a `null` value into a non-nullable
32+
`Enum8`/`Enum16` column. `SerializerUtils.serializeEnumData` had no `null` guard, so a `null` in a
33+
non-nullable enum column reached `value.getClass()` and failed the RowBinary insert path with a confusing
34+
NPE instead of a clear error. It now throws `IllegalArgumentException` naming the column, consistent with
35+
the existing `IllegalArgumentException` for other unsupported enum values. Nullable enum columns are
36+
unaffected. (https://github.com/ClickHouse/clickhouse-java/issues/2931)
3137
- **[client-v2]** Fixed POJO insert error classification so transport write failures such as java.net.SocketException:
3238
Broken pipe (Write failed) are now surfaced as transfer/network errors instead of being wrapped as
3339
DataSerializationException. This only changes the exception type reported for request-body transport failures during

client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/SerializerUtils.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,10 @@ private static void serializeTime64(OutputStream stream, Object value) throws IO
709709
}
710710

711711
public static void serializeEnumData(OutputStream stream, ClickHouseColumn column, Object value) throws IOException {
712+
if (value == null) {
713+
throw new IllegalArgumentException("Cannot write NULL into non-nullable Enum column " + column.getColumnName()
714+
+ " of type " + column.getOriginalTypeName());
715+
}
712716
int enumValue = -1;
713717
if (value instanceof String) {
714718
enumValue = column.getEnumConstants().value((String) value);

client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/SerializerUtilsTest.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,44 @@ public void testGeometrySerializationRejectsMalformedList() {
143143
ClickHouseColumn.of("v", "Geometry")));
144144
}
145145

146+
@Test(dataProvider = "nonNullableEnumTypes")
147+
public void testNullIntoNonNullableEnumThrowsIllegalArgument(String typeName) {
148+
ClickHouseColumn column = ClickHouseColumn.of("bs_flag", typeName);
149+
150+
IllegalArgumentException ex = Assert.expectThrows(IllegalArgumentException.class,
151+
() -> SerializerUtils.serializeData(new ByteArrayOutputStream(), null, column));
152+
String message = ex.getMessage();
153+
Assert.assertTrue(message.contains("Cannot write NULL into non-nullable Enum column"),
154+
"Unexpected message: " + message);
155+
Assert.assertTrue(message.contains("bs_flag"),
156+
"Message should name the offending column: " + message);
157+
Assert.assertTrue(message.contains(typeName),
158+
"Message should include the enum type: " + message);
159+
}
160+
161+
@DataProvider(name = "nonNullableEnumTypes")
162+
private Object[][] nonNullableEnumTypes() {
163+
return new Object[][] {
164+
{"Enum8('B' = 1, 'S' = 2)"},
165+
{"Enum16('B' = 1, 'S' = 2)"},
166+
};
167+
}
168+
169+
@Test
170+
public void testEnumSerializationUnaffectedByNullGuard() throws Exception {
171+
// A Nullable(Enum) with null still takes the early null-marker path and never reaches
172+
// enum serialization, so a single null-marker byte is written.
173+
ByteArrayOutputStream nullableOut = new ByteArrayOutputStream();
174+
SerializerUtils.serializeData(nullableOut, null,
175+
ClickHouseColumn.of("v", "Nullable(Enum8('B' = 1, 'S' = 2))"));
176+
Assert.assertEquals(nullableOut.toByteArray(), new byte[] {1});
177+
178+
// A present value in a non-nullable Enum column still serializes to its mapped numeric value.
179+
ByteArrayOutputStream valueOut = new ByteArrayOutputStream();
180+
SerializerUtils.serializeData(valueOut, "S", ClickHouseColumn.of("v", "Enum8('B' = 1, 'S' = 2)"));
181+
Assert.assertEquals(valueOut.toByteArray(), new byte[] {2});
182+
}
183+
146184
@Test(dataProvider = "nestedNullableData")
147185
public void testNestedNullableRoundTrip(String typeName, Object value) throws Exception {
148186
ClickHouseColumn column = ClickHouseColumn.of("v", typeName);

client-v2/src/test/java/com/clickhouse/client/datatypes/RowBinaryFormatWriterTest.java

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
import com.clickhouse.data.ClickHouseFormat;
1818
import com.clickhouse.data.ClickHouseVersion;
1919
import org.apache.commons.lang3.RandomStringUtils;
20+
import org.testng.Assert;
2021
import org.testng.annotations.BeforeMethod;
22+
import org.testng.annotations.DataProvider;
2123
import org.testng.annotations.Test;
2224

2325
import java.io.IOException;
@@ -254,6 +256,86 @@ public void writeMissingFieldsTest() throws Exception {
254256
}
255257

256258

259+
@Test (groups = { "integration" })
260+
public void writeEnumZeroLikeValuesTest() throws Exception {
261+
String tableName = "rowBinaryFormatWriterTest_enumZeroLike_" + UUID.randomUUID().toString().replace('-', '_');
262+
String tableCreate = "CREATE TABLE \"" + tableName + "\" " +
263+
" (id Int32, " +
264+
" e8 Enum8('' = 0, 'a' = 1, 'neg' = -5), " +
265+
" e16 Enum16('zero' = 0, 'big' = 30000, 'nb' = -20000), " +
266+
" tail Float64" +
267+
" ) Engine = MergeTree ORDER BY id";
268+
269+
Field[][] rows = new Field[][] {
270+
// Zero-like written by enum name: an empty-string name and a named zero both map to 0.
271+
{new Field("id", 1), new Field("e8", ""), new Field("e16", "zero"), new Field("tail", 1.5)},
272+
// The same zero-like members written by their underlying int 0.
273+
{new Field("id", 2), new Field("e8", 0).set(""), new Field("e16", 0).set("zero"), new Field("tail", 2.5)},
274+
// Negative members (Enum8/Enum16 are signed) written by name.
275+
{new Field("id", 3), new Field("e8", "neg"), new Field("e16", "nb"), new Field("tail", 3.5)},
276+
// The same negative members written by their underlying int.
277+
{new Field("id", 4), new Field("e8", -5).set("neg"), new Field("e16", -20000).set("nb"), new Field("tail", 4.5)},
278+
// Ordinary positive members.
279+
{new Field("id", 5), new Field("e8", "a"), new Field("e16", "big"), new Field("tail", 5.5)},
280+
};
281+
282+
writeTest(tableName, tableCreate, rows);
283+
}
284+
285+
286+
// A null element in a non-nullable Enum sub-column reaches serializeEnumData directly (no
287+
// per-element null preamble is written for non-nullable nested columns), which is the path
288+
// that used to throw an opaque NullPointerException. Covers every container seam that routes
289+
// an element through serializeEnumData: Array (level 1), Tuple, Map value, and a nested Array.
290+
@DataProvider(name = "nullEnumContainers")
291+
private Object[][] nullEnumContainers() {
292+
return new Object[][] {
293+
{"Array(Enum8('a' = 1, 'b' = 2))", Arrays.asList("a", null)},
294+
{"Tuple(Enum8('a' = 1, 'b' = 2), Int32)", Arrays.asList(null, 7)},
295+
{"Map(String, Enum16('a' = 1, 'b' = 2))", singleEntryMap("k", null)},
296+
{"Array(Array(Enum8('a' = 1, 'b' = 2)))", Arrays.asList(Arrays.asList("a", null))},
297+
};
298+
}
299+
300+
@Test (groups = { "integration" }, dataProvider = "nullEnumContainers")
301+
public void writeNullEnumInContainerThrowsTest(String columnType, Object valueWithNullEnum) throws Exception {
302+
String tableName = "rowBinaryFormatWriterTest_enumContainerNull_" + UUID.randomUUID().toString().replace('-', '_');
303+
initTable(tableName,
304+
"CREATE TABLE \"" + tableName + "\" (id Int32, c " + columnType + ") Engine = MergeTree ORDER BY id",
305+
new CommandSettings());
306+
TableSchema schema = client.getTableSchema(tableName);
307+
ClickHouseFormat format = ClickHouseFormat.RowBinaryWithDefaults;
308+
309+
Exception thrown = null;
310+
try (InsertResponse response = client.insert(tableName, out -> {
311+
RowBinaryFormatWriter w = new RowBinaryFormatWriter(out, schema, format);
312+
w.setValue(schema.nameToColumnIndex("id"), 1);
313+
w.setValue(schema.nameToColumnIndex("c"), valueWithNullEnum);
314+
w.commitRow();
315+
}, format, settings).get(EXECUTE_CMD_TIMEOUT, TimeUnit.SECONDS)) {
316+
// The insert must not succeed: a null Enum element in a non-nullable container is invalid.
317+
} catch (Exception e) {
318+
thrown = e;
319+
}
320+
321+
Assert.assertNotNull(thrown, "Expected the insert to fail for a null Enum element in " + columnType);
322+
boolean clearMessage = false;
323+
for (Throwable t = thrown; t != null; t = t.getCause()) {
324+
if (t.getMessage() != null && t.getMessage().contains("Cannot write NULL into non-nullable Enum column")) {
325+
clearMessage = true;
326+
break;
327+
}
328+
}
329+
Assert.assertTrue(clearMessage, "Expected a clear non-nullable Enum error for " + columnType + ", but got: " + thrown);
330+
}
331+
332+
private static Map<String, Object> singleEntryMap(String key, Object value) {
333+
Map<String, Object> map = new HashMap<>();
334+
map.put(key, value);
335+
return map;
336+
}
337+
338+
257339
@Test (groups = { "integration" })
258340
public void writeNumbersTest() throws Exception {
259341
String tableName = "rowBinaryFormatWriterTest_writeNumbersTest_" + UUID.randomUUID().toString().replace('-', '_');

jdbc-v2/src/test/java/com/clickhouse/jdbc/JdbcDataTypeTests.java

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1349,6 +1349,68 @@ public void testStringTypes() throws SQLException {
13491349
}
13501350
}
13511351

1352+
@Test(groups = { "integration" })
1353+
public void testEnumZeroLikeValues() throws SQLException {
1354+
runQuery("DROP TABLE IF EXISTS test_enum_zero_like");
1355+
runQuery("CREATE TABLE test_enum_zero_like (order Int8, "
1356+
+ "e8 Enum8('' = 0, 'a' = 1, 'neg' = -5), e16 Enum16('zero' = 0, 'big' = 30000, 'nb' = -20000)"
1357+
+ ") ENGINE = MergeTree ORDER BY ()");
1358+
1359+
try (Connection conn = getJdbcConnection()) {
1360+
try (PreparedStatement stmt = conn.prepareStatement("INSERT INTO test_enum_zero_like VALUES ( ?, ?, ? )")) {
1361+
// Zero-like written by name: an empty-string name and a named zero both map to 0.
1362+
stmt.setInt(1, 1);
1363+
stmt.setString(2, "");
1364+
stmt.setString(3, "zero");
1365+
stmt.addBatch();
1366+
// The same zero-like members written by their underlying int 0.
1367+
stmt.setInt(1, 2);
1368+
stmt.setInt(2, 0);
1369+
stmt.setInt(3, 0);
1370+
stmt.addBatch();
1371+
// Negative members (Enum8/Enum16 are signed) written by name.
1372+
stmt.setInt(1, 3);
1373+
stmt.setString(2, "neg");
1374+
stmt.setString(3, "nb");
1375+
stmt.addBatch();
1376+
// The same negative members written by their underlying int.
1377+
stmt.setInt(1, 4);
1378+
stmt.setInt(2, -5);
1379+
stmt.setInt(3, -20000);
1380+
stmt.addBatch();
1381+
stmt.executeBatch();
1382+
}
1383+
}
1384+
1385+
try (Connection conn = getJdbcConnection()) {
1386+
try (Statement stmt = conn.createStatement()) {
1387+
try (ResultSet rs = stmt.executeQuery("SELECT * FROM test_enum_zero_like ORDER BY order")) {
1388+
assertTrue(rs.next());
1389+
assertEquals(rs.getString("e8"), "");
1390+
assertEquals(rs.getInt("e8"), 0);
1391+
assertEquals(rs.getString("e16"), "zero");
1392+
assertEquals(rs.getInt("e16"), 0);
1393+
assertTrue(rs.next());
1394+
assertEquals(rs.getString("e8"), "");
1395+
assertEquals(rs.getInt("e8"), 0);
1396+
assertEquals(rs.getString("e16"), "zero");
1397+
assertEquals(rs.getInt("e16"), 0);
1398+
assertTrue(rs.next());
1399+
assertEquals(rs.getString("e8"), "neg");
1400+
assertEquals(rs.getInt("e8"), -5);
1401+
assertEquals(rs.getString("e16"), "nb");
1402+
assertEquals(rs.getInt("e16"), -20000);
1403+
assertTrue(rs.next());
1404+
assertEquals(rs.getString("e8"), "neg");
1405+
assertEquals(rs.getInt("e8"), -5);
1406+
assertEquals(rs.getString("e16"), "nb");
1407+
assertEquals(rs.getInt("e16"), -20000);
1408+
assertFalse(rs.next());
1409+
}
1410+
}
1411+
}
1412+
}
1413+
13521414
@Test(groups = { "integration" })
13531415
public void testIpAddressTypes() throws SQLException, UnknownHostException {
13541416
runQuery("CREATE TABLE test_ips (order Int8, "

0 commit comments

Comments
 (0)