From 682746cfae81451a64e170be712cc6f4aba4baa3 Mon Sep 17 00:00:00 2001 From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:34:36 +0000 Subject: [PATCH] Fix client-v2 Native reader misreading multi-row Array columns NativeFormatReader.readBlock() read an Array column's cumulative row offsets but then used the first row's offset as the element count for every row, truncating later rows and desyncing the columns that follow the array in the same block. Compute each row's length from the delta between consecutive offsets instead. Also make BinaryStreamReader.readArrayItem handle len == 0 (empty array rows) without reading a phantom element and indexing a zero-length array, mirroring the existing len == 0 guard in readArray. Fixes: https://github.com/ClickHouse/clickhouse-java/issues/2955 --- CHANGELOG.md | 7 +++ .../api/data_formats/NativeFormatReader.java | 12 +++-- .../internal/BinaryStreamReader.java | 5 ++ .../clickhouse/client/query/QueryTests.java | 46 +++++++++++++++++++ 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6e55a893..ae59a6346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,13 @@ ### Bug Fixes +- **[client-v2]** Fixed the `Native` format reader (`NativeFormatReader`) misreading `Array` columns in multi-row + results whose rows have different lengths. Native encodes an array column as cumulative row offsets followed by the + flattened elements, but the reader used the first row's offset as the element count for every row — truncating later + rows and desyncing the columns that follow the array in the same block. Each row's length is now derived from the + difference between consecutive offsets, and empty array rows (`len == 0`) no longer read a phantom element. Results + with uniform array lengths were unaffected. (https://github.com/ClickHouse/clickhouse-java/issues/2955) + - **[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 `Float32`/`Float64` columns throwing `ClassCastException` when a value of a diff --git a/client-v2/src/main/java/com/clickhouse/client/api/data_formats/NativeFormatReader.java b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/NativeFormatReader.java index ddcf0049b..ceeeab15d 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/data_formats/NativeFormatReader.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/NativeFormatReader.java @@ -93,12 +93,18 @@ private boolean readBlock() throws IOException { List values = new ArrayList<>(nRows); if (column.isArray()) { - int[] sizes = new int[nRows]; + // Native encodes an Array column as nRows cumulative offsets followed by the + // flattened elements; each row's element count is the delta between consecutive + // offsets, not the first offset. + long[] offsets = new long[nRows]; for (int j = 0; j < nRows; j++) { - sizes[j] = Math.toIntExact(binaryStreamReader.readLongLE()); + offsets[j] = binaryStreamReader.readLongLE(); } + long prevOffset = 0; for (int j = 0; j < nRows; j++) { - values.add(binaryStreamReader.readArrayItem(column.getNestedColumns().get(0), sizes[0])); + int len = Math.toIntExact(offsets[j] - prevOffset); + values.add(binaryStreamReader.readArrayItem(column.getNestedColumns().get(0), len)); + prevOffset = offsets[j]; } } else { for (int j = 0; j < nRows; j++) { 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 95e02dd4f..0837f6a34 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 @@ -655,6 +655,11 @@ public ArrayValue readArray(ClickHouseColumn column) throws IOException { } public ArrayValue readArrayItem(ClickHouseColumn itemTypeColumn, int len) throws IOException { + if (len == 0) { + // Nothing to read for an empty array; typing it via resolveArrayItemClass avoids the + // primitive branch below reading a phantom element and indexing a zero-length array. + return new ArrayValue(resolveArrayItemClass(itemTypeColumn), 0); + } ArrayValue array; if (itemTypeColumn.isNullable()) { Class itemClass = resolveArrayItemClass(itemTypeColumn); diff --git a/client-v2/src/test/java/com/clickhouse/client/query/QueryTests.java b/client-v2/src/test/java/com/clickhouse/client/query/QueryTests.java index e8230716c..d90c9b4d0 100644 --- a/client-v2/src/test/java/com/clickhouse/client/query/QueryTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/query/QueryTests.java @@ -482,6 +482,52 @@ public void testReadingArrayInNative() throws Exception { } } + @DataProvider(name = "multiRowArrayCases") + Object[][] getMultiRowArrayCases() { + String nonUniform = "SELECT id, arr, tag FROM values(" + + "'id UInt32, arr Array(Int32), tag Int32', " + + "(1, [10], 100), (2, [20, 21], 200), (3, [], 300), (4, [30, 31, 32], 400), (5, [40], 500)" + + ") ORDER BY id"; + List nonUniformRows = Arrays.asList( + new Object[]{1L, Arrays.asList(10), 100}, + new Object[]{2L, Arrays.asList(20, 21), 200}, + new Object[]{3L, Collections.emptyList(), 300}, + new Object[]{4L, Arrays.asList(30, 31, 32), 400}, + new Object[]{5L, Arrays.asList(40), 500}); + + String uniform = "SELECT id, arr, tag FROM values(" + + "'id UInt32, arr Array(Int32), tag Int32', " + + "(1, [10, 11], 100), (2, [20, 21], 200), (3, [30, 31], 300)" + + ") ORDER BY id"; + List uniformRows = Arrays.asList( + new Object[]{1L, Arrays.asList(10, 11), 100}, + new Object[]{2L, Arrays.asList(20, 21), 200}, + new Object[]{3L, Arrays.asList(30, 31), 300}); + + return new Object[][]{ + {ClickHouseFormat.Native, nonUniform, nonUniformRows}, + {ClickHouseFormat.Native, uniform, uniformRows}, + {ClickHouseFormat.RowBinaryWithNamesAndTypes, nonUniform, nonUniformRows}, + }; + } + + @Test(groups = {"integration"}, dataProvider = "multiRowArrayCases") + public void testReadingMultiRowArrays(ClickHouseFormat format, String sql, List expectedRows) + throws Exception { + QuerySettings settings = new QuerySettings().setFormat(format); + try (QueryResponse response = client.query(sql, settings).get()) { + ClickHouseBinaryFormatReader reader = client.newBinaryFormatReader(response); + for (Object[] expected : expectedRows) { + Map record = reader.next(); + Assert.assertNotNull(record, "Expected a row for id " + expected[0]); + Assert.assertEquals(record.get("id"), expected[0]); + Assert.assertEquals(((BinaryStreamReader.ArrayValue) record.get("arr")).asList(), expected[1]); + Assert.assertEquals(record.get("tag"), expected[2]); + } + Assert.assertNull(reader.next()); + } + } + @Test(groups = {"integration"}) public void testBinaryStreamReader() throws Exception { final String table = "dynamic_schema_test_table";