Skip to content

Commit 0e1a6bb

Browse files
feat(client-v2): validate QBit vector dimension on write
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.
1 parent 0328db3 commit 0e1a6bb

3 files changed

Lines changed: 70 additions & 2 deletions

File tree

client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/SerializerUtils.java

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ public static void serializeData(OutputStream stream, Object value, ClickHouseCo
6464
//Serialize the value to the stream based on the data type
6565
switch (column.getDataType()) {
6666
case QBit:
67-
// QBit(element_type, dimension) is serialized like Array(element_type).
67+
serializeQBitData(stream, value, column);
68+
break;
6869
case Array:
6970
serializeArrayData(stream, value, column);
7071
break;
@@ -472,6 +473,32 @@ public static void serializeArrayData(OutputStream stream, Object value, ClickHo
472473
}
473474
}
474475

476+
/**
477+
* Serializes a {@code QBit(element_type, dimension)} value. On the wire a {@code QBit} is
478+
* transmitted exactly like {@code Array(element_type)} — a var-int length followed by that many
479+
* element values — but the element count is fixed and must equal the declared dimension. The
480+
* count is validated up-front so a wrong-sized (including empty) vector fails fast on the client
481+
* with a clear message instead of a late server {@code SERIALIZATION_ERROR}, mirroring the
482+
* client-side length enforcement already applied to the other fixed-size type,
483+
* {@code FixedString(N)}.
484+
*/
485+
private static void serializeQBitData(OutputStream stream, Object value, ClickHouseColumn column) throws IOException {
486+
if (value != null) {
487+
int length = -1;
488+
if (value.getClass().isArray()) {
489+
length = Array.getLength(value);
490+
} else if (value instanceof List) {
491+
length = ((List<?>) value).size();
492+
}
493+
int dimension = column.getPrecision();
494+
if (length >= 0 && length != dimension) {
495+
throw new IllegalArgumentException("QBit column '" + column.getColumnName()
496+
+ "' expects exactly " + dimension + " elements but got " + length);
497+
}
498+
}
499+
serializeArrayData(stream, value, column);
500+
}
501+
475502
/**
476503
DO NOT USE - part of internal API that will be changed
477504
*/

client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/SerializerUtilsTest.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,47 @@ public void testGeometrySerializationRejectsMalformedList() {
143143
ClickHouseColumn.of("v", "Geometry")));
144144
}
145145

146+
@Test(dataProvider = "qbitWrongDimension")
147+
public void testQBitSerializationRejectsWrongDimension(String typeName, Object value, int actualLength) {
148+
ClickHouseColumn column = ClickHouseColumn.of("vec", typeName);
149+
150+
IllegalArgumentException ex = Assert.expectThrows(IllegalArgumentException.class,
151+
() -> SerializerUtils.serializeData(new ByteArrayOutputStream(), value, column));
152+
String message = ex.getMessage();
153+
Assert.assertTrue(message.contains("vec"), "Message should name the column: " + message);
154+
Assert.assertTrue(message.contains("8"), "Message should state the expected dimension: " + message);
155+
Assert.assertTrue(message.contains("got " + actualLength),
156+
"Message should state the actual length: " + message);
157+
}
158+
159+
@DataProvider(name = "qbitWrongDimension")
160+
private Object[][] qbitWrongDimension() {
161+
// A QBit(E, 8) column requires exactly 8 elements: empty, too-short, and too-long vectors are
162+
// all invalid, for both the Java-array and List representations and every element type.
163+
return new Object[][] {
164+
{"QBit(Float32, 8)", new float[0], 0},
165+
{"QBit(Float32, 8)", new float[] {1f, 2f, 3f, 4f, 5f}, 5},
166+
{"QBit(Float32, 8)", new float[] {1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 10f}, 10},
167+
{"QBit(Float64, 8)", new double[] {1d, 2d, 3d, 4d, 5d}, 5},
168+
{"QBit(BFloat16, 8)", new float[] {1f, 2f, 3f}, 3},
169+
{"QBit(Float32, 8)", Arrays.asList(1f, 2f, 3f), 3},
170+
};
171+
}
172+
173+
@Test
174+
public void testQBitSerializationAcceptsExactDimensionAndMatchesArray() throws Exception {
175+
float[] vec = {1f, -2f, 3.5f, 4f, 5f, 6f, 7f, 8f};
176+
177+
ByteArrayOutputStream qbitOut = new ByteArrayOutputStream();
178+
SerializerUtils.serializeData(qbitOut, vec, ClickHouseColumn.of("vec", "QBit(Float32, 8)"));
179+
180+
// A correctly-sized QBit passes validation and is serialized byte-for-byte identically to
181+
// Array(element_type), which is the wire contract the reader relies on.
182+
ByteArrayOutputStream arrayOut = new ByteArrayOutputStream();
183+
SerializerUtils.serializeData(arrayOut, vec, ClickHouseColumn.of("vec", "Array(Float32)"));
184+
Assert.assertEquals(qbitOut.toByteArray(), arrayOut.toByteArray());
185+
}
186+
146187
@Test(dataProvider = "nonNullableEnumTypes")
147188
public void testNullIntoNonNullableEnumThrowsIllegalArgument(String typeName) {
148189
ClickHouseColumn column = ClickHouseColumn.of("bs_flag", typeName);

docs/features.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Compatibility-sensitive traits:
4444
- Identifier quoting behavior is stable API for helper callers: identifiers are double-quoted, embedded double quotes are doubled, and optional quoting keeps simple identifiers unchanged.
4545
- Instant formatting is type-sensitive and should not drift: `Date` formatting depends on an explicit timezone, `DateTime` is serialized as epoch seconds, and higher-precision timestamps preserve up to 9 fractional digits.
4646
- `BFloat16` conversion is precision-sensitive and should not drift: a write keeps only the high 16 bits of the `float` (the low mantissa bits are truncated toward zero, matching the server's `Float32``BFloat16` conversion), so values that are not exactly representable in `BFloat16` change when written; a read widens the 16-bit value back to `float` losslessly.
47-
- `QBit` is wire-compatible with `Array(element_type)` and should not drift: the client transmits the logical vector as a length-prefixed array of its element type (`float[]` for `BFloat16`/`Float32`, `double[]` for `Float64`) rather than the server's bit-transposed on-disk layout, so a `QBit(E, N)` value read from or written to the server round-trips as an array of `E`.
47+
- `QBit` is wire-compatible with `Array(element_type)` and should not drift: the client transmits the logical vector as a length-prefixed array of its element type (`float[]` for `BFloat16`/`Float32`, `double[]` for `Float64`) rather than the server's bit-transposed on-disk layout, so a `QBit(E, N)` value read from or written to the server round-trips as an array of `E`. A written `QBit(E, N)` value must contain exactly `N` elements: a wrong-sized (including empty) vector is rejected during binary serialization with an `IllegalArgumentException` rather than deferred to a server error, matching the fixed dimension the server enforces.
4848
- Timezone conversion helpers preserve nanoseconds and can intentionally shift local date or time when interpreted in a different timezone; this behavior is covered by tests and should not be normalized away.
4949
- `Geometry` handling is shape-sensitive: supported values are 1D through 4D Java arrays representing the nested geometry variants, and unsupported shapes or non-array values are rejected during serialization.
5050
- `Geometry` write inference is dimension-based rather than fully type-specific: point, ring/line string, polygon/multi-line string, and multi-polygon are selected from array depth, so writing `Geometry` cannot currently distinguish `Ring` from `LineString` or `Polygon` from `MultiLineString`.

0 commit comments

Comments
 (0)