Skip to content
Open
Show file tree
Hide file tree
Changes from all 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).
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.

/**
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);
} 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