Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
`getFloat`/`setFloat` and `getObject` accessors, and reported as such by `ResultSetMetaData` and `DatabaseMetaData`.
Previously reading or writing a `BFloat16` column failed with an
unsupported-data-type error. (https://github.com/ClickHouse/clickhouse-java/issues/2279)
- **[client-v2, jdbc-v2]** Added support for the experimental `QBit(element_type, dimension)` vector data type
(ClickHouse `25.10+`; the `allow_experimental_qbit_type` server setting is required to create a column), with
`BFloat16`, `Float32`, or `Float64` element types. A `QBit` value is transmitted over `RowBinary` exactly like
`Array(element_type)`, so it is read and written as a Java array of the element type (`float[]` for
`BFloat16`/`Float32`, `double[]` for `Float64`) through generic records, binary readers, and POJO binding. In the
JDBC driver (`jdbc-v2`) `QBit` maps to `java.sql.Types.ARRAY` and is returned as a `java.sql.Array` from
`getObject`/`getArray`. Previously `QBit` was an unimplemented type constant and reading or writing such a column
failed. (https://github.com/ClickHouse/clickhouse-java/issues/2610)
- **[client-v2, jdbc-v2]** Added TLS cipher suite selection. `Client.Builder.setSSLCipherSuites(String...)` (client-v2)
and the comma-separated `ssl_cipher_suites` connection property (client-v2 and jdbc-v2) restrict the cipher suites
enabled on secure connections; when unset, the transport defaults are used. Cipher-suite selection is independent of the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ public final class ClickHouseColumn implements Serializable {
private static final String KEYWORD_NESTED = ClickHouseDataType.Nested.name();
private static final String KEYWORD_VARIANT = ClickHouseDataType.Variant.name();
private static final String KEYWORD_JSON = ClickHouseDataType.JSON.name();
private static final String KEYWORD_QBIT = ClickHouseDataType.QBit.name();

private int columnCount;
private int columnIndex;
Expand Down Expand Up @@ -146,6 +147,17 @@ private static ClickHouseColumn update(ClickHouseColumn column) {
}
}
break;
case QBit:
// QBit(element_type, dimension) is a one-level array of its element type on the
// wire; the dimension parameter is kept as the column precision.
if (!column.nested.isEmpty()) {
column.arrayLevel = 1;
column.arrayBaseColumn = column.nested.get(0);
}
if (size > 1) {
column.precision = Integer.parseInt(column.parameters.get(1).trim());
}
break;
case Bool:
column.template = ClickHouseBoolValue.ofNull();
break;
Expand Down Expand Up @@ -567,6 +579,26 @@ protected static int readColumn(String args, int startIndex, int len, String nam
fixedLength = false;
estimatedLength++;
}
} else if (args.startsWith(KEYWORD_QBIT, i)) {
int index = args.indexOf('(', i + KEYWORD_QBIT.length());
if (index < i) {
throw new IllegalArgumentException(ERROR_MISSING_NESTED_TYPE);
}
List<String> params = new LinkedList<>();
i = ClickHouseUtils.readParameters(args, index, len, params);
if (params.size() < 2) {
throw new IllegalArgumentException(
"QBit requires an element type and a dimension, e.g. QBit(Float32, 8)");
}
// QBit(element_type, dimension) is transmitted over RowBinary exactly like
// Array(element_type): a var-int length followed by that many element values. The
// first parameter is the element type and drives the nested (item) column.
List<ClickHouseColumn> nestedColumns = new LinkedList<>();
nestedColumns.add(ClickHouseColumn.of("", params.get(0)));
column = new ClickHouseColumn(ClickHouseDataType.QBit, name, args.substring(startIndex, i),
nullable, lowCardinality, params, nestedColumns);
fixedLength = false;
estimatedLength++;
}

if (column == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -508,4 +508,33 @@ public Object[][] testJSONBinaryFormat_dp() {
{"JSON(max_dynamic_types=3,max_dynamic_paths=3, SKIP REGEXP '^-.*',SKIP ff, flags Array(Array(Array(Int8))), SKIP alt_count)", 2, Arrays.asList("flags")},
};
}

@DataProvider(name = "qbitTypesProvider")
private static Object[][] qbitTypesProvider() {
return new Object[][] {
{ "QBit(BFloat16, 4)", ClickHouseDataType.BFloat16, 4 },
{ "QBit(Float32, 8)", ClickHouseDataType.Float32, 8 },
{ "QBit(Float64, 1536)", ClickHouseDataType.Float64, 1536 },
};
}

@Test(groups = { "unit" }, dataProvider = "qbitTypesProvider")
public void testParseQBit(String typeName, ClickHouseDataType elementType, int dimension) {
ClickHouseColumn column = ClickHouseColumn.of("vec", typeName);
Assert.assertEquals(column.getDataType(), ClickHouseDataType.QBit);
Assert.assertEquals(column.getOriginalTypeName(), typeName);
// QBit(element_type, dimension) is a one-level array of its element type on the wire.
Assert.assertEquals(column.getArrayNestedLevel(), 1);
Assert.assertNotNull(column.getArrayBaseColumn());
Assert.assertEquals(column.getArrayBaseColumn().getDataType(), elementType);
Assert.assertEquals(column.getNestedColumns().size(), 1);
Assert.assertEquals(column.getNestedColumns().get(0).getDataType(), elementType);
// The fixed vector dimension is retained as the column precision.
Assert.assertEquals(column.getPrecision(), dimension);
}

@Test(groups = { "unit" }, expectedExceptions = IllegalArgumentException.class)
public void testParseQBitRequiresDimension() {
ClickHouseColumn.of("vec", "QBit(Float32)");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,8 @@ public <T> T readValue(ClickHouseColumn column, Class<?> typeHint) throws IOExce
return (T) readJsonData(input, actualColumn);
}
// case Object: // deprecated https://clickhouse.com/docs/en/sql-reference/data-types/object-data-type
case QBit:
// QBit(element_type, dimension) is transmitted like Array(element_type).
Comment thread
polyglotAI-bot marked this conversation as resolved.
case Array:
if (typeHint == null) { typeHint = arrayDefaultTypeHint;}
return convertArray(readArray(actualColumn), typeHint);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ public class SerializerUtils {
public static void serializeData(OutputStream stream, Object value, ClickHouseColumn column) throws IOException {
//Serialize the value to the stream based on the data type
switch (column.getDataType()) {
case QBit:
serializeQBitData(stream, value, column);
break;
case Array:
serializeArrayData(stream, value, column);
break;
Expand Down Expand Up @@ -470,6 +473,39 @@ public static void serializeArrayData(OutputStream stream, Object value, ClickHo
}
}

/**
* Serializes a {@code QBit(element_type, dimension)} value. On the wire a {@code QBit} is
* transmitted exactly like {@code Array(element_type)} — a var-int length followed by that many
* element values — but the element count is fixed and must equal the declared dimension. The
* count is validated up-front so a wrong-sized (including empty) vector fails fast on the client
* with a clear message instead of a late server {@code SERIALIZATION_ERROR}, mirroring the
* client-side length enforcement already applied to the other fixed-size type,
* {@code FixedString(N)}. A non-null value that is neither a Java array nor a {@code List} cannot
* carry a vector and is rejected as well — otherwise it would fall through to
* {@link #serializeArrayData} and write no bytes for the column, desynchronizing the
* {@code RowBinary} stream and corrupting the columns that follow.
*/
private static void serializeQBitData(OutputStream stream, Object value, ClickHouseColumn column) throws IOException {
if (value != null) {
int length;
if (value.getClass().isArray()) {
length = Array.getLength(value);
} else if (value instanceof List) {
length = ((List<?>) value).size();
} else {
throw new IllegalArgumentException("QBit column '" + column.getColumnName()
+ "' expects a Java array or List of its element type but got "
+ value.getClass().getName());
}
int dimension = column.getPrecision();
if (length != dimension) {
throw new IllegalArgumentException("QBit column '" + column.getColumnName()
+ "' expects exactly " + dimension + " elements but got " + length);
}
}
serializeArrayData(stream, value, column);
}
Comment thread
polyglotAI-bot marked this conversation as resolved.
Comment thread
polyglotAI-bot marked this conversation as resolved.

/**
DO NOT USE - part of internal API that will be changed
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ public String convertToString(Object value, ClickHouseColumn column) {
case IPv4:
case IPv6:
return ipvToString(value, column);
case QBit:
// QBit is rendered as an array literal of its element type, like Array(element_type).
case Array:
return arrayToString(value, column);
case Point:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,76 @@ public void testGeometrySerializationRejectsMalformedList() {
ClickHouseColumn.of("v", "Geometry")));
}

@Test(dataProvider = "qbitWrongDimension")
public void testQBitSerializationRejectsWrongDimension(String typeName, Object value, int actualLength) {
ClickHouseColumn column = ClickHouseColumn.of("vec", typeName);

IllegalArgumentException ex = Assert.expectThrows(IllegalArgumentException.class,
() -> SerializerUtils.serializeData(new ByteArrayOutputStream(), value, column));
String message = ex.getMessage();
Assert.assertTrue(message.contains("vec"), "Message should name the column: " + message);
Assert.assertTrue(message.contains("8"), "Message should state the expected dimension: " + message);
Assert.assertTrue(message.contains("got " + actualLength),
"Message should state the actual length: " + message);
}

@DataProvider(name = "qbitWrongDimension")
private Object[][] qbitWrongDimension() {
// A QBit(E, 8) column requires exactly 8 elements: empty, too-short, and too-long vectors are
// all invalid, for both the Java-array and List representations and every element type.
return new Object[][] {
{"QBit(Float32, 8)", new float[0], 0},
{"QBit(Float32, 8)", new float[] {1f, 2f, 3f, 4f, 5f}, 5},
{"QBit(Float32, 8)", new float[] {1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 10f}, 10},
{"QBit(Float64, 8)", new double[] {1d, 2d, 3d, 4d, 5d}, 5},
{"QBit(BFloat16, 8)", new float[] {1f, 2f, 3f}, 3},
{"QBit(Float32, 8)", Arrays.asList(1f, 2f, 3f), 3},
};
}

@Test(dataProvider = "qbitWrongType")
public void testQBitSerializationRejectsNonArrayValue(Object value) {
// A non-null QBit value that is neither a Java array nor a List cannot carry a vector. It must
// be rejected up-front: otherwise it falls through to the Array serializer, which writes no
// bytes for the column, desynchronizing the RowBinary stream and corrupting the following
// columns. Writing into a byte sink so any (wrongly) emitted payload would be observable.
ClickHouseColumn column = ClickHouseColumn.of("vec", "QBit(Float32, 8)");
ByteArrayOutputStream out = new ByteArrayOutputStream();

IllegalArgumentException ex = Assert.expectThrows(IllegalArgumentException.class,
() -> SerializerUtils.serializeData(out, value, column));
Assert.assertTrue(ex.getMessage().contains("vec"),
"Message should name the column: " + ex.getMessage());
Assert.assertEquals(out.size(), 0,
"Nothing should be written to the stream when the value is rejected");
}

@DataProvider(name = "qbitWrongType")
private Object[][] qbitWrongType() {
// Values that are neither a Java array nor a List: a String, boxed scalars of the element
// type, and a Map. None of these can represent a QBit(E, N) vector.
return new Object[][] {
{"not-a-vector"},
{3.14f},
{42d},
{newMap("k", "v")},
};
}

@Test
public void testQBitSerializationAcceptsExactDimensionAndMatchesArray() throws Exception {
float[] vec = {1f, -2f, 3.5f, 4f, 5f, 6f, 7f, 8f};

ByteArrayOutputStream qbitOut = new ByteArrayOutputStream();
SerializerUtils.serializeData(qbitOut, vec, ClickHouseColumn.of("vec", "QBit(Float32, 8)"));

// A correctly-sized QBit passes validation and is serialized byte-for-byte identically to
// Array(element_type), which is the wire contract the reader relies on.
ByteArrayOutputStream arrayOut = new ByteArrayOutputStream();
SerializerUtils.serializeData(arrayOut, vec, ClickHouseColumn.of("vec", "Array(Float32)"));
Assert.assertEquals(qbitOut.toByteArray(), arrayOut.toByteArray());
}

@Test(dataProvider = "nonNullableEnumTypes")
public void testNullIntoNonNullableEnumThrowsIllegalArgument(String typeName) {
ClickHouseColumn column = ClickHouseColumn.of("bs_flag", typeName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,17 @@ public void tearDown() {

private <T> void writeReadVerify(String table, String tableDef, Class<T> dtoClass, List<T> data,
BiConsumer<List<T>, T> rowVerifier) throws Exception {
writeReadVerify(table, tableDef, dtoClass, data, rowVerifier, null);
}

private <T> void writeReadVerify(String table, String tableDef, Class<T> dtoClass, List<T> data,
BiConsumer<List<T>, T> rowVerifier, CommandSettings ddlSettings) throws Exception {
client.execute("DROP TABLE IF EXISTS " + table).get();
client.execute(tableDef);
if (ddlSettings == null) {
client.execute(tableDef);
Comment thread
polyglotAI-bot marked this conversation as resolved.
Outdated
} else {
client.execute(tableDef, ddlSettings).get();
}

final TableSchema tableSchema = client.getTableSchema(table);
client.register(dtoClass, tableSchema);
Expand All @@ -107,6 +116,88 @@ private <T> void writeReadVerify(String table, String tableDef, Class<T> dtoClas
Assert.assertEquals(rowCount.get(), data.size());
}

// QBit was introduced in ClickHouse 25.10.
private static final String QBIT_UNSUPPORTED_VERSIONS = "(,25.9]";

@Data
@AllArgsConstructor
@NoArgsConstructor
public static class QBitFloat32DTO {
private long rowId;
private float[] vec;
private int tail;

public static String tblCreateSQL(String table) {
return tableDefinition(table, "rowId Int64", "vec QBit(Float32, 8)", "tail Int32");
}
}

@Data
@AllArgsConstructor
@NoArgsConstructor
public static class QBitFloat64DTO {
private long rowId;
private double[] vec;
private int tail;

public static String tblCreateSQL(String table) {
return tableDefinition(table, "rowId Int64", "vec QBit(Float64, 8)", "tail Int32");
}
}

@Data
@AllArgsConstructor
@NoArgsConstructor
public static class QBitBFloat16DTO {
private long rowId;
private float[] vec;
private int tail;

public static String tblCreateSQL(String table) {
return tableDefinition(table, "rowId Int64", "vec QBit(BFloat16, 8)", "tail Int32");
}
}

@Test(groups = {"integration"})
public void testQBit() throws Exception {
if (isVersionMatch(QBIT_UNSUPPORTED_VERSIONS)) {
throw new SkipException("QBit requires ClickHouse 25.10+");
}

// A QBit(element_type, dimension) value is transmitted over RowBinary exactly like an
// Array(element_type): a var-int length followed by that many element values. The full
// client write -> server -> client read round-trip is verified for every supported
// element type. The trailing Int32 column would shift (and the assertion fail) if the
// QBit codec consumed the wrong number of bytes.
CommandSettings ddl = (CommandSettings) new CommandSettings()
.serverSetting("allow_experimental_qbit_type", "1");

final float[] f32 = {1f, -2f, 3.5f, 4f, 5f, 6f, 7f, 8f};
writeReadVerify("test_qbit_f32", QBitFloat32DTO.tblCreateSQL("test_qbit_f32"),
QBitFloat32DTO.class, Arrays.asList(new QBitFloat32DTO(0, f32, 42)),
(all, dto) -> {
Assert.assertEquals(dto.getVec(), f32);
Assert.assertEquals(dto.getTail(), 42);
}, ddl);

final double[] f64 = {1d, -2d, 3.5d, 4d, 5d, 6d, 7d, 8d};
writeReadVerify("test_qbit_f64", QBitFloat64DTO.tblCreateSQL("test_qbit_f64"),
QBitFloat64DTO.class, Arrays.asList(new QBitFloat64DTO(0, f64, 42)),
(all, dto) -> {
Assert.assertEquals(dto.getVec(), f64);
Assert.assertEquals(dto.getTail(), 42);
}, ddl);

// Integers up to 256 are exactly representable in BFloat16, so these round-trip bit-for-bit.
final float[] bf16 = {1f, 2f, 4f, 8f, 16f, 32f, 64f, 128f};
writeReadVerify("test_qbit_bf16", QBitBFloat16DTO.tblCreateSQL("test_qbit_bf16"),
QBitBFloat16DTO.class, Arrays.asList(new QBitBFloat16DTO(0, bf16, 42)),
(all, dto) -> {
Assert.assertEquals(dto.getVec(), bf16);
Assert.assertEquals(dto.getTail(), 42);
}, ddl);
}

@Test(groups = {"integration"})
public void testNestedDataTypes() throws Exception {
final String table = "test_nested_types";
Expand Down
Loading
Loading