From 507be2bfceb1c65d326716e21edce55fc976678d Mon Sep 17 00:00:00 2001 From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:31:16 +0000 Subject: [PATCH 1/3] fix(client-v2): reject null in non-nullable Enum with clear error serializeEnumData dereferenced the value before the null check, so inserting null into a non-nullable Enum8/Enum16 column failed the RowBinary serialization path with a confusing NullPointerException. Add a null guard that throws IllegalArgumentException naming the column, consistent with how other unsupported enum values are rejected. Nullable enum columns are unaffected. Fixes: https://github.com/ClickHouse/clickhouse-java/issues/2931 --- CHANGELOG.md | 6 ++++ .../internal/SerializerUtils.java | 4 +++ .../internal/SerializerUtilsTest.java | 33 +++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7da98542..aa3850809 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ### Bug Fixes +- **[client-v2]** Fixed a `NullPointerException` when serializing a `null` value into a non-nullable + `Enum8`/`Enum16` column. `SerializerUtils.serializeEnumData` dereferenced the value before the `null` + check, so a `null` in a non-nullable enum column failed the RowBinary insert path with a confusing NPE + instead of a clear error. It now throws `IllegalArgumentException` naming the column, consistent with the + existing `IllegalArgumentException` for other unsupported enum values. Nullable enum columns are unaffected. + (https://github.com/ClickHouse/clickhouse-java/issues/2931) - **[client-v2]** Fixed POJO insert error classification so transport write failures such as java.net.SocketException: Broken pipe (Write failed) are now surfaced as transfer/network errors instead of being wrapped as DataSerializationException. This only changes the exception type reported for request-body transport failures during diff --git a/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/SerializerUtils.java b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/SerializerUtils.java index 1d1afd219..f70c68086 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/SerializerUtils.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/SerializerUtils.java @@ -706,6 +706,10 @@ private static void serializeTime64(OutputStream stream, Object value) throws IO } public static void serializeEnumData(OutputStream stream, ClickHouseColumn column, Object value) throws IOException { + if (value == null) { + throw new IllegalArgumentException("Cannot insert null into non-nullable column " + column.getColumnName() + + " of type " + column.getOriginalTypeName()); + } int enumValue = -1; if (value instanceof String) { enumValue = column.getEnumConstants().value((String) value); diff --git a/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/SerializerUtilsTest.java b/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/SerializerUtilsTest.java index 265cd1cfb..367a0937f 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/SerializerUtilsTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/SerializerUtilsTest.java @@ -143,6 +143,39 @@ public void testGeometrySerializationRejectsMalformedList() { ClickHouseColumn.of("v", "Geometry"))); } + @Test(dataProvider = "nonNullableEnumTypes") + public void testNullIntoNonNullableEnumThrowsIllegalArgument(String typeName) { + ClickHouseColumn column = ClickHouseColumn.of("v", typeName); + + IllegalArgumentException ex = Assert.expectThrows(IllegalArgumentException.class, + () -> SerializerUtils.serializeData(new ByteArrayOutputStream(), null, column)); + Assert.assertTrue(ex.getMessage().contains("Cannot insert null into non-nullable column"), + "Unexpected message: " + ex.getMessage()); + } + + @DataProvider(name = "nonNullableEnumTypes") + private Object[][] nonNullableEnumTypes() { + return new Object[][] { + {"Enum8('B' = 1, 'S' = 2)"}, + {"Enum16('B' = 1, 'S' = 2)"}, + }; + } + + @Test + public void testEnumSerializationUnaffectedByNullGuard() throws Exception { + // A Nullable(Enum) with null still takes the early null-marker path and never reaches + // enum serialization, so a single null-marker byte is written. + ByteArrayOutputStream nullableOut = new ByteArrayOutputStream(); + SerializerUtils.serializeData(nullableOut, null, + ClickHouseColumn.of("v", "Nullable(Enum8('B' = 1, 'S' = 2))")); + Assert.assertEquals(nullableOut.toByteArray(), new byte[] {1}); + + // A present value in a non-nullable Enum column still serializes to its mapped numeric value. + ByteArrayOutputStream valueOut = new ByteArrayOutputStream(); + SerializerUtils.serializeData(valueOut, "S", ClickHouseColumn.of("v", "Enum8('B' = 1, 'S' = 2)")); + Assert.assertEquals(valueOut.toByteArray(), new byte[] {2}); + } + @Test(dataProvider = "nestedNullableData") public void testNestedNullableRoundTrip(String typeName, Object value) throws Exception { ClickHouseColumn column = ClickHouseColumn.of("v", typeName); From 9ac3a015dfe828c58ce9c37ec57893571c598e69 Mon Sep 17 00:00:00 2001 From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:47:23 +0000 Subject: [PATCH 2/3] test(client-v2): assert enum-null error names column and type; clarify changelog Address review feedback on #2932: - Strengthen testNullIntoNonNullableEnumThrowsIllegalArgument to assert the error message names the offending column and includes the enum type string, not just the generic prefix, so a future refactor that drops either detail fails the test. Use a distinctive `bs_flag` column name (matching the issue repro) so the column-name assertion is unambiguous. - Reword CHANGELOG: serializeEnumData had no null guard (there was no prior null check), avoiding the implication of a pre-existing check. --- CHANGELOG.md | 10 +++++----- .../data_formats/internal/SerializerUtilsTest.java | 11 ++++++++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa3850809..89b94a9f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,11 @@ ### Bug Fixes - **[client-v2]** Fixed a `NullPointerException` when serializing a `null` value into a non-nullable - `Enum8`/`Enum16` column. `SerializerUtils.serializeEnumData` dereferenced the value before the `null` - check, so a `null` in a non-nullable enum column failed the RowBinary insert path with a confusing NPE - instead of a clear error. It now throws `IllegalArgumentException` naming the column, consistent with the - existing `IllegalArgumentException` for other unsupported enum values. Nullable enum columns are unaffected. - (https://github.com/ClickHouse/clickhouse-java/issues/2931) + `Enum8`/`Enum16` column. `SerializerUtils.serializeEnumData` had no `null` guard, so a `null` in a + non-nullable enum column reached `value.getClass()` and failed the RowBinary insert path with a confusing + NPE instead of a clear error. It now throws `IllegalArgumentException` naming the column, consistent with + the existing `IllegalArgumentException` for other unsupported enum values. Nullable enum columns are + unaffected. (https://github.com/ClickHouse/clickhouse-java/issues/2931) - **[client-v2]** Fixed POJO insert error classification so transport write failures such as java.net.SocketException: Broken pipe (Write failed) are now surfaced as transfer/network errors instead of being wrapped as DataSerializationException. This only changes the exception type reported for request-body transport failures during diff --git a/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/SerializerUtilsTest.java b/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/SerializerUtilsTest.java index 367a0937f..ee096b67c 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/SerializerUtilsTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/SerializerUtilsTest.java @@ -145,12 +145,17 @@ public void testGeometrySerializationRejectsMalformedList() { @Test(dataProvider = "nonNullableEnumTypes") public void testNullIntoNonNullableEnumThrowsIllegalArgument(String typeName) { - ClickHouseColumn column = ClickHouseColumn.of("v", typeName); + ClickHouseColumn column = ClickHouseColumn.of("bs_flag", typeName); IllegalArgumentException ex = Assert.expectThrows(IllegalArgumentException.class, () -> SerializerUtils.serializeData(new ByteArrayOutputStream(), null, column)); - Assert.assertTrue(ex.getMessage().contains("Cannot insert null into non-nullable column"), - "Unexpected message: " + ex.getMessage()); + String message = ex.getMessage(); + Assert.assertTrue(message.contains("Cannot insert null into non-nullable column"), + "Unexpected message: " + message); + Assert.assertTrue(message.contains("bs_flag"), + "Message should name the offending column: " + message); + Assert.assertTrue(message.contains(typeName), + "Message should include the enum type: " + message); } @DataProvider(name = "nonNullableEnumTypes") From 13010eb1feada49d180c10e8a005a2c8cfbed915 Mon Sep 17 00:00:00 2001 From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com> Date: Sat, 18 Jul 2026 05:14:17 +0000 Subject: [PATCH 3/3] Clarify non-nullable Enum null error and add integration + JDBC tests Reword the serializeEnumData null-guard message to begin with "Cannot write NULL into non-nullable Enum column", and add coverage requested in review of #2931: - client-v2 RowBinary round-trip of zero-like Enum values (empty-string name, underlying 0, signed negatives, by name and by int), proving correct read + write. - client-v2 parametrized test that a null Enum element in a non-nullable Array/Tuple/Map/nested-Array container now fails with the clear message instead of an opaque NullPointerException (the top-level scalar case is already guarded earlier by writeValuePreamble). - jdbc-v2 zero-like Enum round-trip through PreparedStatement/ResultSet. --- .../internal/SerializerUtils.java | 2 +- .../internal/SerializerUtilsTest.java | 2 +- .../datatypes/RowBinaryFormatWriterTest.java | 82 +++++++++++++++++++ .../clickhouse/jdbc/JdbcDataTypeTests.java | 62 ++++++++++++++ 4 files changed, 146 insertions(+), 2 deletions(-) diff --git a/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/SerializerUtils.java b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/SerializerUtils.java index e0b495dc1..8ab25bdf3 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/SerializerUtils.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/SerializerUtils.java @@ -707,7 +707,7 @@ private static void serializeTime64(OutputStream stream, Object value) throws IO public static void serializeEnumData(OutputStream stream, ClickHouseColumn column, Object value) throws IOException { if (value == null) { - throw new IllegalArgumentException("Cannot insert null into non-nullable column " + column.getColumnName() + throw new IllegalArgumentException("Cannot write NULL into non-nullable Enum column " + column.getColumnName() + " of type " + column.getOriginalTypeName()); } int enumValue = -1; diff --git a/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/SerializerUtilsTest.java b/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/SerializerUtilsTest.java index ee096b67c..9574bed72 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/SerializerUtilsTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/SerializerUtilsTest.java @@ -150,7 +150,7 @@ public void testNullIntoNonNullableEnumThrowsIllegalArgument(String typeName) { IllegalArgumentException ex = Assert.expectThrows(IllegalArgumentException.class, () -> SerializerUtils.serializeData(new ByteArrayOutputStream(), null, column)); String message = ex.getMessage(); - Assert.assertTrue(message.contains("Cannot insert null into non-nullable column"), + Assert.assertTrue(message.contains("Cannot write NULL into non-nullable Enum column"), "Unexpected message: " + message); Assert.assertTrue(message.contains("bs_flag"), "Message should name the offending column: " + message); diff --git a/client-v2/src/test/java/com/clickhouse/client/datatypes/RowBinaryFormatWriterTest.java b/client-v2/src/test/java/com/clickhouse/client/datatypes/RowBinaryFormatWriterTest.java index 1a0ee2287..333feaa5f 100644 --- a/client-v2/src/test/java/com/clickhouse/client/datatypes/RowBinaryFormatWriterTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/datatypes/RowBinaryFormatWriterTest.java @@ -17,7 +17,9 @@ import com.clickhouse.data.ClickHouseFormat; import com.clickhouse.data.ClickHouseVersion; import org.apache.commons.lang3.RandomStringUtils; +import org.testng.Assert; import org.testng.annotations.BeforeMethod; +import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import java.io.IOException; @@ -254,6 +256,86 @@ public void writeMissingFieldsTest() throws Exception { } + @Test (groups = { "integration" }) + public void writeEnumZeroLikeValuesTest() throws Exception { + String tableName = "rowBinaryFormatWriterTest_enumZeroLike_" + UUID.randomUUID().toString().replace('-', '_'); + String tableCreate = "CREATE TABLE \"" + tableName + "\" " + + " (id Int32, " + + " e8 Enum8('' = 0, 'a' = 1, 'neg' = -5), " + + " e16 Enum16('zero' = 0, 'big' = 30000, 'nb' = -20000), " + + " tail Float64" + + " ) Engine = MergeTree ORDER BY id"; + + Field[][] rows = new Field[][] { + // Zero-like written by enum name: an empty-string name and a named zero both map to 0. + {new Field("id", 1), new Field("e8", ""), new Field("e16", "zero"), new Field("tail", 1.5)}, + // The same zero-like members written by their underlying int 0. + {new Field("id", 2), new Field("e8", 0).set(""), new Field("e16", 0).set("zero"), new Field("tail", 2.5)}, + // Negative members (Enum8/Enum16 are signed) written by name. + {new Field("id", 3), new Field("e8", "neg"), new Field("e16", "nb"), new Field("tail", 3.5)}, + // The same negative members written by their underlying int. + {new Field("id", 4), new Field("e8", -5).set("neg"), new Field("e16", -20000).set("nb"), new Field("tail", 4.5)}, + // Ordinary positive members. + {new Field("id", 5), new Field("e8", "a"), new Field("e16", "big"), new Field("tail", 5.5)}, + }; + + writeTest(tableName, tableCreate, rows); + } + + + // A null element in a non-nullable Enum sub-column reaches serializeEnumData directly (no + // per-element null preamble is written for non-nullable nested columns), which is the path + // that used to throw an opaque NullPointerException. Covers every container seam that routes + // an element through serializeEnumData: Array (level 1), Tuple, Map value, and a nested Array. + @DataProvider(name = "nullEnumContainers") + private Object[][] nullEnumContainers() { + return new Object[][] { + {"Array(Enum8('a' = 1, 'b' = 2))", Arrays.asList("a", null)}, + {"Tuple(Enum8('a' = 1, 'b' = 2), Int32)", Arrays.asList(null, 7)}, + {"Map(String, Enum16('a' = 1, 'b' = 2))", singleEntryMap("k", null)}, + {"Array(Array(Enum8('a' = 1, 'b' = 2)))", Arrays.asList(Arrays.asList("a", null))}, + }; + } + + @Test (groups = { "integration" }, dataProvider = "nullEnumContainers") + public void writeNullEnumInContainerThrowsTest(String columnType, Object valueWithNullEnum) throws Exception { + String tableName = "rowBinaryFormatWriterTest_enumContainerNull_" + UUID.randomUUID().toString().replace('-', '_'); + initTable(tableName, + "CREATE TABLE \"" + tableName + "\" (id Int32, c " + columnType + ") Engine = MergeTree ORDER BY id", + new CommandSettings()); + TableSchema schema = client.getTableSchema(tableName); + ClickHouseFormat format = ClickHouseFormat.RowBinaryWithDefaults; + + Exception thrown = null; + try (InsertResponse response = client.insert(tableName, out -> { + RowBinaryFormatWriter w = new RowBinaryFormatWriter(out, schema, format); + w.setValue(schema.nameToColumnIndex("id"), 1); + w.setValue(schema.nameToColumnIndex("c"), valueWithNullEnum); + w.commitRow(); + }, format, settings).get(EXECUTE_CMD_TIMEOUT, TimeUnit.SECONDS)) { + // The insert must not succeed: a null Enum element in a non-nullable container is invalid. + } catch (Exception e) { + thrown = e; + } + + Assert.assertNotNull(thrown, "Expected the insert to fail for a null Enum element in " + columnType); + boolean clearMessage = false; + for (Throwable t = thrown; t != null; t = t.getCause()) { + if (t.getMessage() != null && t.getMessage().contains("Cannot write NULL into non-nullable Enum column")) { + clearMessage = true; + break; + } + } + Assert.assertTrue(clearMessage, "Expected a clear non-nullable Enum error for " + columnType + ", but got: " + thrown); + } + + private static Map singleEntryMap(String key, Object value) { + Map map = new HashMap<>(); + map.put(key, value); + return map; + } + + @Test (groups = { "integration" }) public void writeNumbersTest() throws Exception { String tableName = "rowBinaryFormatWriterTest_writeNumbersTest_" + UUID.randomUUID().toString().replace('-', '_'); diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/JdbcDataTypeTests.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/JdbcDataTypeTests.java index ed0b00cb9..2bcd6ce54 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/JdbcDataTypeTests.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/JdbcDataTypeTests.java @@ -1178,6 +1178,68 @@ public void testStringTypes() throws SQLException { } } + @Test(groups = { "integration" }) + public void testEnumZeroLikeValues() throws SQLException { + runQuery("DROP TABLE IF EXISTS test_enum_zero_like"); + runQuery("CREATE TABLE test_enum_zero_like (order Int8, " + + "e8 Enum8('' = 0, 'a' = 1, 'neg' = -5), e16 Enum16('zero' = 0, 'big' = 30000, 'nb' = -20000)" + + ") ENGINE = MergeTree ORDER BY ()"); + + try (Connection conn = getJdbcConnection()) { + try (PreparedStatement stmt = conn.prepareStatement("INSERT INTO test_enum_zero_like VALUES ( ?, ?, ? )")) { + // Zero-like written by name: an empty-string name and a named zero both map to 0. + stmt.setInt(1, 1); + stmt.setString(2, ""); + stmt.setString(3, "zero"); + stmt.addBatch(); + // The same zero-like members written by their underlying int 0. + stmt.setInt(1, 2); + stmt.setInt(2, 0); + stmt.setInt(3, 0); + stmt.addBatch(); + // Negative members (Enum8/Enum16 are signed) written by name. + stmt.setInt(1, 3); + stmt.setString(2, "neg"); + stmt.setString(3, "nb"); + stmt.addBatch(); + // The same negative members written by their underlying int. + stmt.setInt(1, 4); + stmt.setInt(2, -5); + stmt.setInt(3, -20000); + stmt.addBatch(); + stmt.executeBatch(); + } + } + + try (Connection conn = getJdbcConnection()) { + try (Statement stmt = conn.createStatement()) { + try (ResultSet rs = stmt.executeQuery("SELECT * FROM test_enum_zero_like ORDER BY order")) { + assertTrue(rs.next()); + assertEquals(rs.getString("e8"), ""); + assertEquals(rs.getInt("e8"), 0); + assertEquals(rs.getString("e16"), "zero"); + assertEquals(rs.getInt("e16"), 0); + assertTrue(rs.next()); + assertEquals(rs.getString("e8"), ""); + assertEquals(rs.getInt("e8"), 0); + assertEquals(rs.getString("e16"), "zero"); + assertEquals(rs.getInt("e16"), 0); + assertTrue(rs.next()); + assertEquals(rs.getString("e8"), "neg"); + assertEquals(rs.getInt("e8"), -5); + assertEquals(rs.getString("e16"), "nb"); + assertEquals(rs.getInt("e16"), -20000); + assertTrue(rs.next()); + assertEquals(rs.getString("e8"), "neg"); + assertEquals(rs.getInt("e8"), -5); + assertEquals(rs.getString("e16"), "nb"); + assertEquals(rs.getInt("e16"), -20000); + assertFalse(rs.next()); + } + } + } + } + @Test(groups = { "integration" }) public void testIpAddressTypes() throws SQLException, UnknownHostException { runQuery("CREATE TABLE test_ips (order Int8, "