From 2bdba3771f2507da00c37647486f749d82f6a3fc Mon Sep 17 00:00:00 2001 From: Payal Lenka Date: Wed, 4 Nov 2026 23:41:53 +0530 Subject: [PATCH 1/2] [client-v2] Type nullable arrays by the values read Reading Array(Nullable(T)) failed with "array element type mismatch" when readValue returns a different class than the one declared for T: UInt64 (BigInteger, array was UnsignedLong[]), DateTime and DateTime64 (ZonedDateTime, array was LocalDateTime[]) and IPv6 holding IPv4-mapped addresses (Inet4Address). Allocate the array with the common class of the values read, and use the declared type only for an array of NULLs. Correct that fallback for UInt64, DateTime and Tuple. Fixes #3283 Fixes #3287 --- .../internal/BinaryStreamReader.java | 37 ++++++++-- ...seBinaryFormatReaderNullableArrayTest.java | 71 +++++++++++++++++++ history/latest/3283.md | 8 +++ 3 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 history/latest/3283.md 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 018b0aeb62..f8390a3170 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 @@ -801,10 +801,20 @@ public ArrayValue readArrayItem(ClickHouseColumn itemTypeColumn, int len) throws } ArrayValue array; if (itemTypeColumn.isNullable()) { - Class itemClass = resolveArrayItemClass(itemTypeColumn); - array = new ArrayValue(itemClass, len); + // Type the array by the values actually read: the declared type does not always tell + // which class readValue returns (UInt64 -> BigInteger, DateTime64 -> ZonedDateTime). + Object[] values = new Object[len]; + Class itemClass = null; + for (int i = 0; i < len; i++) { + Object value = readValue(itemTypeColumn); + values[i] = value; + if (value != null) { + itemClass = itemClass == null ? value.getClass() : commonSuperclass(itemClass, value.getClass()); + } + } + array = new ArrayValue(itemClass == null ? resolveArrayItemClass(itemTypeColumn) : itemClass, len); for (int i = 0; i < len; i++) { - array.set(i, readValue(itemTypeColumn)); + array.set(i, values[i]); } } else if (itemTypeColumn.getDataType() == ClickHouseDataType.Variant || itemTypeColumn.getDataType() == ClickHouseDataType.Dynamic @@ -868,8 +878,15 @@ private static Class resolveArrayItemClass(ClickHouseColumn itemTypeColumn) { case UInt8: case UInt16: case UInt32: - case UInt64: return dataType.getWiderObjectClass(); + case UInt64: + return BigInteger.class; + case DateTime: + case DateTime32: + case DateTime64: + return ZonedDateTime.class; + case Tuple: + return Object[].class; case Enum8: case Enum16: return EnumValue.class; @@ -905,6 +922,18 @@ private static Class resolveArrayItemClass(ClickHouseColumn itemTypeColumn) { } } + /** + * Returns the most specific class that both classes extend, so that an array of it can hold values + * of both. For example, IPv6 values can be read as {@link Inet4Address} or {@link Inet6Address}. + */ + private static Class commonSuperclass(Class a, Class b) { + Class cls = a; + while (!cls.isAssignableFrom(b)) { + cls = cls.getSuperclass(); + } + return cls; + } + /** * Reads a value nested inside a container (Array, Map, Tuple, Nested, Variant, JSON). Strings are always * decoded into {@link String} here, regardless of the binary-string feature flag, because nested types diff --git a/client-v2/src/test/java/com/clickhouse/client/api/data_formats/ClickHouseBinaryFormatReaderNullableArrayTest.java b/client-v2/src/test/java/com/clickhouse/client/api/data_formats/ClickHouseBinaryFormatReaderNullableArrayTest.java index 497548d19a..4121925a68 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/data_formats/ClickHouseBinaryFormatReaderNullableArrayTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/data_formats/ClickHouseBinaryFormatReaderNullableArrayTest.java @@ -9,6 +9,11 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.InputStream; +import java.math.BigInteger; +import java.net.InetAddress; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Arrays; import java.util.List; import java.util.TimeZone; @@ -372,4 +377,70 @@ public void testNullableArraysWithMixedTypes() throws Exception { Assert.assertNull(mixedList.get(3)); Assert.assertEquals(mixedList.get(4), 5L); } + + @Test + public void testNullableArraysOfTypesReadAsOtherClasses() throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + String[] names = new String[]{"uint64_arr", "datetime64_arr", "ipv6_arr", "all_null_uint64_arr"}; + String[] types = new String[]{"Array(Nullable(UInt64))", "Array(Nullable(DateTime64(3, 'UTC')))", + "Array(Nullable(IPv6))", "Array(Nullable(UInt64))"}; + + BinaryStreamUtils.writeVarInt(out, names.length); + for (String name : names) { + BinaryStreamUtils.writeString(out, name); + } + for (String type : types) { + BinaryStreamUtils.writeString(out, type); + } + + // Array(Nullable(UInt64)): [0, NULL, 18446744073709551615] + BigInteger maxUInt64 = new BigInteger("18446744073709551615"); + BinaryStreamUtils.writeVarInt(out, 3); + BinaryStreamUtils.writeNonNull(out); + BinaryStreamUtils.writeUnsignedInt64(out, BigInteger.ZERO); + BinaryStreamUtils.writeNull(out); + BinaryStreamUtils.writeNonNull(out); + BinaryStreamUtils.writeUnsignedInt64(out, maxUInt64); + + // Array(Nullable(DateTime64(3, 'UTC'))): ['2026-01-01 13:34:56.780', NULL] + LocalDateTime dateTime = LocalDateTime.of(2026, 1, 1, 13, 34, 56, 780_000_000); + BinaryStreamUtils.writeVarInt(out, 2); + BinaryStreamUtils.writeNonNull(out); + BinaryStreamUtils.writeDateTime64(out, dateTime, 3, TimeZone.getTimeZone("UTC")); + BinaryStreamUtils.writeNull(out); + + // Array(Nullable(IPv6)): ['::ffff:1.2.3.4', NULL, '2001:db8::1'] + // An IPv4-mapped address is read as Inet4Address, the other one as Inet6Address. + BinaryStreamUtils.writeVarInt(out, 3); + BinaryStreamUtils.writeNonNull(out); + out.write(new byte[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, (byte) 0xff, (byte) 0xff, 1, 2, 3, 4}); + BinaryStreamUtils.writeNull(out); + BinaryStreamUtils.writeNonNull(out); + out.write(InetAddress.getByName("2001:db8::1").getAddress()); + + // Array(Nullable(UInt64)): [NULL, NULL] + BinaryStreamUtils.writeVarInt(out, 2); + BinaryStreamUtils.writeNull(out); + BinaryStreamUtils.writeNull(out); + + InputStream in = new ByteArrayInputStream(out.toByteArray()); + QuerySettings querySettings = new QuerySettings().setUseTimeZone(TimeZone.getTimeZone("UTC").toZoneId().getId()); + RowBinaryWithNamesAndTypesFormatReader reader = + new RowBinaryWithNamesAndTypesFormatReader(in, querySettings, new BinaryStreamReader.CachingByteBufferAllocator()); + + reader.next(); + + List uint64List = reader.getList("uint64_arr"); + Assert.assertEquals(uint64List, Arrays.asList(BigInteger.ZERO, null, maxUInt64)); + + List dateTimeList = reader.getList("datetime64_arr"); + Assert.assertEquals(dateTimeList, Arrays.asList(dateTime.atZone(ZoneId.of("UTC")), null)); + + List ipv6List = reader.getList("ipv6_arr"); + Assert.assertEquals(ipv6List, Arrays.asList(InetAddress.getByName("1.2.3.4"), null, + InetAddress.getByName("2001:db8::1"))); + + List allNullList = reader.getList("all_null_uint64_arr"); + Assert.assertEquals(allNullList, Arrays.asList(null, null)); + } } diff --git a/history/latest/3283.md b/history/latest/3283.md new file mode 100644 index 0000000000..21709e2b30 --- /dev/null +++ b/history/latest/3283.md @@ -0,0 +1,8 @@ +- **[client-v2]** Fixed reading `Array(Nullable(T))` columns whose element values are read as a different class than + the one declared for `T`. Reading failed with `IllegalArgumentException: array element type mismatch` for + `Array(Nullable(UInt64))` (values are `BigInteger`, the array was `UnsignedLong[]`), for + `Array(Nullable(DateTime64))` and `Array(Nullable(DateTime))` (values are `ZonedDateTime`, the array was + `LocalDateTime[]`), and for `Array(Nullable(IPv6))` holding IPv4-mapped addresses (read as `Inet4Address`). The + array is now typed by the values that were read. An array of only `NULL` elements gets the same type as one with + values for `UInt64` (`BigInteger`), `DateTime`/`DateTime64` (`ZonedDateTime`) and `Tuple` (`Object[]`). + (https://github.com/ClickHouse/clickhouse-java/issues/3283, https://github.com/ClickHouse/clickhouse-java/issues/3287) From 4bacdc329206dc091e94d72926c628bc81c937d2 Mon Sep 17 00:00:00 2001 From: Payal Lenka Date: Fri, 6 Nov 2026 22:29:57 +0530 Subject: [PATCH 2/2] [client-v2] Keep Array(Nullable(Point)) readable Typing nullable arrays by the values read made Array(Nullable(Point)) fail: a Point is read as double[], and ArrayValue allocates Object[][] for an array class, which cannot hold a double[]. Use an Object array for primitive array values, as before. --- .../internal/BinaryStreamReader.java | 8 ++- ...seBinaryFormatReaderNullableArrayTest.java | 60 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) 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 f8390a3170..6220b3c30a 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 @@ -812,7 +812,13 @@ public ArrayValue readArrayItem(ClickHouseColumn itemTypeColumn, int len) throws itemClass = itemClass == null ? value.getClass() : commonSuperclass(itemClass, value.getClass()); } } - array = new ArrayValue(itemClass == null ? resolveArrayItemClass(itemTypeColumn) : itemClass, len); + if (itemClass == null) { + itemClass = resolveArrayItemClass(itemTypeColumn); + } else if (itemClass.isArray() && itemClass.getComponentType().isPrimitive()) { + // ArrayValue allocates Object[][] for an array class, which cannot hold Point's double[] + itemClass = Object.class; + } + array = new ArrayValue(itemClass, len); for (int i = 0; i < len; i++) { array.set(i, values[i]); } diff --git a/client-v2/src/test/java/com/clickhouse/client/api/data_formats/ClickHouseBinaryFormatReaderNullableArrayTest.java b/client-v2/src/test/java/com/clickhouse/client/api/data_formats/ClickHouseBinaryFormatReaderNullableArrayTest.java index 4121925a68..7a709d87e9 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/data_formats/ClickHouseBinaryFormatReaderNullableArrayTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/data_formats/ClickHouseBinaryFormatReaderNullableArrayTest.java @@ -443,4 +443,64 @@ public void testNullableArraysOfTypesReadAsOtherClasses() throws Exception { List allNullList = reader.getList("all_null_uint64_arr"); Assert.assertEquals(allNullList, Arrays.asList(null, null)); } + + @Test + public void testNullableArraysOfArrayBackedTypes() throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + String[] names = new String[]{"point_arr", "ring_arr", "tuple_arr"}; + String[] types = new String[]{"Array(Nullable(Point))", "Array(Nullable(Ring))", + "Array(Nullable(Tuple(Int32, Int32)))"}; + + BinaryStreamUtils.writeVarInt(out, names.length); + for (String name : names) { + BinaryStreamUtils.writeString(out, name); + } + for (String type : types) { + BinaryStreamUtils.writeString(out, type); + } + + // Array(Nullable(Point)): [(1.5, 2.5), NULL] + BinaryStreamUtils.writeVarInt(out, 2); + BinaryStreamUtils.writeNonNull(out); + BinaryStreamUtils.writeFloat64(out, 1.5); + BinaryStreamUtils.writeFloat64(out, 2.5); + BinaryStreamUtils.writeNull(out); + + // Array(Nullable(Ring)): [[(1.5, 2.5)], NULL] + BinaryStreamUtils.writeVarInt(out, 2); + BinaryStreamUtils.writeNonNull(out); + BinaryStreamUtils.writeVarInt(out, 1); + BinaryStreamUtils.writeFloat64(out, 1.5); + BinaryStreamUtils.writeFloat64(out, 2.5); + BinaryStreamUtils.writeNull(out); + + // Array(Nullable(Tuple(Int32, Int32))): [(1, 2), NULL] + BinaryStreamUtils.writeVarInt(out, 2); + BinaryStreamUtils.writeNonNull(out); + BinaryStreamUtils.writeInt32(out, 1); + BinaryStreamUtils.writeInt32(out, 2); + BinaryStreamUtils.writeNull(out); + + InputStream in = new ByteArrayInputStream(out.toByteArray()); + QuerySettings querySettings = new QuerySettings().setUseTimeZone(TimeZone.getTimeZone("UTC").toZoneId().getId()); + RowBinaryWithNamesAndTypesFormatReader reader = + new RowBinaryWithNamesAndTypesFormatReader(in, querySettings, new BinaryStreamReader.CachingByteBufferAllocator()); + + reader.next(); + + List points = reader.getList("point_arr"); + Assert.assertEquals(points.size(), 2); + Assert.assertEquals((double[]) points.get(0), new double[]{1.5, 2.5}); + Assert.assertNull(points.get(1)); + + List rings = reader.getList("ring_arr"); + Assert.assertEquals(rings.size(), 2); + Assert.assertEquals(((double[][]) rings.get(0))[0], new double[]{1.5, 2.5}); + Assert.assertNull(rings.get(1)); + + List tuples = reader.getList("tuple_arr"); + Assert.assertEquals(tuples.size(), 2); + Assert.assertEquals((Object[]) tuples.get(0), new Object[]{1, 2}); + Assert.assertNull(tuples.get(1)); + } }