Skip to content

Commit 687dd1d

Browse files
test(client-v2, jdbc-v2): address review on BFloat16 support (#2279)
Per @chernser's review on #2934: - Throw SkipException (instead of a silent return) when a BFloat16 test is skipped on a server older than 24.11. - Add exhaustive integration read tests in both client-v2 and jdbc-v2 covering all 2^16 BFloat16 bit patterns. The dataset is written with SQL text literals so the server - not the client's binary writer - produces the stored bytes, decoupling the read path under test from any write-side error. The read is verified against the server's stored bits (reinterpretAsUInt16), which stays correct regardless of how the server converts a literal to BFloat16 (e.g. it flushes subnormals to zero). - Add a jdbc-v2 setFloat write-as-Float test and assert the JDBC BFloat16 -> FLOAT / java.lang.Float mapping via ResultSetMetaData and getObject. - Document the jdbc-v2 BFloat16 = Float mapping in CHANGELOG and docs/features.md. No production changes: jdbc-v2 already mapped BFloat16 to Float and works via the (now BFloat16-capable) client-v2 reader/writer. Implements: #2279
1 parent 18f09b2 commit 687dd1d

4 files changed

Lines changed: 229 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44

55
### New Features
66

7-
- **[client-v2]** Added support for the `BFloat16` data type (ClickHouse `24.11+`). `BFloat16` columns are read as Java
8-
`float` values (widening is lossless) and written from `float`/`Float` values, including through generic records, POJO
7+
- **[client-v2, jdbc-v2]** Added support for the `BFloat16` data type (ClickHouse `24.11+`). `BFloat16` columns are read as
8+
Java `float` values (widening is lossless) and written from `float`/`Float` values, including through generic records, POJO
99
binding, `Nullable(BFloat16)`, and `BFloat16` values held in `Dynamic`/`Variant` columns. On write the client keeps the
10-
high 16 bits of the `float`, matching the ClickHouse server's own `Float32``BFloat16` conversion. Previously reading
11-
or writing a `BFloat16` column failed with an unsupported-data-type error. (https://github.com/ClickHouse/clickhouse-java/issues/2279)
10+
high 16 bits of the `float`, matching the ClickHouse server's own `Float32``BFloat16` conversion. In the JDBC driver
11+
(`jdbc-v2`) `BFloat16` maps to `java.sql.Types.FLOAT` / `java.lang.Float` and is read and written through the standard
12+
`getFloat`/`setFloat` and `getObject` accessors. Previously reading or writing a `BFloat16` column failed with an
13+
unsupported-data-type error. (https://github.com/ClickHouse/clickhouse-java/issues/2279)
1214

1315
### Bug Fixes
1416

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

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import lombok.NoArgsConstructor;
2424
import org.apache.commons.lang3.StringUtils;
2525
import org.testng.Assert;
26+
import org.testng.SkipException;
2627
import org.testng.annotations.AfterMethod;
2728
import org.testng.annotations.BeforeMethod;
2829
import org.testng.annotations.DataProvider;
@@ -174,7 +175,7 @@ public static String tblCreateSQL(String table) {
174175
@Test(groups = {"integration"})
175176
public void testBFloat16() throws Exception {
176177
if (isVersionMatch(BFLOAT16_UNSUPPORTED_VERSIONS)) {
177-
return;
178+
throw new SkipException("BFloat16 requires ClickHouse 24.11+");
178179
}
179180

180181
// Exhaustively cover every one of the 2^16 BFloat16 bit patterns through a full client
@@ -217,10 +218,82 @@ private static void assertBFloat16Equals(Float actual, Float expected) {
217218
}
218219
}
219220

221+
@Test(groups = {"integration"})
222+
public void testBFloat16ReadFromServerWrittenValues() throws Exception {
223+
if (isVersionMatch(BFLOAT16_UNSUPPORTED_VERSIONS)) {
224+
throw new SkipException("BFloat16 requires ClickHouse 24.11+");
225+
}
226+
227+
// Exhaustively cover reading every one of the 2^16 BFloat16 bit patterns. The dataset is
228+
// written with SQL text literals so the *server* produces the stored bytes; this decouples
229+
// the read path under test from the client's binary write path. Row b holds the BFloat16
230+
// whose bit pattern is b, so a read must widen it to Float.intBitsToFloat(b << 16) (NaN
231+
// inputs collapse to a single canonical NaN on the server and only read back as NaN).
232+
final String table = "test_bfloat16_read";
233+
final int count = 1 << 16;
234+
final int batchSize = 4096; // keep each INSERT well under max_query_size
235+
client.execute("DROP TABLE IF EXISTS " + table).get();
236+
client.execute("CREATE TABLE " + table
237+
+ " (rowId Int32, v BFloat16, vNull Nullable(BFloat16)) ENGINE = MergeTree ORDER BY rowId").get();
238+
239+
for (int start = 0; start < count; start += batchSize) {
240+
int end = Math.min(start + batchSize, count);
241+
StringBuilder insert = new StringBuilder("INSERT INTO ").append(table).append(" VALUES ");
242+
for (int b = start; b < end; b++) {
243+
String literal = bFloat16Literal(b);
244+
if (b > start) {
245+
insert.append(',');
246+
}
247+
insert.append('(').append(b).append(',').append(literal).append(',')
248+
.append(b == 0 ? "NULL" : literal).append(')');
249+
}
250+
client.execute(insert.toString()).get();
251+
}
252+
253+
// The server's stored bits are the ground truth for the read: reinterpretAsUInt16(v) yields
254+
// the exact 16-bit pattern the server wrote, so a correct read must widen it to
255+
// Float.intBitsToFloat(bits << 16). Deriving the expectation from the stored bits (rather than
256+
// from rowId) keeps the read assertion correct regardless of how the server converts the text
257+
// literal to BFloat16 - in particular the server flushes BFloat16 subnormals to zero, so those
258+
// patterns are stored as +/-0 even though the literal named a tiny subnormal.
259+
AtomicInteger rowCount = new AtomicInteger(0);
260+
client.queryAll("SELECT rowId, v, reinterpretAsUInt16(v) AS bits, vNull FROM " + table + " ORDER BY rowId")
261+
.forEach(row -> {
262+
int b = row.getInteger("rowId");
263+
float expected = Float.intBitsToFloat(row.getInteger("bits") << 16);
264+
assertBFloat16Equals(row.getFloat("v"), expected);
265+
if (b == 0) {
266+
Assert.assertNull(row.getObject("vNull"));
267+
} else {
268+
assertBFloat16Equals((Float) row.getObject("vNull"), expected);
269+
}
270+
rowCount.incrementAndGet();
271+
});
272+
Assert.assertEquals(rowCount.get(), count);
273+
}
274+
275+
// Text form of the exact BFloat16 whose bit pattern is {@code pattern}, suitable for use as a
276+
// SQL numeric literal. Non-finite patterns use ClickHouse's nan/inf/-inf tokens; every other
277+
// pattern uses the shortest round-trippable decimal of Float.intBitsToFloat(pattern << 16),
278+
// which is exactly representable in BFloat16 (its low 16 mantissa bits are zero).
279+
private static String bFloat16Literal(int pattern) {
280+
float value = Float.intBitsToFloat(pattern << 16);
281+
if (Float.isNaN(value)) {
282+
return "nan";
283+
}
284+
if (value == Float.POSITIVE_INFINITY) {
285+
return "inf";
286+
}
287+
if (value == Float.NEGATIVE_INFINITY) {
288+
return "-inf";
289+
}
290+
return Float.toString(value);
291+
}
292+
220293
@Test(groups = {"integration"})
221294
public void testBFloat16TruncationMatchesServer() throws Exception {
222295
if (isVersionMatch(BFLOAT16_UNSUPPORTED_VERSIONS)) {
223-
return;
296+
throw new SkipException("BFloat16 requires ClickHouse 24.11+");
224297
}
225298

226299
// (a) The client must decode a BFloat16 produced by the server (Float32 -> BFloat16
@@ -250,7 +323,7 @@ public void testBFloat16TruncationMatchesServer() throws Exception {
250323
@Test(groups = {"integration"})
251324
public void testBFloat16InDynamicColumn() throws Exception {
252325
if (isVersionMatch("(,24.8]") || isVersionMatch(BFLOAT16_UNSUPPORTED_VERSIONS)) {
253-
return;
326+
throw new SkipException("BFloat16 requires ClickHouse 24.11+");
254327
}
255328

256329
// Reading a BFloat16 held in a Dynamic column exercises the dynamic type-tag read path.

docs/features.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ This document lists stable, user-visible behavior in `client-v2` and `jdbc-v2` t
1818
- Binary format readers: Reads ClickHouse binary result formats including `Native`, `RowBinary`, `RowBinaryWithNames`, and `RowBinaryWithNamesAndTypes`.
1919
- JSONEachRow text reader: Can stream `JSONEachRow` responses through a caller-supplied `JsonParser`, with Jackson and Gson parser factory implementations available as optional classpath dependencies, and infers a best-effort schema from the first row.
2020
- Data type conversion: Maps ClickHouse types to Java values for binary reads, POJO binding, and SQL parameter formatting, including date/time handling.
21-
- BFloat16 type support: For ClickHouse `24.11+`, reads and writes the `BFloat16` type through generic records, binary readers, POJO binding, and `Nullable`/`Dynamic`/`Variant` wrappers. `BFloat16` maps to the Java `float` type: a read widens the stored 16-bit value losslessly, while a write keeps the high 16 bits of the `float`.
21+
- BFloat16 type support: For ClickHouse `24.11+`, reads and writes the `BFloat16` type through generic records, binary readers, POJO binding, and `Nullable`/`Dynamic`/`Variant` wrappers. `BFloat16` maps to the Java `float` type: a read widens the stored 16-bit value losslessly, while a write keeps the high 16 bits of the `float`. In `jdbc-v2`, `BFloat16` maps to `java.sql.Types.FLOAT` / `java.lang.Float` and is read and written through the standard `getFloat`/`setFloat` and `getObject` accessors.
2222
- Geometry type support: For ClickHouse `25.11+`, where `Geometry` changed from a string alias to `Variant(Point, Ring, LineString, MultiLineString, Polygon, MultiPolygon)`, the client reads and writes `Geometry` values through generic records, binary readers, POJO binding, and SQL parameter formatting, using Java array dimensionality to represent the geometry shape.
2323
- Insert APIs: Supports inserting registered POJOs, raw streams, and callback-driven writers, with optional column lists and format selection.
2424
- Insert controls: Supports insert-specific settings such as deduplication token, query id, compression behavior, and request headers.

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

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import org.slf4j.Logger;
1414
import org.slf4j.LoggerFactory;
1515
import org.testng.Assert;
16+
import org.testng.SkipException;
1617
import org.testng.annotations.BeforeClass;
1718
import org.testng.annotations.DataProvider;
1819
import org.testng.annotations.Test;
@@ -495,6 +496,151 @@ public void testUnsignedIntegerTypes() throws Exception {
495496
}
496497

497498

499+
// BFloat16 was introduced in ClickHouse 24.11.
500+
private static final String BFLOAT16_UNSUPPORTED_VERSIONS = "(,24.10]";
501+
502+
@Test(groups = { "integration" })
503+
public void testBFloat16() throws SQLException {
504+
if (ClickHouseVersion.of(getServerVersion()).check(BFLOAT16_UNSUPPORTED_VERSIONS)) {
505+
throw new SkipException("BFloat16 was introduced in ClickHouse 24.11");
506+
}
507+
508+
// Exhaustively cover reading every one of the 2^16 BFloat16 bit patterns through the JDBC
509+
// driver, which must expose BFloat16 as java.lang.Float. The dataset is written with SQL
510+
// text literals so the *server* produces the stored bytes, decoupling the read path under
511+
// test from any write-side codec error. Row b holds the BFloat16 whose bit pattern is b, so
512+
// a read must widen it to Float.intBitsToFloat(b << 16) (NaN inputs collapse to a single
513+
// canonical NaN on the server and only read back as NaN).
514+
final String table = "test_bfloat16";
515+
final int count = 1 << 16;
516+
final int batchSize = 4096; // keep each INSERT well under max_query_size
517+
runQuery("DROP TABLE IF EXISTS " + table);
518+
runQuery("CREATE TABLE " + table
519+
+ " (rowId Int32, v BFloat16, vNull Nullable(BFloat16)) ENGINE = MergeTree ORDER BY rowId");
520+
521+
try (Connection conn = getJdbcConnection();
522+
Statement stmt = conn.createStatement()) {
523+
for (int start = 0; start < count; start += batchSize) {
524+
int end = Math.min(start + batchSize, count);
525+
StringBuilder insert = new StringBuilder("INSERT INTO ").append(table).append(" VALUES ");
526+
for (int b = start; b < end; b++) {
527+
String literal = bFloat16Literal(b);
528+
if (b > start) {
529+
insert.append(',');
530+
}
531+
insert.append('(').append(b).append(',').append(literal).append(',')
532+
.append(b == 0 ? "NULL" : literal).append(')');
533+
}
534+
stmt.executeUpdate(insert.toString());
535+
}
536+
}
537+
538+
// The server's stored bits are the ground truth for the read: reinterpretAsUInt16(v) yields
539+
// the exact 16-bit pattern the server wrote, so a correct read must widen it to
540+
// Float.intBitsToFloat(bits << 16). Deriving the expectation from the stored bits keeps the
541+
// assertion correct regardless of how the server converts the text literal to BFloat16 - in
542+
// particular the server flushes BFloat16 subnormals to zero, so those patterns are stored as
543+
// +/-0 even though the literal named a tiny subnormal.
544+
try (Connection conn = getJdbcConnection();
545+
Statement stmt = conn.createStatement();
546+
ResultSet rs = stmt.executeQuery(
547+
"SELECT rowId, v, reinterpretAsUInt16(v) AS bits, vNull FROM " + table + " ORDER BY rowId")) {
548+
ResultSetMetaData meta = rs.getMetaData();
549+
assertEquals(meta.getColumnType(2), Types.FLOAT, "BFloat16 should map to Types.FLOAT");
550+
assertEquals(meta.getColumnClassName(2), Float.class.getName(), "BFloat16 should map to java.lang.Float");
551+
552+
int rows = 0;
553+
while (rs.next()) {
554+
int b = rs.getInt("rowId");
555+
float expected = Float.intBitsToFloat(rs.getInt("bits") << 16);
556+
Object obj = rs.getObject("v");
557+
assertTrue(obj instanceof Float,
558+
"BFloat16 getObject should return Float but was "
559+
+ (obj == null ? "null" : obj.getClass().getName()));
560+
assertBFloat16Equals((Float) obj, expected);
561+
assertBFloat16Equals(rs.getFloat("v"), expected);
562+
if (b == 0) {
563+
assertNull(rs.getObject("vNull"));
564+
} else {
565+
assertBFloat16Equals((Float) rs.getObject("vNull"), expected);
566+
}
567+
rows++;
568+
}
569+
assertEquals(rows, count);
570+
}
571+
}
572+
573+
@Test(groups = { "integration" })
574+
public void testBFloat16WriteAsFloat() throws SQLException {
575+
if (ClickHouseVersion.of(getServerVersion()).check(BFLOAT16_UNSUPPORTED_VERSIONS)) {
576+
throw new SkipException("BFloat16 was introduced in ClickHouse 24.11");
577+
}
578+
579+
// Writing through the JDBC Float surface: setFloat must store a BFloat16. Exactly
580+
// representable values round-trip unchanged; 3.14f is truncated to the high 16 bits
581+
// (0x4048F5C3 -> 0x40480000 = 3.125f), matching the server's Float32 -> BFloat16 cast.
582+
final String table = "test_bfloat16_write";
583+
runQuery("DROP TABLE IF EXISTS " + table);
584+
runQuery("CREATE TABLE " + table
585+
+ " (rowId Int32, v BFloat16, vNull Nullable(BFloat16)) ENGINE = MergeTree ORDER BY rowId");
586+
587+
float[] inputs = { 0f, 0.5f, 1.5f, -2.5f, 3.14f, 128.0f };
588+
try (Connection conn = getJdbcConnection();
589+
PreparedStatement ps = conn.prepareStatement("INSERT INTO " + table + " VALUES (?, ?, ?)")) {
590+
for (int i = 0; i < inputs.length; i++) {
591+
ps.setInt(1, i);
592+
ps.setFloat(2, inputs[i]);
593+
ps.setFloat(3, inputs[i]);
594+
ps.executeUpdate();
595+
}
596+
}
597+
598+
try (Connection conn = getJdbcConnection();
599+
Statement stmt = conn.createStatement();
600+
ResultSet rs = stmt.executeQuery("SELECT rowId, v, vNull FROM " + table + " ORDER BY rowId")) {
601+
for (int i = 0; i < inputs.length; i++) {
602+
assertTrue(rs.next());
603+
float expected = Float.intBitsToFloat(Float.floatToIntBits(inputs[i]) & 0xFFFF0000);
604+
assertEquals(rs.getFloat("v"), expected, 0f);
605+
assertEquals(rs.getObject("v"), Float.valueOf(expected));
606+
assertEquals(rs.getFloat("vNull"), expected, 0f);
607+
assertEquals(rs.getObject("vNull"), Float.valueOf(expected));
608+
}
609+
assertFalse(rs.next());
610+
}
611+
}
612+
613+
// Text form of the exact BFloat16 whose bit pattern is {@code pattern}, suitable for use as a
614+
// SQL numeric literal. Non-finite patterns use ClickHouse's nan/inf/-inf tokens; every other
615+
// pattern uses the shortest round-trippable decimal of Float.intBitsToFloat(pattern << 16),
616+
// which is exactly representable in BFloat16 (its low 16 mantissa bits are zero).
617+
private static String bFloat16Literal(int pattern) {
618+
float value = Float.intBitsToFloat(pattern << 16);
619+
if (Float.isNaN(value)) {
620+
return "nan";
621+
}
622+
if (value == Float.POSITIVE_INFINITY) {
623+
return "inf";
624+
}
625+
if (value == Float.NEGATIVE_INFINITY) {
626+
return "-inf";
627+
}
628+
return Float.toString(value);
629+
}
630+
631+
// BFloat16 keeps the high 16 bits of a float32, so every non-NaN value round-trips bit-for-bit;
632+
// NaN inputs collapse to a single canonical NaN on the server and only read back as NaN.
633+
private static void assertBFloat16Equals(Float actual, Float expected) {
634+
if (expected == null) {
635+
assertNull(actual);
636+
} else if (Float.isNaN(expected)) {
637+
assertTrue(actual != null && Float.isNaN(actual), "expected a NaN but got " + actual);
638+
} else {
639+
assertTrue(actual != null, "expected " + expected + " but got null");
640+
assertEquals(Float.floatToRawIntBits(actual), Float.floatToRawIntBits(expected));
641+
}
642+
}
643+
498644
@Test(groups = { "integration" })
499645
public void testUUIDTypes() throws Exception {
500646
Random rand = new Random();

0 commit comments

Comments
 (0)