diff --git a/CHANGELOG.md b/CHANGELOG.md index ebd7bba1f..d303d4ba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### Bug Fixes +- **[client-v2]** Fixed `RowBinaryFormatWriter` throwing `UnsupportedOperationException: Unsupported data type: Nested` when inserting into a table with an un-flattened `Nested(...)` column (created with `flatten_nested = 0`). The `RowBinary` writer now serializes a `Nested(f1 T1, ..., fN TN)` column the same way it is read — identically to `Array(Tuple(T1, ..., TN))`. (https://github.com/ClickHouse/clickhouse-java/issues/2477) + +- **[jdbc-v2]** Fixed `java.sql.Array#getResultSet()` on an un-flattened `Nested(...)` column throwing `Value of class [Ljava.lang.Object; cannot be converted to class java.lang.Byte`. The array `ResultSet` now exposes each nested row as its `Tuple(f1 T1, ..., fN TN)` element type instead of the first nested field, so `getObject` returns the whole tuple. Insert (via `Connection#createArrayOf` + `setArray` or `setObject`) and read (via `getArray`/`getObject`) of `Nested(...)` columns are covered by tests. (https://github.com/ClickHouse/clickhouse-java/issues/2477) + - **[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/internal/SerializerUtils.java b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/SerializerUtils.java index 6ff4b4b79..d3d7a43bb 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/SerializerUtils.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/SerializerUtils.java @@ -72,6 +72,9 @@ public static void serializeData(OutputStream stream, Object value, ClickHouseCo case Map: serializeMapData(stream, value, column); break; + case Nested: + serializeNestedTypeData(stream, value, column); + break; case AggregateFunction: serializeAggregateFunction(stream, value, column); break; @@ -130,6 +133,25 @@ private static void serializeNestedData(OutputStream stream, Object value, Click serializeData(stream, value, column); } + /** + * Serializes a {@code Nested} column. In {@code RowBinary} a {@code Nested(f1 T1, ..., fN TN)} + * column has the same layout as {@code Array(Tuple(T1, ..., TN))}: a var-uint element count + * followed by that many tuples, each carrying the N field values in declaration order. The + * value is therefore a list (or array) of tuples, matching what + * {@link BinaryStreamReader#readNested(ClickHouseColumn)} produces on read. + */ + private static void serializeNestedTypeData(OutputStream stream, Object value, ClickHouseColumn column) throws IOException { + if (value == null) { + writeVarInt(stream, 0); + return; + } + List tuples = convertArrayValueToList(value); + writeVarInt(stream, tuples.size()); + for (Object tuple : tuples) { + serializeTupleData(stream, tuple, column); + } + } + private static final Map, ClickHouseColumn> PREDEFINED_TYPE_COLUMNS = getPredefinedTypeColumnsMap(); private static Map, ClickHouseColumn> getPredefinedTypeColumnsMap() { diff --git a/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/SerializerUtilsTest.java b/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/SerializerUtilsTest.java index 265cd1cfb..46f269b58 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/SerializerUtilsTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/SerializerUtilsTest.java @@ -217,6 +217,44 @@ private Object[][] nestedNullableData() throws Exception { }; } + @Test(dataProvider = "rowBinaryTypeData") + public void testRowBinaryTypeRoundTrip(String typeName, Object value) throws Exception { + ClickHouseColumn column = ClickHouseColumn.of("v", typeName); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SerializerUtils.serializeData(out, value, column); + + Object actual = newReader(out.toByteArray()).readValue(column); + Assert.assertEquals(normalize(actual), normalize(value)); + } + + @DataProvider(name = "rowBinaryTypeData") + private Object[][] rowBinaryTypeData() { + return new Object[][] { + // A Nested(...) column has the same RowBinary layout as Array(Tuple(...)): a + // var-uint row count followed by that many tuples. Two rows detect a wrong count + // or a dropped field byte, which would shift every following tuple. + {"Nested(a Int32, b String)", + Arrays.asList(Arrays.asList(1, "x"), Arrays.asList(2, "y"))}, + + // Same value as arrays instead of Lists: an Object[][] of Object[] rows (a + // "matrix" array). convertArrayValueToList takes the array branch and each row is + // serialized by serializeTupleData's array branch, producing the same bytes as the + // List-shaped case above. + {"Nested(a Int32, b String)", + new Object[][] {{1, "x"}, {2, "y"}}}, + + // A Nullable field in the MIDDLE of the nested tuple, with a trailing fixed-width + // Float64: a dropped null-marker byte misaligns the Float64 and is caught. The + // second row exercises the null branch of that field. + {"Nested(a Int32, b Nullable(String), c Float64)", + Arrays.asList(Arrays.asList(7, "opt", 9.5d), Arrays.asList(7, null, 8.5d))}, + + // An empty Nested serializes as a zero-length array. + {"Nested(a Int32, b String)", Arrays.asList()}, + }; + } + // Normalizes Tuple (Object[]) and Array (ArrayValue / List) results to nested Lists so // round-tripped values compare structurally regardless of the container representation the // reader returns. diff --git a/client-v2/src/test/java/com/clickhouse/client/datatypes/RowBinaryFormatWriterTest.java b/client-v2/src/test/java/com/clickhouse/client/datatypes/RowBinaryFormatWriterTest.java index 1a0ee2287..bf7ff3067 100644 --- a/client-v2/src/test/java/com/clickhouse/client/datatypes/RowBinaryFormatWriterTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/datatypes/RowBinaryFormatWriterTest.java @@ -534,6 +534,27 @@ public void writeNestedTests() throws Exception { writeTest(tableName, tableCreate, rows); } + @Test (groups = { "integration" }) + public void writeNestedTypeTests() throws Exception { + String tableName = "rowBinaryFormatWriterTest_writeNestedTypeTests_" + UUID.randomUUID().toString().replace('-', '_'); + // flatten_nested = 0 keeps the column typed as Nested(...) in the schema instead of + // expanding it into parallel Array(...) sub-columns; the un-flattened Nested type is the + // one that has to be serialized as Array(Tuple(...)). + String tableCreate = "CREATE TABLE \"" + tableName + "\" " + + " (id Int32, " + + " n Nested(a UInt32, b Nullable(String)) " + + " ) Engine = MergeTree ORDER BY id SETTINGS flatten_nested = 0"; + + List nested = Arrays.asList(Arrays.asList(10L, "x"), Arrays.asList(20L, null)); + Field[][] rows = new Field[][] {{ + new Field("id", 1), //Row ID + new Field("n", nested).set(nested) //Nested + } + }; + + writeTest(tableName, tableCreate, rows); + } + @Test (groups = { "integration" }) public void writeNullableTests() throws Exception { String tableName = "rowBinaryFormatWriterTest_writeNullableTests_" + UUID.randomUUID().toString().replace('-', '_'); diff --git a/docs/features.md b/docs/features.md index 4237a80cd..1a753e9ee 100644 --- a/docs/features.md +++ b/docs/features.md @@ -75,6 +75,7 @@ Compatibility-sensitive traits: - Type mapping and conversions: Maps ClickHouse types to JDBC types and Java classes, including date/time handling and `java.time` support. - 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`, `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. - 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. - Client info propagation: Supports JDBC client info such as `ApplicationName` and forwards it to the underlying client name. - Wrapper support: Implements standard JDBC `Wrapper` and `unwrap` behavior on major JDBC objects. diff --git a/jdbc-v2/src/main/java/com/clickhouse/jdbc/types/ArrayResultSet.java b/jdbc-v2/src/main/java/com/clickhouse/jdbc/types/ArrayResultSet.java index 9965978dd..97da74a06 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/types/ArrayResultSet.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/types/ArrayResultSet.java @@ -64,8 +64,7 @@ public ArrayResultSet(Object array, ClickHouseColumn column) { this.column = column; this.columnCount = 2; // INDEX, VALUE - List nestedColumns = column.getNestedColumns(); - ClickHouseColumn valueColumn = column.getArrayNestedLevel() == 1 ? column.getArrayBaseColumn() : nestedColumns.get(0); + ClickHouseColumn valueColumn = elementColumn(column); this.metadata = new ResultSetMetaDataImpl(Arrays.asList(INDEX_COLUMN, ClickHouseColumn.parse(VALUE_COLUMN + " " + valueColumn.getOriginalTypeName()).get(0)) , "", "", "", JdbcUtils.DATA_TYPE_CLASS_MAP, java.util.Collections.emptyMap()); @@ -74,6 +73,29 @@ public ArrayResultSet(Object array, ClickHouseColumn column) { indexConverterMap = defaultValueConverters.getConvertersForType(Integer.class); } + /** + * Resolves the type of a single element of {@code column} (an array-like column). A + * {@code Nested(f1 T1, ..., fN TN)} column is read as an array whose elements are + * {@code Tuple(f1 T1, ..., fN TN)}, so its element is that tuple rather than the first nested + * field; every other array-like column keeps its existing base/nested-column resolution. + */ + private static ClickHouseColumn elementColumn(ClickHouseColumn column) { + List nestedColumns = column.getNestedColumns(); + if (column.isNested()) { + StringBuilder tupleType = new StringBuilder(ClickHouseDataType.Tuple.name()).append('('); + for (int i = 0; i < nestedColumns.size(); i++) { + ClickHouseColumn field = nestedColumns.get(i); + if (i > 0) { + tupleType.append(", "); + } + tupleType.append(field.getColumnName()).append(' ').append(field.getOriginalTypeName()); + } + tupleType.append(')'); + return ClickHouseColumn.of(VALUE_COLUMN, tupleType.toString()); + } + return column.getArrayNestedLevel() == 1 ? column.getArrayBaseColumn() : nestedColumns.get(0); + } + @Override public boolean next() throws SQLException { if (pos + 1 >= length || length == 0) { @@ -139,9 +161,7 @@ private Object getValueAsObject(int columnIndex, Class type, Object defaultVa } if (type == Array.class) { - ClickHouseColumn nestedColumn = - column.getArrayNestedLevel() == 1 ? column.getArrayBaseColumn() : column.getNestedColumns().get(0); - return new com.clickhouse.jdbc.types.Array(nestedColumn, JdbcUtils.arrayToObjectArray(value)); + return new com.clickhouse.jdbc.types.Array(elementColumn(column), JdbcUtils.arrayToObjectArray(value)); } else { Map, Function> valueConverterMap = initValueConverterMapIfNeeded(value); return convertValue(value, type, valueConverterMap); 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 ed0b00cb9..bcab0f373 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/JdbcDataTypeTests.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/JdbcDataTypeTests.java @@ -1516,6 +1516,92 @@ public void testArrayTypes() throws SQLException { } } + @Test(groups = { "integration" }) + public void testNestedType() throws SQLException { + runQuery("DROP TABLE IF EXISTS test_nested_jdbc"); + runQuery("CREATE TABLE test_nested_jdbc (order Int8, " + + "n Nested(a Int8, b Nullable(String)), " + + "tail Int32" + + ") ENGINE = MergeTree ORDER BY (order) SETTINGS flatten_nested = 0"); + + // A null in the Nullable field exercises null propagation through every read path. + Tuple[] nested = new Tuple[] { + new Tuple((byte) 1, "x"), + new Tuple((byte) 2, null), + }; + Tuple[] empty = new Tuple[0]; + + // Row 1: insert the Nested column through java.sql.Array (Connection#createArrayOf + setArray). + try (Connection conn = getJdbcConnection(); + PreparedStatement stmt = conn.prepareStatement("INSERT INTO test_nested_jdbc VALUES (1, ?, 100)")) { + stmt.setArray(1, conn.createArrayOf("Tuple(Int8, Nullable(String))", nested)); + stmt.executeUpdate(); + } + + // Row 2: insert the same Nested value as a plain Java array of tuples (setObject). + try (Connection conn = getJdbcConnection(); + PreparedStatement stmt = conn.prepareStatement("INSERT INTO test_nested_jdbc VALUES (2, ?, 200)")) { + stmt.setObject(1, nested); + stmt.executeUpdate(); + } + + // Row 3: an empty Nested value, so the read paths cover a zero-row array. + try (Connection conn = getJdbcConnection(); + PreparedStatement stmt = conn.prepareStatement("INSERT INTO test_nested_jdbc VALUES (3, ?, 300)")) { + stmt.setObject(1, empty); + stmt.executeUpdate(); + } + + try (Connection conn = getJdbcConnection(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT order, n, tail FROM test_nested_jdbc ORDER BY order")) { + // Row 1 read through getArray + assertTrue(rs.next()); + assertEquals(rs.getByte("order"), (byte) 1); + assertNestedEquals(rs.getArray("n"), nested); + assertEquals(rs.getInt("tail"), 100); + + // Row 2 read through getObject + assertTrue(rs.next()); + assertEquals(rs.getByte("order"), (byte) 2); + assertNestedEquals((Array) rs.getObject("n"), nested); + assertEquals(rs.getInt("tail"), 200); + + // Row 3: empty Nested -> empty array and an empty getResultSet(). + assertTrue(rs.next()); + assertEquals(rs.getByte("order"), (byte) 3); + assertNestedEquals(rs.getArray("n"), empty); + assertEquals(rs.getInt("tail"), 300); + + assertFalse(rs.next()); + } + } + + private static void assertNestedEquals(Array nestedColumn, Tuple[] expected) throws SQLException { + // getArray() yields one element per nested row, each an Object[] of the tuple field values. + Object[] rows = (Object[]) nestedColumn.getArray(); + assertEquals(rows.length, expected.length); + for (int i = 0; i < expected.length; i++) { + assertTupleEquals((Object[]) rows[i], expected[i]); + } + + // getResultSet() exposes the same rows as (INDEX, VALUE) pairs. + try (ResultSet ars = nestedColumn.getResultSet()) { + int seen = 0; + while (ars.next()) { + assertEquals(ars.getInt(1), seen + 1); + assertTupleEquals((Object[]) ars.getObject(2), expected[seen]); + seen++; + } + assertEquals(seen, expected.length); + } + } + + private static void assertTupleEquals(Object[] actual, Tuple expected) { + assertEquals(String.valueOf(actual[0]), String.valueOf(expected.getValue(0))); + assertEquals(actual[1], expected.getValue(1)); // Nullable(String): null stays null + } + @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 ()");