From d4c648b25f52a0e394d5e1b269dceecc536fea70 Mon Sep 17 00:00:00 2001 From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:10:47 +0000 Subject: [PATCH] Fix jdbc-v2: read Array(Nested(...)) columns ResultSet#getObject/getArray on an Array(Nested(...)) column threw IllegalStateException "There is a child array at depth 0 where it is not expected". A Nested(...) value is an array of tuples, but JdbcUtils.convert passed only the Array levels of the column to convertList, so the list of tuples of each Nested value looked like an unexpected extra dimension. Count the Nested level as one more array dimension and keep its tuples as is, the same as Array(Array(Tuple)). Fixes: https://github.com/ClickHouse/clickhouse-java/issues/3178 Co-Authored-By: Claude Opus 5.5 --- docs/features.md | 2 +- history/latest/3178.md | 7 ++ .../clickhouse/jdbc/internal/JdbcUtils.java | 16 ++++- .../clickhouse/jdbc/JdbcDataTypeTests.java | 70 +++++++++++++++++++ 4 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 history/latest/3178.md diff --git a/docs/features.md b/docs/features.md index b8b2b24a8..a9c6e405a 100644 --- a/docs/features.md +++ b/docs/features.md @@ -94,7 +94,7 @@ Compatibility-sensitive traits: - QBit type mapping: For ClickHouse `25.10+`, JDBC exposes the experimental `QBit(element_type, dimension)` type as `ARRAY`, returning the vector as a `java.sql.Array` of the element type from `getObject()`/`getArray()`. Supported element types are `BFloat16` and `Float32` (both `java.lang.Float`) and `Float64` (`java.lang.Double`). The `allow_experimental_qbit_type` server setting is required to create a `QBit` column. - Custom result-set type map: `ResultSet#getObject(int|String, Map>)` accepts both ClickHouse type names and JDBC `SQLType` names as map keys. Only unwrapped type names are used — `Nullable(...)` and `LowCardinality(...)` wrappers are stripped before lookup, so a key like `"Int32"` matches both `Int32` and `Nullable(Int32)` columns, and keys like `"Nullable(Int32)"` are not recognized. Lookup order is `ClickHouseColumn#getDataType().name()` (e.g. `"Int32"`, `"String"`, `"DateTime"`) then `SQLType.getName()` (e.g. `"INTEGER"`, `"VARCHAR"`, `"TIMESTAMP"`); a missing entry leaves the value uncoerced (read as-is). The feature is supported for primitive ClickHouse types only — `Array`, `Tuple`, `Map`, `Nested`, and geometry types (`Point`, `Ring`, `LineString`, `MultiPoint`, `Polygon`, `MultiPolygon`, `MultiLineString`, `Geometry`) bypass the type map and are returned in their native form. - Arrays and tuples: Supports JDBC arrays plus ClickHouse tuple values through custom `Array` and `Struct` implementations. -- Nested columns: Un-flattened `Nested(f1 T1, ..., fN TN)` columns (tables created with `flatten_nested = 0`) are exposed as JDBC `ARRAY` whose element type is `Tuple(f1 T1, ..., fN TN)`. They can be inserted through `Connection#createArrayOf`/`setArray` or `setObject` (a Java array of tuples) and read back through `getArray`/`getObject`; `java.sql.Array#getResultSet()` iterates the nested rows as `(INDEX, VALUE)` pairs where each `VALUE` is the tuple. +- Nested columns: Un-flattened `Nested(f1 T1, ..., fN TN)` columns (tables created with `flatten_nested = 0`) are exposed as JDBC `ARRAY` whose element type is `Tuple(f1 T1, ..., fN TN)`. They can be inserted through `Connection#createArrayOf`/`setArray` or `setObject` (a Java array of tuples) and read back through `getArray`/`getObject`; `java.sql.Array#getResultSet()` iterates the nested rows as `(INDEX, VALUE)` pairs where each `VALUE` is the tuple. An `Array(Nested(...))` column is read as a JDBC `ARRAY` whose elements are the nested values, each an array of tuples (`Object[]` of `Object[]`); its `getResultSet()` returns each element as a `java.sql.Array` equal to a top-level `Nested(...)` value. - Geometry type mapping: For ClickHouse `25.11+`, where `Geometry` changed from a string alias to `Variant(Point, Ring, LineString, MultiLineString, Polygon, MultiPolygon)`, JDBC exposes `Geometry` as `ARRAY`, returns nested Java arrays from `getObject()`/`getArray()`, and accepts `Struct` or nested `Array` inputs for prepared-statement inserts depending on the geometry shape. For ClickHouse `26.8+` the variant also carries `MultiPoint`. - MultiPoint type mapping: For ClickHouse `26.8+`, JDBC exposes `MultiPoint` as `ARRAY` with type name `MultiPoint`, returns `double[][]` from `getObject()` and a `java.sql.Array` from `getArray()`, and accepts a nested `Array` (`createArrayOf("Array(Point)", ...)`) for prepared-statement inserts, the same as `Ring` and `LineString`. - Client info propagation: Supports JDBC client info such as `ApplicationName` and forwards it to the underlying client name. diff --git a/history/latest/3178.md b/history/latest/3178.md new file mode 100644 index 000000000..a3a6ef8fd --- /dev/null +++ b/history/latest/3178.md @@ -0,0 +1,7 @@ +- **[jdbc-v2]** Fixed reading an `Array(Nested(...))` column through `ResultSet#getObject`/`getArray`, which failed + with `IllegalStateException: There is a child array at depth 0 where it is not expected`. A `Nested(...)` value is + itself an array of tuples, but the conversion counted only the `Array` levels of the column. The `Nested` level is + now counted too: the column is returned as a `java.sql.Array` whose elements are the nested values (each an + `Object[]` of tuples), the same as an `Array(Array(Tuple(...)))` column, and `java.sql.Array#getResultSet()` returns + each element as a `java.sql.Array` equal to a top-level `Nested(...)` value. + (https://github.com/ClickHouse/clickhouse-java/issues/3178) diff --git a/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcUtils.java b/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcUtils.java index 97c323911..d49a69be6 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcUtils.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcUtils.java @@ -308,13 +308,14 @@ public static Object convert(Object value, Class type, ClickHouseColumn colum if (value instanceof List) { List listValue = (List) value; if (type != java.sql.Array.class) { - return convertList(listValue, type, column.getArrayNestedLevel()); + return convertList(listValue, type, getArrayDimensions(column)); } if (column != null && column.getArrayBaseColumn() != null) { - ClickHouseDataType baseType = column.getArrayBaseColumn().getDataType(); + ClickHouseColumn baseColumn = column.getArrayBaseColumn(); + ClickHouseDataType baseType = baseColumn.isNested() ? ClickHouseDataType.Tuple : baseColumn.getDataType(); Object[] convertedValues = convertList(listValue, convertToJavaClass(baseType), - column.getArrayNestedLevel()); + getArrayDimensions(column)); return new Array(column, convertedValues); } @@ -346,6 +347,15 @@ public static Object convert(Object value, Class type, ClickHouseColumn colum return convertObject(value, type, column); } + /** + * Returns the number of array dimensions of an array column value. A {@code Nested(...)} base column is an + * array of tuples, so it adds one more dimension to the {@code Array} levels of the column. + */ + private static int getArrayDimensions(ClickHouseColumn column) { + ClickHouseColumn baseColumn = column.getArrayBaseColumn(); + return column.getArrayNestedLevel() + (baseColumn != null && baseColumn.isNested() ? 1 : 0); + } + static Object convertObject(Object value, Class type, ClickHouseColumn column) throws SQLException { if (value == null || type == null) { return value; diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/JdbcDataTypeTests.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/JdbcDataTypeTests.java index f00057bf5..0b37a4bbd 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/JdbcDataTypeTests.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/JdbcDataTypeTests.java @@ -1898,6 +1898,76 @@ private static void assertTupleEquals(Object[] actual, Tuple expected) { assertEquals(actual[1], expected.getValue(1)); // Nullable(String): null stays null } + @Test(groups = { "integration" }) + public void testArrayOfNestedType() throws SQLException { + runQuery("DROP TABLE IF EXISTS test_array_of_nested_jdbc"); + runQuery("CREATE TABLE test_array_of_nested_jdbc (order Int8, " + + "an Array(Nested(a Int8, b Nullable(String))), " + + "aan Array(Array(Nested(a Int8, b Nullable(String)))), " + + "aat Array(Array(Tuple(a Int8, b Nullable(String)))), " + + "tail Int32" + + ") ENGINE = MergeTree ORDER BY (order)"); + runQuery("INSERT INTO test_array_of_nested_jdbc VALUES " + + "(1, [[(1, 'x'), (2, NULL)], [], [(3, 'y')]], [[[(1, 'x'), (2, NULL)]], [], [[], [(3, 'y')]]], " + + "[[(1, 'x'), (2, NULL)], [], [(3, 'y')]], 100), " + + "(2, [], [], [], 200)"); + + Tuple[][] an = new Tuple[][] { + { new Tuple((byte) 1, "x"), new Tuple((byte) 2, null) }, + {}, + { new Tuple((byte) 3, "y") }, + }; + Tuple[][][] aan = new Tuple[][][] { + { { new Tuple((byte) 1, "x"), new Tuple((byte) 2, null) } }, + {}, + { {}, { new Tuple((byte) 3, "y") } }, + }; + + try (Connection conn = getJdbcConnection(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT order, an, aan, aat, tail FROM test_array_of_nested_jdbc ORDER BY order")) { + assertTrue(rs.next()); + assertEquals(rs.getByte("order"), (byte) 1); + assertArrayOfNestedEquals(rs.getArray("an"), an); + assertArrayOfNestedEquals((Array) rs.getObject("an"), an); + Object[] aanValue = (Object[]) rs.getArray("aan").getArray(); + assertEquals(aanValue.length, aan.length); + try (ResultSet ars = rs.getArray("aan").getResultSet()) { + for (Tuple[][] expected : aan) { + assertTrue(ars.next()); + assertArrayOfNestedEquals((Array) ars.getObject(2), expected); + } + assertFalse(ars.next()); + } + assertArrayOfNestedEquals(rs.getArray("aat"), an); + assertTrue(Arrays.deepEquals((Object[]) rs.getArray("an").getArray(), (Object[]) rs.getArray("aat").getArray())); + assertTrue(Arrays.deepEquals(rs.getObject("an", Object[].class), rs.getObject("aat", Object[].class))); + assertEquals(rs.getInt("tail"), 100); + + assertTrue(rs.next()); + assertEquals(rs.getByte("order"), (byte) 2); + assertEquals(((Object[]) rs.getArray("an").getArray()).length, 0); + assertEquals(((Object[]) rs.getArray("aan").getArray()).length, 0); + assertEquals(((Object[]) rs.getArray("aat").getArray()).length, 0); + assertEquals(rs.getInt("tail"), 200); + + assertFalse(rs.next()); + } + } + + private static void assertArrayOfNestedEquals(Array array, Tuple[][] expected) throws SQLException { + Object[] elements = (Object[]) array.getArray(); + assertEquals(elements.length, expected.length); + try (ResultSet ars = array.getResultSet()) { + for (int i = 0; i < expected.length; i++) { + assertTrue(ars.next()); + assertEquals(((Object[]) elements[i]).length, expected[i].length); + assertNestedEquals((Array) ars.getObject(2), expected[i]); + } + assertFalse(ars.next()); + } + } + @Test(groups = { "integration" }) public void testStringsUsedAsBytes() throws Exception { runQuery("CREATE TABLE test_strings_as_bytes (order Int8, str String, fixed FixedString(10)) ENGINE = MergeTree ORDER BY ()");