You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
On the compiled-POJO read fast path, a Nullable(T) column bound to a primitive setter decodes without consuming the one-byte null marker. That desyncs the RowBinary stream by one byte per row, so the nullable column and every column after it decode from the wrong offset. Nothing throws — the reader consumes the right total number of bytes, just split in the wrong places — so the caller silently gets plausible-looking wrong numbers.
SerializerUtils.compilePOJOSetter chooses the box-free path on:
if (targetType.isPrimitive() && BinaryStreamReader.isReadToPrimitive(column.getDataType())) {
Neither half of that condition looks at column.isNullable(). isReadToPrimitive answers for the data type — Int64 is readable to a primitive whether or not the column is nullable — so binaryReaderMethodForType emits a bare BinaryStreamReader.readLongLE() with no marker read.
The failure mode is inverted from what you would expect. With NULLs actually present it throws (a long field cannot hold a NULL, so that binding gets rejected somewhere downstream). It is the case that looks entirely safe — a Nullable column that happens to contain no nulls — that corrupts silently. The marker byte is on the wire because the column is declaredNullable, not because any value is null.
This also means rejecting the binding only when a NULL is encountered would not be a sufficient fix.
Steps to reproduce
Query a Nullable(Int64) column containing no nulls, followed by any other column.
Register a POJO whose setter for the nullable column takes primitive long.
queryAll(sql, Pojo.class, schema) — both columns come back wrong, no exception.
Error Log or Exception StackTrace
No exception. That is the bug.
Expected Behaviour
a = 0,1,2,3,4 and b = 1000..1004. Actual:
column 'a' decoded as [0, 65536, 33554432, 12884901888, 4398046511104]
but should be [0, 1, 2, 3, 4]
column 'b' likewise corrupt
Those values are 0, 1<<16, 2<<24, 3<<32, 4<<40 — each row shifted eight more bits than the last, which is the cumulative one-byte-per-row desync made visible.
Code Example
publicstaticclassPrimitiveSetterPOJO {
privatelonga;
privatelongb;
publiclonggetA() { returna; }
publicvoidsetA(longa) { this.a = a; } // primitive setter on the Nullable columnpubliclonggetB() { returnb; }
publicvoidsetB(longb) { this.b = b; } // witness for the cascade
}
// Nullable(Int64) containing NO nulls, followed by a plain Int64Stringsql = "SELECT toNullable(toInt64(number)) AS a, toInt64(number + 1000) AS b FROM numbers(5)";
TableSchemaschema = client.getTableSchemaFromQuery(sql);
client.register(PrimitiveSetterPOJO.class, schema);
for (PrimitiveSetterPOJOrow : client.queryAll(sql, PrimitiveSetterPOJO.class, schema)) {
System.out.println(row.getA() + " " + row.getB()); // 0 256000, 65536 65601536, ...
}
Changing setA(long) to setA(Long) declines the primitive path and the row decodes correctly — which localises the defect to the primitive path rather than the query, the schema or the wire format.
Reproducing test
I have a TestNG integration test in the project's own style — the failing case, a boxed-setter control that passes, and the with-NULLs case for contrast. Happy to open it as a PR if useful; here it is inline:
Reachable from Client.queryAll(sql, Class, schema), i.e. the documented POJO read API.
Not specific to Int64 — it should affect any nullable column whose data type passes isReadToPrimitive (BinaryStreamReader.java:1153-1171) bound to a primitive setter.
Minor, found alongside: POJOSerDe.registerClass rejects the TableSchema a reader builds for itself, because RowBinaryWithNamesAndTypesFormatReader:55 uses new TableSchema(columns), which sets both tableName="" and query="", while POJOSerDe.java:85-91 requires exactly one to be null. Happy to file separately if that is not already known.
Configuration
Environment
Cloud
Client version: reproduced on main @ 9776111f; also present in released 0.9.8
Language version: OpenJDK 17.0.19
OS: Linux (WSL2)
ClickHouse Server
ClickHouse Server version: 25.x via the project's own testcontainer (BaseIntegrationTest); also reproduced against 26.6.1
ClickHouse Server non-default settings, if any: none
CREATE TABLE statements for tables involved: none — reproduces against numbers(5)
Description
On the compiled-POJO read fast path, a
Nullable(T)column bound to a primitive setter decodes without consuming the one-byte null marker. That desyncs the RowBinary stream by one byte per row, so the nullable column and every column after it decode from the wrong offset. Nothing throws — the reader consumes the right total number of bytes, just split in the wrong places — so the caller silently gets plausible-looking wrong numbers.SerializerUtils.compilePOJOSetterchooses the box-free path on:https://github.com/ClickHouse/clickhouse-java/blob/9776111f/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/SerializerUtils.java#L857
Neither half of that condition looks at
column.isNullable().isReadToPrimitiveanswers for the data type —Int64is readable to a primitive whether or not the column is nullable — sobinaryReaderMethodForTypeemits a bareBinaryStreamReader.readLongLE()with no marker read.The failure mode is inverted from what you would expect. With NULLs actually present it throws (a
longfield cannot hold a NULL, so that binding gets rejected somewhere downstream). It is the case that looks entirely safe — aNullablecolumn that happens to contain no nulls — that corrupts silently. The marker byte is on the wire because the column is declaredNullable, not because any value is null.This also means rejecting the binding only when a NULL is encountered would not be a sufficient fix.
Steps to reproduce
Nullable(Int64)column containing no nulls, followed by any other column.long.queryAll(sql, Pojo.class, schema)— both columns come back wrong, no exception.Error Log or Exception StackTrace
Expected Behaviour
a= 0,1,2,3,4 andb= 1000..1004. Actual:Those values are
0,1<<16,2<<24,3<<32,4<<40— each row shifted eight more bits than the last, which is the cumulative one-byte-per-row desync made visible.Code Example
Changing
setA(long)tosetA(Long)declines the primitive path and the row decodes correctly — which localises the defect to the primitive path rather than the query, the schema or the wire format.Reproducing test
I have a TestNG integration test in the project's own style — the failing case, a boxed-setter control that passes, and the with-NULLs case for contrast. Happy to open it as a PR if useful; here it is inline:
client-v2/src/test/java/com/clickhouse/client/query/NullablePrimitivePOJOTests.javaRun with (failsafe, not surefire — surefire excludes the
integrationgroup):Notes
Client.queryAll(sql, Class, schema), i.e. the documented POJO read API.Int64— it should affect any nullable column whose data type passesisReadToPrimitive(BinaryStreamReader.java:1153-1171) bound to a primitive setter.compilePOJOSetter→binaryReaderMethodForTypepath, for the case where the primitive path threw. The nullable check appears never to have been added.POJOSerDe.registerClassrejects theTableSchemaa reader builds for itself, becauseRowBinaryWithNamesAndTypesFormatReader:55usesnew TableSchema(columns), which sets bothtableName=""andquery="", whilePOJOSerDe.java:85-91requires exactly one to be null. Happy to file separately if that is not already known.Configuration
Environment
main@9776111f; also present in released0.9.8ClickHouse Server
BaseIntegrationTest); also reproduced against 26.6.1CREATE TABLEstatements for tables involved: none — reproduces againstnumbers(5)