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 018b0aeb6..6220b3c30 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,26 @@ public ArrayValue readArrayItem(ClickHouseColumn itemTypeColumn, int len) throws } ArrayValue array; if (itemTypeColumn.isNullable()) { - Class itemClass = resolveArrayItemClass(itemTypeColumn); + // 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()); + } + } + 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, readValue(itemTypeColumn)); + array.set(i, values[i]); } } else if (itemTypeColumn.getDataType() == ClickHouseDataType.Variant || itemTypeColumn.getDataType() == ClickHouseDataType.Dynamic @@ -868,8 +884,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 +928,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 497548d19..7a709d87e 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,130 @@ 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)); + } + + @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)); + } } diff --git a/history/latest/3283.md b/history/latest/3283.md new file mode 100644 index 000000000..21709e2b3 --- /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)