Skip to content

[client-v2] Fix reading Array(Nullable(T)) when values are read as another class - #3293

Open
payallenka wants to merge 3 commits into
ClickHouse:mainfrom
payallenka:fix/3283-nullable-array-item-type
Open

payallenka wants to merge 3 commits into
ClickHouse:mainfrom
payallenka:fix/3283-nullable-array-item-type

Conversation

@payallenka

@payallenka payallenka commented Nov 4, 2026 •

Copy link
Copy Markdown

Summary

Reading an Array(Nullable(T)) column fails with IllegalArgumentException: array element type mismatch when readValue returns a different class than the one resolveArrayItemClass predicts for T. The reported cases:

Element type Array allocated as readValue returns
UInt64 (#3283) UnsignedLong[] BigInteger
DateTime, DateTime64 (#3287) LocalDateTime[] ZonedDateTime
IPv6 holding an IPv4-mapped address Inet6Address[] Inet4Address

Other types disagree with the table too (Tuple → Object[], Interval* → Duration/Period, String with binary strings → StringValue). So instead of correcting each entry, the nullable branch of readArrayItem now reads the elements first and allocates the array with the most specific common class of the non-null values. resolveArrayItemClass is used only when every element is NULL. There I corrected UInt64 (BigInteger), DateTime/DateTime32/DateTime64 (ZonedDateTime) and Tuple (Object[]), so an all-NULL array gets the same component type as one with values. For element types that already worked, the result is unchanged.

Closes #3283
Closes #3287

Checklist


Note

Medium Risk
Changes core binary deserialization for all nullable array columns; behavior is broader (runtime typing) but scoped to client-v2 read path with new regression tests.

Overview
Fixes Array(Nullable(T)) deserialization in BinaryStreamReader when readValue returns a different Java type than resolveArrayItemClass assumed (e.g. UInt64 → BigInteger, DateTime64 → ZonedDateTime, IPv6 with IPv4-mapped addresses → Inet4Address/Inet6Address).

For nullable elements, the reader buffers values first, then picks the component type from the common superclass of non-null elements, falling back to resolveArrayItemClass when every element is NULL. Primitive array-valued types (e.g. Point as double[]) use an Object-typed array so ArrayValue does not mis-allocate. resolveArrayItemClass is updated for all-null nullable UInt64, DateTime variants, and Tuple.

Adds nullable-array tests for those cases plus Point, Ring, and Tuple, and a changelog entry for issues #3283 and #3287.

Reviewed by Cursor Bugbot for commit 4bacdc3. Bugbot is set up for automated code reviews on this repo. Configure here.


Update (Payal Lenka, 2026-11-06): the first commit broke Array(Nullable(Point)), as Cursor Bugbot reported: a Point is read as double[], which ArrayValue cannot store in the Object[][] it allocates for array classes. 4bacdc3 uses an Object array for primitive array values, and testNullableArraysOfArrayBackedTypes covers Point, Ring and Tuple. It fails without that commit.

This also corrects the sentence above that the result is unchanged for types that already worked. The values are unchanged, but the array's component type can now be more specific where it was declared as Object (for example HashMap[] instead of Object[] for Array(Nullable(JSON))).

mvn -pl client-v2,jdbc-v2 install passes with -Duser.timezone=UTC: client-v2 826 + 1201, jdbc-v2 1975 + 799 (21 skipped).

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 ClickHouse#3283
Fixes ClickHouse#3287
@payallenka
payallenka force-pushed the fix/3283-nullable-array-item-type branch from 9935557 to 2bdba37 Compare November 4, 2026 19:59

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 2bdba37. Configure here.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

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.
@payallenka

Copy link
Copy Markdown
Author

CLA assistant check Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.You have signed the CLA already but the status is still pending? Let us recheck it.

Already Agreed

@ccoltx

ccoltx commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Hi @payallenka , it looks like you haven't signed the CLA yet. Clicking on recheck doesn't change the status. Please try again.

Once you've signed, it would look like this ClickHouse/clickhouse-go#2112 (comment)

@Flipmonster Flipmonster reopened this Nov 12, 2026
Object[] values = new Object[len];
Class<?> itemClass = null;
for (int i = 0; i < len; i++) {
Object value = readValue(itemTypeColumn);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Major: Nullable(String) array elements become StringValue

readValue(itemTypeColumn) applies the top-level binary_string_support flag, so with the flag on, each Nullable(String) / Nullable(FixedString) element is a StringValue. The non-nullable branch uses readNestedValue, and the documented rule is that strings nested in containers stay String (constructor Javadoc, testJsonStringPathsStayStringWithBinaryStringSupport).

On main this combination throws (StringValue does not fit in String[]). With this change it returns List<StringValue> from getList for Array(Nullable(String)), while Array(String) returns List<String>. After a release, callers can depend on this, and a later fix becomes a behavior change.

readNestedValue still reads the null marker, so this one-line change keeps the branch correct:

Suggested change
Object value = readValue(itemTypeColumn);
Object value = readNestedValue(itemTypeColumn);

Please also add a test that reads Array(Nullable(String)) with binary_string_support set and expects String elements.

A reply here is read on the next review: an explanation that holds up drops the finding.

Object value = readValue(itemTypeColumn);
values[i] = value;
if (value != null) {
itemClass = itemClass == null ? value.getClass() : commonSuperclass(itemClass, value.getClass());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Nit: Array component type changes and can differ per row

value.getClass() gives a component type that can differ from main and from row to row:

  • Array(Nullable(IPv6)): Inet4Address[], Inet6Address[] or InetAddress[], depending on the values in the row.
  • Types that already work: Ring / LineString / MultiPoint go from Object[] to Object[][], and JSON from Object[] to HashMap[]. An empty JSON value is Collections.emptyMap(), so this type can also differ per row.

This is visible through the public ArrayValue.getArray() and readValue(column, X[].class). getList, getObjectArray and JDBC getArray are not affected.

Suggestion: keep the resolveArrayItemClass corrections, start from resolveArrayItemClass(itemTypeColumn), and widen only when a value is not an instance of that class. Then every input that works on main keeps its component type, and only inputs that fail on main change. commonSuperclass must then accept an interface as the start class (its getSuperclass() is null), for example by returning Object.

A reply here is read on the next review: an explanation that holds up drops the finding.

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];

Copy link
Copy Markdown
Collaborator

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 temporary Object[len] in addition to the typed array, and copies it. docs/ai-review.md lists 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 resolveArrayItemClass entries that disagree with readValue (for example Interval*, declared Long but read as Period / Duration).

A reply here is read on the next review: an explanation that holds up drops the finding.

InetAddress.getByName("2001:db8::1")));

List<Object> allNullList = reader.getList("all_null_uint64_arr");
Assert.assertEquals(allNullList, Arrays.asList(null, null));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Nit: All-NULL assertion also passes on main

On main, an all-NULL Array(Nullable(UInt64)) is an UnsignedLong[] that holds nulls, so getList also returns [null, null] there. No test fails without the new UInt64 / DateTime* / Tuple entries in resolveArrayItemClass. Assert the component type, for example ((BinaryStreamReader.ArrayValue) reader.readValue("all_null_uint64_arr")).getArray().getClass(), or remove the entries.

Also:

A reply here is read on the next review: an explanation that holds up drops the finding.

@polyglotAI-bot

Copy link
Copy Markdown
Collaborator

Findings

High

  • None.

Medium

  • With binary_string_support on, Array(Nullable(String)) gives StringValue elements (BinaryStreamReader.java:809). The nullable branch reads each element with readValue(itemTypeColumn), which applies the top-level binary-string flag. The non-nullable branch uses readNestedValue. The documented rule is that strings in containers stay String (the BinaryStreamReader constructor Javadoc and testJsonStringPathsStayStringWithBinaryStringSupport). On main this case throws. With this PR, getList returns List<StringValue> for Array(Nullable(String)) but List<String> for Array(String). Nullable(FixedString) is the same. Fix: readNestedValue(itemTypeColumn) (it still reads the null marker), plus a test with the flag on.

Low

  • The array component type changes for some types that already work, and for IPv6 it depends on the data (:812). Visible through the public ArrayValue.getArray() and readValue(column, X[].class). getList, getObjectArray and JDBC getArray are not affected.
  • Each nullable array is copied once more (:806): a temporary Object[len] is allocated in addition to the typed array.
  • The all-NULL assertion does not test the resolveArrayItemClass changes (test :444): it also passes on main.
  • Out of scope, for a follow-up issue: non-nullable Array(IPv6) with an IPv4-mapped value and a normal IPv6 value still fails with array element type mismatch, on main and on this head. The non-nullable branch types the array by its first value. The changelog names IPv6, so users can expect this case to work too.

The fix for the earlier Point finding is correct: Point gets an Object array, and Ring works because double[][] fits in Object[][]. commonSuperclass always stops at Object, because each start class is a concrete value class.

Summary

  • What changed: the nullable branch of readArrayItem reads all elements first, then allocates the array with the common superclass of the non-null values. resolveArrayItemClass now returns BigInteger, ZonedDateTime and Object[] for nullable UInt64, DateTime* and Tuple.
  • Compatibility risk: low. No public signature changes. The runtime component type of ArrayValue.getArray() changes as described above. If that change stays, state it in the changelog.
  • Tests run: ClickHouseBinaryFormatReaderNullableArrayTest passes on f3ac350 (unit tests only). Local probe tests against this head and against main confirm the Medium finding and the IPv6 behavior. Integration tests were not run. The fork CI workflows on this head wait for maintainer approval, so only Bugbot has run.
  • Docs: no docs/features.md change is needed. The changelog fragment is present.

Verdict

need changes

Review by Polyglot AI (Claude Opus 5.5) of f3ac350, run by hand by the operator. Requested by @ccoltx. Replies on the inline threads are read on the next review. A maintainer can ask for a new review by mentioning @polyglotAI-bot or requesting a review from it.

@polyglotAI-bot polyglotAI-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested in the review of f3ac350: see the summary comment and the inline threads.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

5 participants