feat(client-v2, jdbc-v2): add QBit data type support - #2939
Conversation
QBit(element_type, dimension) is transmitted over RowBinary exactly like Array(element_type) - a var-int length followed by that many element values - so it is read and written as a Java array of its element type (float[] for BFloat16/Float32, double[] for Float64) reusing the existing array codec, while keeping dataType == QBit for metadata fidelity. In jdbc-v2 QBit maps to java.sql.Types.ARRAY and is exposed as a java.sql.Array. Element types: BFloat16, Float32, Float64. The bit-transposed on-disk layout is a server storage detail and is not exposed to the client. Implements: #2610
Client V2 CoverageCoverage Report
Class Coverage
|
JDBC V2 CoverageCoverage Report
Class Coverage
|
JDBC V1 CoverageCoverage Report
Class Coverage
|
Client V1 CoverageCoverage Report
Class Coverage
|
QBit(element_type, dimension) is a fixed-size vector: the server requires exactly `dimension` elements and rejects any other length over RowBinary with a late SERIALIZATION_ERROR (verified against server 26.5). The write path reused serializeArrayData, which wrote the actual array/list length without checking it, so a wrong-sized (including empty) vector was serialized like a normal Array and only failed on the server after a round-trip. Split the QBit case out of the Array fall-through into serializeQBitData, which validates the element count against column.getPrecision() (the dimension) and throws a clear IllegalArgumentException naming the column, expected dimension, and actual length before delegating to serializeArrayData. This mirrors the client-side length enforcement already applied to the other fixed-size type, FixedString(N). A correctly-sized QBit is still byte-for-byte identical to Array(element_type). Addresses Cursor Bugbot review on PR #2939.
TriageCategory: Summary What this impacts
Concerns
Required reviewer action
|
A non-null QBit value that is neither a Java array nor a List fell through serializeQBitData to serializeArrayData, which writes no bytes for such a value, desynchronizing the RowBinary stream so the following columns are misread or rejected by the server. Reject it up-front with a clear IllegalArgumentException, consistent with the existing fixed-dimension validation and the Geometry reject-unsupported path. Addresses Cursor Bugbot review on #2939.
|
@cursor review |
There was a problem hiding this comment.
Pull request overview
Adds end-to-end support for ClickHouse’s experimental QBit(element_type, dimension) vector type across the shared type parser (clickhouse-data), the client-v2 RowBinary reader/writer, and jdbc-v2 type mapping and array handling, with integration/unit test coverage and documentation updates.
Changes:
- Parse
QBit(E, N)inclickhouse-dataas a one-level “array-like” type (nested element column + dimension retained as precision). - Reuse existing
Array(element_type)RowBinary read/write paths forQBitinclient-v2, including parameter string rendering as an array literal. - Expose
QBitasTypes.ARRAYinjdbc-v2and ensure JDBCArraymetadata reports the element type (not the fullQBit(...)type).
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| jdbc-v2/src/test/java/com/clickhouse/jdbc/JdbcDataTypeTests.java | Adds JDBC integration test for QBit mapping, PreparedStatement write, and ResultSet read/metadata. |
| jdbc-v2/src/main/java/com/clickhouse/jdbc/types/Array.java | Treats QBit as array-nested for base type/name reporting via getArrayNestedLevel(). |
| jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcUtils.java | Maps ClickHouseDataType.QBit to JDBCType.ARRAY. |
| docs/features.md | Documents QBit wire contract/behavior and JDBC mapping expectations. |
| client-v2/src/test/java/com/clickhouse/client/datatypes/DataTypeTests.java | Adds client-v2 integration round-trip tests for QBit with Float32/Float64/BFloat16, plus DDL settings support in helper. |
| client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/SerializerUtilsTest.java | Adds serializer-level validation tests for QBit dimension and input type. |
| client-v2/src/main/java/com/clickhouse/client/api/internal/DataTypeConverter.java | Renders QBit parameters as array literals by reusing the Array path. |
| client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/SerializerUtils.java | Adds QBit serialization that validates fixed dimension and type, then delegates to array serialization. |
| client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReader.java | Reads QBit by reusing Array decoding. |
| clickhouse-data/src/test/java/com/clickhouse/data/ClickHouseColumnTest.java | Adds unit tests for QBit parsing (element type + dimension) and missing-dimension failure case. |
| clickhouse-data/src/main/java/com/clickhouse/data/ClickHouseColumn.java | Adds QBit keyword parsing, nested element column setup, and dimension retention in precision. |
| CHANGELOG.md | Adds release note entry for QBit support and behavior. |
…eject null QBit, await DDL in test Review response on PR #2939: - NativeFormatReader: reject reading any column that is or contains a QBit (recursive check). The server transmits QBit in the Native format using its internal bit-transposed Tuple(FixedString(...)) layout, not the Array-like RowBinary representation this reader decodes, so the previous fall-through misread the bytes and desynchronized the block. Fail loud with a clear ClientException pointing to a RowBinary format instead of decoding garbage. - SerializerUtils.serializeQBitData: reject a null value. A fixed-dimension QBit cannot be null; a QBit nested in a container (Tuple/Map/Array) reaches this path via serializeNestedData without the top-level preamble, so without the guard the null was written as a zero-length vector, desynchronizing the stream. - DataTypeTests.writeReadVerify: await client.execute(tableDef) in the no-ddl branch (it was unawaited, racing the subsequent getTableSchema). - Tests: Native rejection (top-level + nested Map(String,QBit)); null QBit rejection (direct + nested-in-Tuple). docs/features.md + CHANGELOG updated. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 557768b. Configure here.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
client-v2/src/main/java/com/clickhouse/client/api/data_formats/NativeFormatReader.java:116
- In NativeFormatReader's Array handling, the per-row offsets are read into
sizes, butreadArrayItem(...)is always invoked withsizes[0]. This makes every row read the same element count and can desynchronize the Native stream for multi-row results. In ClickHouse Native format, Array columns store cumulative offsets, so the per-row length should be computed as a difference between successive offsets.
if (column.isArray()) {
int[] sizes = new int[nRows];
for (int j = 0; j < nRows; j++) {
sizes[j] = Math.toIntExact(binaryStreamReader.readLongLE());
}
for (int j = 0; j < nRows; j++) {
values.add(binaryStreamReader.readArrayItem(column.getNestedColumns().get(0), sizes[0]));
|
Thanks @mshustov for the re-review. Addressing the one low-confidence item Copilot surfaced (suppressed) on Verdict: it's a genuine bug, but it is pre-existing and unrelated to this QBit PR — so I'm scoping it out of this PR and tracking it for a separate fix. Not introduced here. The concern is real for multi-row Line 116 calls Suggested fix (per-row length = successive-offset difference): for (int j = 0; j < nRows; j++) {
int len = sizes[j] - (j == 0 ? 0 : sizes[j - 1]);
values.add(binaryStreamReader.readArrayItem(column.getNestedColumns().get(0), len));
}Since this is independent of the QBit feature and lives in the general Array read path, I'm keeping this PR focused on QBit (one concern per PR) and will handle the |
chernser
left a comment
There was a problem hiding this comment.
- use dedicated method to handle QBit.
- do more effort to support native format
- do more effort in handling QBit with strides as it get virtual columns.
…-type parsing, dedicated methods, Dynamic decode Addresses @chernser's review round on PR #2939: - ClickHouseColumn: accept QBit(element_type, dimension[, stride]) (two or three parameters); warn instead of rejecting when the element type is outside the documented {Int8, BFloat16, Float32, Float64} set, so a newer server-side element type keeps parsing without a client change. - BinaryStreamReader: dedicated readQBit method (QBit no longer folded into the Array switch case); readDynamicData now decodes a QBit nested in a Dynamic/Variant/JSON column (0x36 <element_type> <dimension>) instead of desynchronizing the stream. - DataTypeConverter: dedicated qbitToString method. - Tests: broaden ClickHouseColumnTest parser cases (Int8, larger dimensions, the stride form, a >3-parameter rejection, and an undocumented-element-type-parses case) and add a QBit-in-Dynamic round-trip integration test with a trailing-column desync guard. - docs/features.md + CHANGELOG updated. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…Bit read dimension Addresses the Cursor Bugbot review round on PR #2939: - writeDynamicTypeTag rejects QBit explicitly (ClientException) instead of falling through to the default branch and emitting a bare 0x36 tag, while readDynamicData expects 0x36 <element_type> <var_uint dimension>. The client never infers a QBit from a Java value (valueToColumnForDynamicType only yields Array/Map/scalar), so this write path is unreachable via the public API; failing loud removes the write/read asymmetry rather than silently mis-encoding. Reading a server-sent QBit inside a Dynamic column remains supported. - readQBit validates the on-wire element count against the declared dimension (symmetric with the write-side serializeQBitData check), surfacing a corrupt count as a clear error instead of a wrong-length vector. The read itself cannot misalign the stream: readArray consumes exactly the length-prefixed count. - Tests: testDynamicTypeTagRejectsQBit + testQBitReadRejectsWrongDimension. - docs/features.md: Dynamic QBit is read-only (write rejected); document the read-side dimension validation. The 0x37 strided-QBit Dynamic tag is deliberately not decoded: server 26.5.1 rejects 3-parameter QBit so the wire form is unverifiable, and an unknown tag already fails loud in readDynamicData. Left for the pending strides scope decision. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
CI note — the Diagnosis:
I have not modified the test (it is pre-existing and unrelated to |
The QBit -> SQL text path (DataTypeConverter.convertToString QBit case -> qbitToString) was exercised only by the version-gated QBit integration tests, which the SonarCloud coverage job (ClickHouse 25.3, below QBit's 25.10 minimum) skips -- leaving those lines uncovered on new code. Add a unit test asserting the array-literal rendering for Float32/Float64 QBit columns so the codec path is covered independently of the server version.
CI fix — SonarCloud "Coverage on New Code" (18.7%)The failing SonarCloud Quality Gate was not an actual gap in the QBit code. Root cause:
Fix (no change to QBit production behavior):
Verified locally: |
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit df12232. Configure here.
…sTest The "Merge branch 'main'" commit (887c3a7) dropped the closing brace of testQBitReadRejectsWrongDimension() at the seam where main's dynamicDecimalTypeInference @dataProvider was interleaved, leaving the method body unterminated. That broke the JDK 8 compile of the client-v2 test sources (cascading ';' expected / not a statement / illegal start of expression). No production code is affected — restore the single closing brace. Verified: client-v2 + jdbc-v2 test sources compile; SerializerUtilsTest 96/96 green.
|



Description
Closes #2610.
Adds read/write support for the experimental
QBit(element_type, dimension)vector data type (ClickHouse25.10+) toclient-v2andjdbc-v2.QBitwas previously only an unimplemented type constant (#2646), and every data-type test skipped it with// TODO: add support.Supported element types:
BFloat16,Float32,Float64(the server rejects any other element type). Theallow_experimental_qbit_typeserver setting is required only to create aQBitcolumn — insert/select on an existing column do not need it.Design
The key finding (verified byte-for-byte against a live ClickHouse
26.5server): aQBit(element_type, dimension)value is transmitted overRowBinaryexactly likeArray(element_type)— a var-int length prefix followed by that many little-endian element values. The bit-transposedTuple(FixedString(...))layout referenced in the issue is a MergeTree on-disk storage detail, not the wire format; on the wire the server presents (and accepts) the logical vector as an array, and reports the column type string asQBit(Float32, 8).So
QBitis implemented as a one-level array of its element type, reusing the existing, well-tested array code paths, while keepingdataType == QBitfor metadata fidelity:clickhouse-dataClickHouseColumn): aQBitbranch parses(element_type, dimension), builds the nested element column, setsarrayLevel = 1/arrayBaseColumn, and keeps the dimension as the columnprecision.client-v2BinaryStreamReader):case QBitreuses theArrayread (readArray).client-v2SerializerUtils):case QBitreusesserializeArrayData.float[]forBFloat16/Float32,double[]forFloat64— identical toArray(element_type), through generic records and POJO binding.QBitmaps tojava.sql.Types.ARRAY;getObject/getArrayreturn ajava.sql.Arrayof the element type, andPreparedStatementbinding renders it via the array-literal path (DataTypeConverter).Entry points covered:
client-v2read + write (generic + POJO);jdbc-v2read + write; the shared type parser.Changes
clickhouse-data/.../ClickHouseColumn.java—KEYWORD_QBIT;QBitparse branch;QBitcase inupdate().client-v2/.../BinaryStreamReader.java—case QBit→ array read.client-v2/.../SerializerUtils.java—case QBit→ array write.client-v2/.../internal/DataTypeConverter.java—case QBit→ array-literal rendering (JDBC/SQL param path).jdbc-v2/.../internal/JdbcUtils.java—QBit→JDBCType.ARRAY.jdbc-v2/.../types/Array.java— resolve the array element column viagetArrayNestedLevel() > 0(wasisArray(), which isfalseforQBit) sogetBaseType()/getBaseTypeName()report the element type, not the wholeQBittype.docs/features.md,CHANGELOG.md— documentation.Test
ClickHouseColumnTest(unit,@DataProvider): parsingQBit(E, N)for all three element types (dataType, element column, dimension) + a negative missing-dimension case.DataTypeTests(client-v2 integration): full client write → server → read round-trip forFloat32/Float64/BFloat16. TheQBitcolumn sits mid-schema with a trailingInt32column, so a codec byte-misalignment would shift it and fail the assertion.JdbcDataTypeTests(jdbc-v2 integration):PreparedStatementwrite +ResultSetread; assertsgetColumnType == ARRAY,Array.getBaseType == FLOAT/getBaseTypeName == "Float32", and the element values.Regression: full
clickhouse-dataunit suite (1660) and the existingArrayintegration tests in both modules pass unedited.Notes
QBitnested insideVariant/Dynamic— the server support there is experimental and the pre-existing tests already skip those combinations; left out of scope.nullinto a non-nullableQBitcolumn throws a clear client-side error (rather than emitting an empty vector asArraydoes), which is intentional givenQBit's fixed dimension — an empty vector is never valid.Pre-PR validation gate
Array(element_type);QBit→ARRAYlikeGeometry/Point)docs/features.md(client-v2 + jdbc-v2) andCHANGELOG.mdupdatedAGENTS.md/docs/features.md/docs/changes_checklist.md