Skip to content

Commit 577e765

Browse files
Fix client-v2: reject null into non-nullable Array in RowBinary writer
Writing a Java null into a non-nullable Array(...) column via RowBinaryFormatWriter emitted two bytes (00 00) instead of one: writeValuePreamble special-cased Array and wrote a stray writeNonNull marker (0x00) on top of the array length that serializeArrayData(null) already writes (var-uint 0 = 0x00). The server read the extra byte as a phantom extra row (single-column inserts) or a column shift that failed the whole insert with CANNOT_READ_ALL_DATA (multi-column inserts). A non-nullable Array cannot represent a null, so it now throws IllegalArgumentException naming the column - like every other non-nullable type and the merged Enum fix (#2932) - in both the RowBinary and RowBinaryWithDefaults branches. Empty arrays still serialize as a single length byte, columns with a DDL default still use the default under RowBinaryWithDefaults, and Dynamic columns (which can hold a null as the implicit Nothing type) are left unchanged. Fixes: #2938
1 parent 1529baf commit 577e765

4 files changed

Lines changed: 144 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@
3434
NPE instead of a clear error. It now throws `IllegalArgumentException` naming the column, consistent with
3535
the existing `IllegalArgumentException` for other unsupported enum values. Nullable enum columns are
3636
unaffected. (https://github.com/ClickHouse/clickhouse-java/issues/2931)
37+
- **[client-v2]** Fixed silent data corruption when serializing a Java `null` into a non-nullable
38+
`Array(...)` column via `RowBinaryFormatWriter`. `RowBinaryFormatSerializer.writeValuePreamble`
39+
special-cased `Array`, emitting a stray marker byte on top of the array length; the server read the
40+
extra byte as a phantom extra row (single-column inserts) or as a column shift that failed the insert
41+
with `CANNOT_READ_ALL_DATA` (multi-column inserts). A non-nullable `Array` cannot represent a `null`,
42+
so it now throws `IllegalArgumentException` naming the column — consistent with every other non-nullable
43+
type — in both the `RowBinary` and `RowBinaryWithDefaults` paths. Empty arrays (`[]`) still serialize
44+
correctly, and `Dynamic` columns, which can hold a `null` as the implicit `Nothing` type, are
45+
unaffected. (https://github.com/ClickHouse/clickhouse-java/issues/2938)
3746
- **[client-v2]** Fixed POJO insert error classification so transport write failures such as java.net.SocketException:
3847
Broken pipe (Write failed) are now surfaced as transfer/network errors instead of being wrapped as
3948
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/RowBinaryFormatSerializer.java

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,9 @@ public static boolean writeValuePreamble(OutputStream out, boolean defaultsSuppo
204204
SerializerUtils.writeNonNull(out);
205205
SerializerUtils.writeNull(out);//Then we send null, write 1
206206
return false;//And we're done
207-
} else if (dataType == ClickHouseDataType.Array) {//If the column is an array
208-
SerializerUtils.writeNonNull(out);//Then we send nonNull
209207
} else if (dataType == ClickHouseDataType.Dynamic) {
210-
SerializerUtils.writeNonNull(out);
208+
// A Dynamic column can hold a null: it is serialized below as the implicit `Nothing` type.
209+
SerializerUtils.writeNonNull(out);//Write 0 for no default
211210
} else {
212211
throw new IllegalArgumentException(String.format("An attempt to write null into not nullable column '%s'", column));
213212
}
@@ -220,10 +219,8 @@ public static boolean writeValuePreamble(OutputStream out, boolean defaultsSuppo
220219
}
221220
SerializerUtils.writeNonNull(out);
222221
} else if (value == null) {
223-
if (dataType == ClickHouseDataType.Array) {
224-
SerializerUtils.writeNonNull(out);
225-
} else if (dataType == ClickHouseDataType.Dynamic) {
226-
// do nothing
222+
if (dataType == ClickHouseDataType.Dynamic) {
223+
// A Dynamic column can hold a null: it is serialized below as the implicit `Nothing` type.
227224
} else {
228225
throw new IllegalArgumentException(String.format("An attempt to write null into not nullable column '%s'", column));
229226
}

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

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,123 @@ private static Map<String, Object> singleEntryMap(String key, Object value) {
335335
return map;
336336
}
337337

338+
@DataProvider(name = "rowBinaryWriterFormats")
339+
private Object[][] rowBinaryWriterFormats() {
340+
return new Object[][] {
341+
{ClickHouseFormat.RowBinary},
342+
{ClickHouseFormat.RowBinaryWithDefaults},
343+
};
344+
}
345+
346+
// A non-nullable Array column cannot represent a null, so writing a Java null into it must fail
347+
// loudly like every other non-nullable type instead of emitting a stray marker byte on top of the
348+
// array length. That stray byte was read by the server as a phantom extra row (single column) or
349+
// shifted every following column (multi column). 'tail' sits after the array so a stray byte corrupts it.
350+
@Test (groups = { "integration" }, dataProvider = "rowBinaryWriterFormats")
351+
public void writeNullIntoNonNullableArrayThrowsTest(ClickHouseFormat format) throws Exception {
352+
String tableName = "rowBinaryFormatWriterTest_nonNullableArrayNull_" + UUID.randomUUID().toString().replace('-', '_');
353+
initTable(tableName,
354+
"CREATE TABLE \"" + tableName + "\" (id Int32, arr Array(Int32), tail Int32) Engine = MergeTree ORDER BY id",
355+
new CommandSettings());
356+
TableSchema schema = client.getTableSchema(tableName);
357+
358+
Exception thrown = null;
359+
try (InsertResponse response = client.insert(tableName, out -> {
360+
RowBinaryFormatWriter w = new RowBinaryFormatWriter(out, schema, format);
361+
w.setValue(schema.nameToColumnIndex("id"), 1);
362+
w.setValue(schema.nameToColumnIndex("arr"), null);
363+
w.setValue(schema.nameToColumnIndex("tail"), 7);
364+
w.commitRow();
365+
}, format, settings).get(EXECUTE_CMD_TIMEOUT, TimeUnit.SECONDS)) {
366+
// The insert must not succeed: a null in a non-nullable Array column is invalid.
367+
} catch (Exception e) {
368+
thrown = e;
369+
}
370+
371+
Assert.assertNotNull(thrown, "Expected the insert to fail for a null in a non-nullable Array column using " + format);
372+
boolean clearMessage = false;
373+
for (Throwable t = thrown; t != null; t = t.getCause()) {
374+
if (t.getMessage() != null && t.getMessage().contains("An attempt to write null into not nullable column")) {
375+
clearMessage = true;
376+
break;
377+
}
378+
}
379+
Assert.assertTrue(clearMessage, "Expected a clear non-nullable column error using " + format + ", but got: " + thrown);
380+
}
381+
382+
// Contrast: an empty (and a populated) non-nullable Array must still round-trip, and the fixed-width
383+
// 'tail' column after it keeps its value - proving the array length is still written as a single
384+
// leading byte and that rejecting null did not perturb valid array writes.
385+
@Test (groups = { "integration" }, dataProvider = "rowBinaryWriterFormats")
386+
public void writeNonNullableArrayRoundTripsTest(ClickHouseFormat format) throws Exception {
387+
String tableName = "rowBinaryFormatWriterTest_nonNullableArrayRoundTrip_" + UUID.randomUUID().toString().replace('-', '_');
388+
initTable(tableName,
389+
"CREATE TABLE \"" + tableName + "\" (id Int32, arr Array(Int32), tail Int32) Engine = MergeTree ORDER BY id",
390+
new CommandSettings());
391+
TableSchema schema = client.getTableSchema(tableName);
392+
393+
try (InsertResponse response = client.insert(tableName, out -> {
394+
RowBinaryFormatWriter w = new RowBinaryFormatWriter(out, schema, format);
395+
w.setValue(schema.nameToColumnIndex("id"), 1);
396+
w.setValue(schema.nameToColumnIndex("arr"), new ArrayList<Integer>());
397+
w.setValue(schema.nameToColumnIndex("tail"), 7);
398+
w.commitRow();
399+
w.setValue(schema.nameToColumnIndex("id"), 2);
400+
w.setValue(schema.nameToColumnIndex("arr"), Arrays.asList(1, 2, 3));
401+
w.setValue(schema.nameToColumnIndex("tail"), 8);
402+
w.commitRow();
403+
}, format, settings).get(EXECUTE_CMD_TIMEOUT, TimeUnit.SECONDS)) {
404+
// inserted
405+
}
406+
407+
List<GenericRecord> records = client.queryAll(
408+
"SELECT id, toInt32(length(arr)) AS alen, arrayStringConcat(arr, ',') AS acat, tail FROM \""
409+
+ tableName + "\" ORDER BY id");
410+
Assert.assertEquals(records.size(), 2);
411+
412+
GenericRecord row1 = records.get(0);
413+
Assert.assertEquals(row1.getInteger("alen"), 0);
414+
Assert.assertEquals(row1.getString("acat"), "");
415+
Assert.assertEquals(row1.getInteger("tail"), 7);
416+
417+
GenericRecord row2 = records.get(1);
418+
Assert.assertEquals(row2.getInteger("alen"), 3);
419+
Assert.assertEquals(row2.getString("acat"), "1,2,3");
420+
Assert.assertEquals(row2.getInteger("tail"), 8);
421+
}
422+
423+
// Contrast: with RowBinaryWithDefaults, a null into a non-nullable Array column that HAS a DDL
424+
// default must still fall back to that default (the null-to-default coercion is decided before the
425+
// type dispatch), not throw - proving the fix only rejects null for non-nullable arrays with no default.
426+
@Test (groups = { "integration" })
427+
public void writeNullIntoDefaultedArrayUsesDefaultTest() throws Exception {
428+
String tableName = "rowBinaryFormatWriterTest_defaultedArrayNull_" + UUID.randomUUID().toString().replace('-', '_');
429+
initTable(tableName,
430+
"CREATE TABLE \"" + tableName + "\" (id Int32, arr Array(Int32) DEFAULT [1, 2], tail Int32) Engine = MergeTree ORDER BY id",
431+
new CommandSettings());
432+
TableSchema schema = client.getTableSchema(tableName);
433+
ClickHouseFormat format = ClickHouseFormat.RowBinaryWithDefaults;
434+
435+
try (InsertResponse response = client.insert(tableName, out -> {
436+
RowBinaryFormatWriter w = new RowBinaryFormatWriter(out, schema, format);
437+
w.setValue(schema.nameToColumnIndex("id"), 1);
438+
w.setValue(schema.nameToColumnIndex("arr"), null);
439+
w.setValue(schema.nameToColumnIndex("tail"), 7);
440+
w.commitRow();
441+
}, format, settings).get(EXECUTE_CMD_TIMEOUT, TimeUnit.SECONDS)) {
442+
// inserted; the null arr must fall back to the DDL default
443+
}
444+
445+
List<GenericRecord> records = client.queryAll(
446+
"SELECT toInt32(length(arr)) AS alen, arrayStringConcat(arr, ',') AS acat, tail FROM \""
447+
+ tableName + "\" ORDER BY id");
448+
Assert.assertEquals(records.size(), 1);
449+
GenericRecord row = records.get(0);
450+
Assert.assertEquals(row.getInteger("alen"), 2);
451+
Assert.assertEquals(row.getString("acat"), "1,2");
452+
Assert.assertEquals(row.getInteger("tail"), 7);
453+
}
454+
338455

339456
@Test (groups = { "integration" })
340457
public void writeNumbersTest() throws Exception {

client-v2/src/test/java/com/clickhouse/client/internal/SerializerUtilsTests.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,20 @@ public void testDynamicNullWithDefaultsWritesPreambleAndNothingTag() throws Exce
3838
Assert.assertEquals(out.toByteArray(), new byte[]{0, 0});
3939
}
4040

41+
@Test
42+
public void testDynamicNullWithoutDefaultsWritesNothingTag() throws Exception {
43+
// A non-nullable Dynamic column can still hold a null (serialized as the implicit `Nothing`
44+
// type), unlike a plain non-nullable Array which must reject null. This pins that behavior in
45+
// the plain RowBinary path: no null-marker, just the single Nothing type tag.
46+
ClickHouseColumn column = ClickHouseColumn.of("dyn", "Dynamic");
47+
ByteArrayOutputStream out = new ByteArrayOutputStream();
48+
49+
Assert.assertTrue(RowBinaryFormatSerializer.writeValuePreamble(out, false, column, null));
50+
SerializerUtils.serializeData(out, null, column);
51+
52+
Assert.assertEquals(out.toByteArray(), new byte[]{0});
53+
}
54+
4155
@Test
4256
public void testSerializeNumbersRoundTrip() throws IOException {
4357
String[] names = new String[]{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o",

0 commit comments

Comments
 (0)