diff --git a/CHANGELOG.md b/CHANGELOG.md index ab70ea343..6a1d8f8de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Bug Fixes +- **[client-v2]** Fixed binary array decoding for nullable element types so `Array(Nullable(Float64))` and similar columns now return boxed arrays such as `Double[]` instead of `Object[]`. This keeps null-supporting arrays aligned with their element type while preserving the existing `Object[]` fallback for Variant/Dynamic/Geometry arrays. (https://github.com/ClickHouse/clickhouse-java/issues/2846) - **[client-v2]** Fixed binary varint decoding for length and count fields so overflowing or overlong values fail with an `IOException` instead of being decoded into corrupted or negative `int` values. (https://github.com/ClickHouse/clickhouse-java/issues/2902) - **[client-v2]** Fixed container query parameters being sent unquoted, so `Client.query(sql, params, settings)` binding diff --git a/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReader.java b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReader.java index 83c98907b..41e0709f8 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReader.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReader.java @@ -609,8 +609,8 @@ public ArrayValue readArray(ClickHouseColumn column) throws IOException { ArrayValue array; ClickHouseColumn itemTypeColumn = column.getNestedColumns().get(0); if (len == 0) { - Class> itemClass = itemTypeColumn.getDataType().getPrimitiveClass(); - array = new ArrayValue(itemClass == null ? Object.class : itemClass, 0); + Class> itemClass = resolveArrayItemClass(itemTypeColumn); + array = new ArrayValue(itemClass, 0); } else if (column.getArrayNestedLevel() == 1) { array = readArrayItem(itemTypeColumn, len); } else { @@ -625,8 +625,13 @@ public ArrayValue readArray(ClickHouseColumn column) throws IOException { public ArrayValue readArrayItem(ClickHouseColumn itemTypeColumn, int len) throws IOException { ArrayValue array; - if (itemTypeColumn.isNullable() - || itemTypeColumn.getDataType() == ClickHouseDataType.Variant + if (itemTypeColumn.isNullable()) { + Class> itemClass = resolveArrayItemClass(itemTypeColumn); + array = new ArrayValue(itemClass, len); + for (int i = 0; i < len; i++) { + array.set(i, readValue(itemTypeColumn)); + } + } else if (itemTypeColumn.getDataType() == ClickHouseDataType.Variant || itemTypeColumn.getDataType() == ClickHouseDataType.Dynamic || itemTypeColumn.getDataType() == ClickHouseDataType.Geometry) { array = new ArrayValue(Object.class, len); @@ -667,6 +672,64 @@ public ArrayValue readArrayItem(ClickHouseColumn itemTypeColumn, int len) throws return array; } + /** + * Resolves the Java class that {@link #readValue} actually returns for a given column + * so that it can be used as the component type of an array. + * + *
For unsigned integer types, {@code readValue} widens the value (e.g. UInt8 → Short, + * UInt32 → Long), so we use {@link ClickHouseDataType#getWiderObjectClass()} or + * {@link ClickHouseDataType#getWiderPrimitiveClass()} which mirrors that widening. + * For Enum types, {@code readValue} returns {@link EnumValue} rather than the + * declared {@code String.class}. All other types use {@link ClickHouseDataType#getObjectClass()} + * or {@link ClickHouseDataType#getPrimitiveClass()}. + * + * @param itemTypeColumn the element column of the array + * @return the Java class to use as the array component type; never {@code null} + */ + private static Class> resolveArrayItemClass(ClickHouseColumn itemTypeColumn) { + ClickHouseDataType dataType = itemTypeColumn.getDataType(); + if (itemTypeColumn.isNullable()) { + switch (dataType) { + case UInt8: + case UInt16: + case UInt32: + case UInt64: + return dataType.getWiderObjectClass(); + case Enum8: + case Enum16: + return EnumValue.class; + default: + Class> cls = dataType.getObjectClass(); + return cls == null ? Object.class : cls; + } + } else { + switch (dataType) { + case UInt8: + case UInt16: + case UInt32: + case UInt64: + return dataType.getWiderPrimitiveClass(); + case Enum8: + case Enum16: + return EnumValue.class; + case Variant: + case Dynamic: + case Geometry: + return Object.class; + case Array: + return ArrayValue.class; + case Tuple: + case Nested: + return Object[].class; + case Map: + return Map.class; + default: + Class> cls = dataType.getPrimitiveClass(); + return cls == null ? Object.class : cls; + } + } + } + public void skipValue(ClickHouseColumn column) throws IOException { readValue(column, null); } diff --git a/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReaderTests.java b/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReaderTests.java index 2852d63df..4b089a8ea 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReaderTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReaderTests.java @@ -1,6 +1,7 @@ package com.clickhouse.client.api.data_formats.internal; import com.clickhouse.data.ClickHouseColumn; +import com.clickhouse.data.format.BinaryStreamUtils; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -206,24 +207,102 @@ public void testReadNullVariantReturnsNull() throws Exception { } @Test - public void testReadVarIntReadsMaxInt() throws IOException { - Assert.assertEquals(readVarInt((byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0x07), - Integer.MAX_VALUE); + public void testNullableArrayValueUsesBoxedComponentType() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + BinaryStreamUtils.writeVarInt(baos, 2); + BinaryStreamUtils.writeNonNull(baos); + BinaryStreamUtils.writeFloat64(baos, 1.0); + BinaryStreamUtils.writeNonNull(baos); + BinaryStreamUtils.writeFloat64(baos, 2.0); + + BinaryStreamReader reader = new BinaryStreamReader( + new ByteArrayInputStream(baos.toByteArray()), + TimeZone.getTimeZone("UTC"), + null, + new BinaryStreamReader.CachingByteBufferAllocator(), + false, + null); + + BinaryStreamReader.ArrayValue array = (BinaryStreamReader.ArrayValue) reader.readValue( + ClickHouseColumn.of("v", "Array(Nullable(Float64))")); + + Assert.assertEquals(array.getArray().getClass().getComponentType(), Double.class); } @Test - public void testReadVarIntRejectsOverflow() { - Assert.assertThrows(IOException.class, - () -> readVarInt((byte) 0x80, (byte) 0x80, (byte) 0x80, (byte) 0x80, (byte) 0x08)); + public void testNullableUnsignedArrayUsesWidenedType() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + BinaryStreamUtils.writeVarInt(baos, 2); + BinaryStreamUtils.writeNonNull(baos); + BinaryStreamUtils.writeUnsignedInt8(baos, 10); + BinaryStreamUtils.writeNonNull(baos); + BinaryStreamUtils.writeUnsignedInt8(baos, 20); + + BinaryStreamReader reader = new BinaryStreamReader( + new ByteArrayInputStream(baos.toByteArray()), + TimeZone.getTimeZone("UTC"), + null, + new BinaryStreamReader.CachingByteBufferAllocator(), + false, + null); + + BinaryStreamReader.ArrayValue array = (BinaryStreamReader.ArrayValue) reader.readValue( + ClickHouseColumn.of("v", "Array(Nullable(UInt8))")); + + Assert.assertEquals(array.getArray().getClass().getComponentType(), Short.class); } @Test - public void testReadVarIntRejectsOverlongValue() { - Assert.assertThrows(IOException.class, - () -> readVarInt((byte) 0x80, (byte) 0x80, (byte) 0x80, (byte) 0x80, (byte) 0x80, (byte) 0x01)); + public void testNullableEnumArrayUsesEnumValueType() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + BinaryStreamUtils.writeVarInt(baos, 2); + BinaryStreamUtils.writeNonNull(baos); + baos.write(1); // enum ordinal for 'a' + BinaryStreamUtils.writeNonNull(baos); + baos.write(2); // enum ordinal for 'b' + + BinaryStreamReader reader = new BinaryStreamReader( + new ByteArrayInputStream(baos.toByteArray()), + TimeZone.getTimeZone("UTC"), + null, + new BinaryStreamReader.CachingByteBufferAllocator(), + false, + null); + + BinaryStreamReader.ArrayValue array = (BinaryStreamReader.ArrayValue) reader.readValue( + ClickHouseColumn.of("v", "Array(Nullable(Enum8('a'=1,'b'=2)))")); + + Assert.assertEquals(array.getArray().getClass().getComponentType(), + BinaryStreamReader.EnumValue.class); + } + + @Test + public void testEmptyArrayTypes() throws Exception { + assertEmptyArrayComponentType("Array(UInt8)", short.class); + assertEmptyArrayComponentType("Array(Nullable(UInt8))", Short.class); + assertEmptyArrayComponentType("Array(String)", String.class); + assertEmptyArrayComponentType("Array(Nullable(String))", String.class); + assertEmptyArrayComponentType("Array(Enum8('a'=1))", BinaryStreamReader.EnumValue.class); + assertEmptyArrayComponentType("Array(Nullable(Enum8('a'=1)))", BinaryStreamReader.EnumValue.class); + assertEmptyArrayComponentType("Array(Variant(Int32, String))", Object.class); + assertEmptyArrayComponentType("Array(Array(String))", BinaryStreamReader.ArrayValue.class); } - private static int readVarInt(byte... bytes) throws IOException { - return BinaryStreamReader.readVarInt(new ByteArrayInputStream(bytes)); + private void assertEmptyArrayComponentType(String columnType, Class> expectedComponentType) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + BinaryStreamUtils.writeVarInt(baos, 0); + + BinaryStreamReader reader = new BinaryStreamReader( + new ByteArrayInputStream(baos.toByteArray()), + TimeZone.getTimeZone("UTC"), + null, + new BinaryStreamReader.CachingByteBufferAllocator(), + false, + null); + + BinaryStreamReader.ArrayValue array = (BinaryStreamReader.ArrayValue) reader.readValue( + ClickHouseColumn.of("v", columnType)); + + Assert.assertEquals(array.getArray().getClass().getComponentType(), expectedComponentType, "Failed for " + columnType); } }