Repository navigation
[client-v2] Fix reading Array(Nullable(T)) when values are read as another class #3293
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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); | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On
Suggested change
Please also add a test that reads A reply here is read on the next review: an explanation that holds up drops the finding. |
||||||
| values[i] = value; | ||||||
| if (value != null) { | ||||||
| itemClass = itemClass == null ? value.getClass() : commonSuperclass(itemClass, value.getClass()); | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Nit: Array component type changes and can differ per row
This is visible through the public Suggestion: keep the A reply here is read on the next review: an explanation that holds up drops the finding. |
||||||
| } | ||||||
| } | ||||||
| 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]); | ||||||
|
ccoltx marked this conversation as resolved.
Resolved
|
||||||
| } | ||||||
| } 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 | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Object> uint64List = reader.getList("uint64_arr"); | ||
| Assert.assertEquals(uint64List, Arrays.asList(BigInteger.ZERO, null, maxUInt64)); | ||
|
|
||
| List<Object> dateTimeList = reader.getList("datetime64_arr"); | ||
| Assert.assertEquals(dateTimeList, Arrays.asList(dateTime.atZone(ZoneId.of("UTC")), null)); | ||
|
|
||
| List<Object> ipv6List = reader.getList("ipv6_arr"); | ||
| Assert.assertEquals(ipv6List, Arrays.asList(InetAddress.getByName("1.2.3.4"), null, | ||
| InetAddress.getByName("2001:db8::1"))); | ||
|
|
||
| List<Object> allNullList = reader.getList("all_null_uint64_arr"); | ||
| Assert.assertEquals(allNullList, Arrays.asList(null, null)); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Nit: All-NULL assertion also passes on main On Also:
A reply here is read on the next review: an explanation that holds up drops the finding. |
||
| } | ||
|
|
||
| @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<Object> 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<Object> 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<Object> 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)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Nit: Extra Object[] buffer and copy for each nullable array
Each
Array(Nullable(T))value now allocates a temporaryObject[len]in addition to the typed array, and copies it.docs/ai-review.mdlists row decoding as performance-sensitive, and a large array doubles its transient memory.With the approach in the comment on line 812, the reader can allocate the declared type, set each value directly, and copy once only when a value does not fit. To keep that copy rare, correct the remaining
resolveArrayItemClassentries that disagree withreadValue(for exampleInterval*, declaredLongbut read asPeriod/Duration).A reply here is read on the next review: an explanation that holds up drops the finding.