From aa6ee95c9f0c3e53e02e14db7bd2b90d9cced058 Mon Sep 17 00:00:00 2001 From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:48:41 +0000 Subject: [PATCH 1/2] feat(client-v2, jdbc-v2): add MultiPoint data type support MultiPoint is a geo type added in ClickHouse 26.8 and is Array(Point) on the wire, the same representation as Ring and LineString, so it is read and written as double[][]. The server also adds MultiPoint to the Geometry variant. It appends the new variant after the existing six instead of ordering it by type name, so the Geometry helper column now pins that order explicitly rather than relying on the generic Variant name ordering. MultiPoint is deliberately left out of the Geometry write-side mappings: it shares double[][] with Ring and LineString, so a value written to a Geometry column keeps resolving to Ring exactly as before. Implements: https://github.com/ClickHouse/clickhouse-java/issues/3048 --- CHANGELOG.md | 13 ++++ .../com/clickhouse/data/ClickHouseColumn.java | 24 +++++- .../clickhouse/data/ClickHouseDataType.java | 2 + .../clickhouse/data/ClickHouseColumnTest.java | 39 ++++++++++ .../internal/BinaryStreamReader.java | 2 + .../internal/SerializerUtils.java | 2 + .../api/internal/DataTypeConverter.java | 5 +- .../internal/SerializerUtilsTest.java | 19 +++++ .../client/datatypes/DataTypeTests.java | 78 +++++++++++++++++++ docs/features.md | 12 +-- .../clickhouse/jdbc/internal/JdbcUtils.java | 2 + .../jdbc/metadata/DatabaseMetaDataImpl.java | 1 + .../jdbc/metadata/ResultSetMetaDataImpl.java | 1 + .../clickhouse/jdbc/JdbcDataTypeTests.java | 62 +++++++++++++++ .../jdbc/metadata/DatabaseMetaDataTest.java | 13 +++- 15 files changed, 265 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52c92a57a..16b5f017b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ ### New Features +- **[client-v2, jdbc-v2]** Added support for the `MultiPoint` geo data type (ClickHouse `26.8+`). Previously the type was + unknown to the client, so reading or writing a `MultiPoint` column failed with `Unknown data type: MultiPoint`, and a + `MultiPoint` value inside a `Geometry` column failed with an out-of-range variant discriminator. `MultiPoint` is + `Array(Point)` on the wire, exactly like `Ring` and `LineString`, so it is read and written as `double[][]` through + generic records, binary readers, POJO binding, and SQL parameter formatting, and is read from `Dynamic` columns. In the + JDBC driver (`jdbc-v2`) `MultiPoint` maps + to `java.sql.Types.ARRAY`, is returned as `double[][]` from `getObject` and as a `java.sql.Array` from `getArray`, and is + reported by `ResultSetMetaData` and `DatabaseMetaData`. ClickHouse `26.8` also adds `MultiPoint` to the `Geometry` + variant; the server appends it after the existing six variants instead of ordering it by type name, so the client now + keeps that order and decodes a `MultiPoint` held in a `Geometry` column. Because `MultiPoint` shares its Java + representation (`double[][]`) with `Ring` and `LineString`, it is not selectable through the shape-based `Geometry` + write path — a 2D value keeps resolving to `Ring` as before, and writing `MultiPoint` requires a concrete `MultiPoint` + column. (https://github.com/ClickHouse/clickhouse-java/issues/3048) - **[client-v2, jdbc-v2]** Added support for the `BFloat16` data type (ClickHouse `24.11+`). `BFloat16` columns are read as Java `float` values (widening is lossless) and written from `float`/`Float` values, including through generic records, POJO binding, `Nullable(BFloat16)`, and `BFloat16` values held in `Dynamic`/`Variant` columns. On write the client keeps the diff --git a/clickhouse-data/src/main/java/com/clickhouse/data/ClickHouseColumn.java b/clickhouse-data/src/main/java/com/clickhouse/data/ClickHouseColumn.java index a058b206c..b39cdc1a1 100644 --- a/clickhouse-data/src/main/java/com/clickhouse/data/ClickHouseColumn.java +++ b/clickhouse-data/src/main/java/com/clickhouse/data/ClickHouseColumn.java @@ -324,6 +324,9 @@ private static ClickHouseColumn update(ClickHouseColumn column) { case LineString: column.template = ClickHouseGeoRingValue.ofEmpty(); break; + case MultiPoint: + column.template = ClickHouseGeoRingValue.ofEmpty(); + break; case Polygon: column.template = ClickHouseGeoPolygonValue.ofEmpty(); break; @@ -363,8 +366,27 @@ private static ClickHouseColumn update(ClickHouseColumn column) { } private static ClickHouseColumn createGeometryVariantColumn() { - ClickHouseColumn column = ClickHouseColumn.of("v", + // The six geometry variants that exist since CH 25.11. Variant nested columns are ordered by + // type name, which reproduces the discriminators the server assigns to them. + ClickHouseColumn base = ClickHouseColumn.of("v", "Variant(Point, Ring, LineString, MultiLineString, Polygon, MultiPolygon)"); + + // CH 26.8 added MultiPoint to Geometry without renumbering the existing variants: the server + // appends it after MultiPolygon instead of inserting it in type-name order, so it is appended + // here as well rather than relying on the generic Variant ordering. + List nestedColumns = new ArrayList<>(base.nested); + nestedColumns.add(ClickHouseColumn.of("v." + ClickHouseDataType.MultiPoint.name(), + ClickHouseDataType.MultiPoint.name())); + + ClickHouseColumn column = new ClickHouseColumn(ClickHouseDataType.Variant, "v", + "Variant(Point, Ring, LineString, MultiLineString, Polygon, MultiPolygon, MultiPoint)", + false, false, null, nestedColumns); + + // MultiPoint shares its Java representation (double[][]) with Ring and LineString, so it is + // deliberately left out of both write-side mappings: a Java value written to a Geometry column + // keeps resolving to the same variant it resolved to before. MultiPoint is read-only through + // Geometry and has to be written through a concrete MultiPoint column. + column.classToVariantOrdNumMap = base.classToVariantOrdNumMap; Map map = new HashMap<>(); map.put(1, getVariantOrdNum(column.nested, ClickHouseDataType.Point)); map.put(2, getVariantOrdNum(column.nested, ClickHouseDataType.Ring)); diff --git a/clickhouse-data/src/main/java/com/clickhouse/data/ClickHouseDataType.java b/clickhouse-data/src/main/java/com/clickhouse/data/ClickHouseDataType.java index c0817401e..c4ce44360 100644 --- a/clickhouse-data/src/main/java/com/clickhouse/data/ClickHouseDataType.java +++ b/clickhouse-data/src/main/java/com/clickhouse/data/ClickHouseDataType.java @@ -106,6 +106,7 @@ public enum ClickHouseDataType implements SQLType { Ring(Object.class, false, true, true, 0, 0, 0, 0, 0, true), // same as Array(Point) LineString( Object.class, false, true, true, 0, 0, 0, 0, 0, true), // same as Array(Point) MultiLineString(Object.class, false, true, true, 0, 0, 0, 0, 0, true), // same as Array(Ring) + MultiPoint(Object.class, false, true, true, 0, 0, 0, 0, 0, true), // same as Array(Point) Geometry(Object.class, false, true, true, 0, 0, 0, 0, 0, true), // same as Variant(Point, ...) JSON(Object.class, false, false, false, 0, 0, 0, 0, 0, true, 0x30), @Deprecated // (since = "CH 25.11") @@ -216,6 +217,7 @@ static Map>> dataTypeClassMap() { map.put(Point, setOf(double[].class, ClickHouseGeoPointValue.class)); map.put(Ring, setOf(double[][].class, ClickHouseGeoRingValue.class)); map.put(LineString, setOf(double[][].class, ClickHouseGeoRingValue.class)); + map.put(MultiPoint, setOf(double[][].class, ClickHouseGeoRingValue.class)); map.put(Polygon, setOf(double[][][].class, ClickHouseGeoPolygonValue.class)); map.put(MultiLineString, setOf(double[][][].class, ClickHouseGeoPolygonValue.class)); map.put(MultiPolygon, setOf(double[][][][].class, ClickHouseGeoMultiPolygonValue.class)); diff --git a/clickhouse-data/src/test/java/com/clickhouse/data/ClickHouseColumnTest.java b/clickhouse-data/src/test/java/com/clickhouse/data/ClickHouseColumnTest.java index 3b18d35e2..8c168ffac 100644 --- a/clickhouse-data/src/test/java/com/clickhouse/data/ClickHouseColumnTest.java +++ b/clickhouse-data/src/test/java/com/clickhouse/data/ClickHouseColumnTest.java @@ -477,6 +477,45 @@ public void testGeometryVariantOrdNumUsesArrayDimensions() { Assert.assertEquals(geometry.getGeometryVariantOrdNum(new Object()), -1); } + @Test(groups = { "unit" }) + public void testGeometryVariantOrder() { + ClickHouseColumn geometry = ClickHouseColumn.of("v", "Geometry"); + + // The discriminators the server assigns to the Geometry variants. MultiPoint was added in + // 26.8 after the other six and keeps the last position instead of being ordered by name. + List expected = Arrays.asList( + ClickHouseDataType.LineString, + ClickHouseDataType.MultiLineString, + ClickHouseDataType.MultiPolygon, + ClickHouseDataType.Point, + ClickHouseDataType.Polygon, + ClickHouseDataType.Ring, + ClickHouseDataType.MultiPoint); + + List actual = new LinkedList<>(); + geometry.getNestedColumns().forEach(c -> actual.add(c.getDataType())); + Assert.assertEquals(actual, expected); + + // MultiPoint shares double[][] with Ring and LineString, so a Java value written to a + // Geometry column must keep resolving to the variant it resolved to before. + Assert.assertEquals(geometry.getGeometryVariantOrdNum(2), + getVariantOrdNum(geometry, ClickHouseDataType.Ring)); + Assert.assertEquals(geometry.getGeometryVariantOrdNum( + ClickHouseGeoRingValue.of(new double[][] { { 1D, 2D }, { 3D, 4D } })), + getVariantOrdNum(geometry, ClickHouseDataType.Ring)); + } + + @Test(groups = { "unit" }) + public void testMultiPointColumn() { + ClickHouseColumn column = ClickHouseColumn.of("m", "MultiPoint"); + + Assert.assertEquals(column.getDataType(), ClickHouseDataType.MultiPoint); + Assert.assertFalse(column.isNullable()); + Assert.assertTrue(column.newValue(null) instanceof ClickHouseGeoRingValue); + Assert.assertEquals(ClickHouseColumn.of("m", "Array(MultiPoint)").getArrayBaseColumn().getDataType(), + ClickHouseDataType.MultiPoint); + } + private static int getVariantOrdNum(ClickHouseColumn column, ClickHouseDataType dataType) { for (int i = 0; i < column.getNestedColumns().size(); i++) { if (column.getNestedColumns().get(i).getDataType() == dataType) { 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 7a244e956..8e24ddae2 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 @@ -244,6 +244,8 @@ private T readValue(ClickHouseColumn column, Class typeHint, boolean stri return (T) readGeoRing(); case LineString: return (T) readGeoRing(); + case MultiPoint: + return (T) readGeoRing(); case JSON: // experimental https://clickhouse.com/docs/en/sql-reference/data-types/newjson if (jsonAsString) { return (T) readString(input); 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 44badb507..a5f2944c8 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 @@ -103,6 +103,7 @@ public static void serializeData(OutputStream stream, Object value, ClickHouseCo break; case Ring: case LineString: + case MultiPoint: value = value instanceof ClickHouseGeoRingValue ? ((ClickHouseGeoRingValue)value).getValue() : value; serializeArrayData(stream, value, GEO_RING_ARRAY); break; @@ -388,6 +389,7 @@ public static void writeDynamicTypeTag(OutputStream stream, ClickHouseColumn typ case Point: case LineString: case MultiLineString: + case MultiPoint: case Polygon: case Ring: case MultiPolygon: diff --git a/client-v2/src/main/java/com/clickhouse/client/api/internal/DataTypeConverter.java b/client-v2/src/main/java/com/clickhouse/client/api/internal/DataTypeConverter.java index 086c499fb..713e9cc43 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/internal/DataTypeConverter.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/internal/DataTypeConverter.java @@ -79,6 +79,7 @@ public String convertToString(Object value, ClickHouseColumn column) { case Point: case Ring: case LineString: + case MultiPoint: case Polygon: case MultiLineString: case MultiPolygon: @@ -487,6 +488,7 @@ private boolean isGeoType(ClickHouseDataType dataType) { case Point: case Ring: case LineString: + case MultiPoint: case Polygon: case MultiLineString: case MultiPolygon: @@ -502,7 +504,8 @@ private boolean isGeoTypeForDimensions(ClickHouseDataType dataType, int dimensio case 1: return dataType == ClickHouseDataType.Point; case 2: - return dataType == ClickHouseDataType.Ring || dataType == ClickHouseDataType.LineString; + return dataType == ClickHouseDataType.Ring || dataType == ClickHouseDataType.LineString + || dataType == ClickHouseDataType.MultiPoint; case 3: return dataType == ClickHouseDataType.Polygon || dataType == ClickHouseDataType.MultiLineString; case 4: 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 608c7c84d..29941bbd6 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 @@ -4,6 +4,7 @@ import com.clickhouse.data.ClickHouseColumn; import com.clickhouse.data.ClickHouseDataType; import com.clickhouse.data.value.ClickHouseGeoPolygonValue; +import com.clickhouse.data.value.ClickHouseGeoRingValue; import org.testng.Assert; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; @@ -127,9 +128,27 @@ public void testDynamicWithGeoCustomTypeRoundTrip() throws Exception { public void testDynamicTypeTagUsesCustomEncodingForGeoTypes() throws Exception { assertCustomGeoTypeTag("LineString"); assertCustomGeoTypeTag("MultiLineString"); + assertCustomGeoTypeTag("MultiPoint"); assertCustomGeoTypeTag("Geometry"); } + @Test + public void testMultiPointRoundTrip() throws Exception { + ClickHouseColumn multiPoint = ClickHouseColumn.of("v", "MultiPoint"); + double[][] points = new double[][] {{1D, 2D}, {3D, 4D}, {5D, 6D}}; + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SerializerUtils.serializeData(out, ClickHouseGeoRingValue.of(points), multiPoint); + + // Identical wire representation to Ring: a var-uint point count followed by two Float64 per point. + ByteArrayOutputStream ring = new ByteArrayOutputStream(); + SerializerUtils.serializeData(ring, ClickHouseGeoRingValue.of(points), ClickHouseColumn.of("v", "Ring")); + Assert.assertEquals(out.toByteArray(), ring.toByteArray()); + + Object value = newReader(out.toByteArray()).readValue(multiPoint); + Assert.assertTrue(Arrays.deepEquals((double[][]) value, points)); + } + @Test public void testGeometrySerializationRejectsUnsupportedValue() { Assert.assertThrows(IllegalArgumentException.class, diff --git a/client-v2/src/test/java/com/clickhouse/client/datatypes/DataTypeTests.java b/client-v2/src/test/java/com/clickhouse/client/datatypes/DataTypeTests.java index 5e330a6a3..6ee48fe67 100644 --- a/client-v2/src/test/java/com/clickhouse/client/datatypes/DataTypeTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/datatypes/DataTypeTests.java @@ -603,6 +603,7 @@ public void testVariantWithSimpleDataTypes() throws Exception { case Nullable: // virtual type case LowCardinality: // virtual type case LineString: // same as Ring + case MultiPoint: // same as Ring case MultiLineString: // same as MultiPolygon case Time: case Time64: @@ -1038,6 +1039,7 @@ public void testDynamicWithPrimitives() throws Exception { case LowCardinality: // virtual type case Enum: // virtual type case LineString: // same as Ring + case MultiPoint: // same as Ring case MultiLineString: // same as MultiPolygon case Time: case Time64: @@ -1873,6 +1875,82 @@ public void testGeometryWriteToTable() throws Exception { } } + private static final String MULTI_POINT_UNSUPPORTED_VERSIONS = "(,26.7]"; + + @Data + @AllArgsConstructor + public static class DTOForMultiPointTests { + private int rowId; + private double[][] geom; + private double marker; + } + + @Test(groups = {"integration"}) + public void testMultiPoint() throws Exception { + if (isVersionMatch(MULTI_POINT_UNSUPPORTED_VERSIONS)) { + return; + } + + final String table = "test_multi_point"; + final double[][] expected = new double[][] {{1D, 2D}, {3D, 4D}, {5D, 6D}}; + + client.execute("DROP TABLE IF EXISTS " + table).get().close(); + client.execute(tableDefinition(table, "rowId Int32", "geom MultiPoint", "marker Float64")).get().close(); + client.register(DTOForMultiPointTests.class, client.getTableSchema(table)); + + client.insert(table, Collections.singletonList(new DTOForMultiPointTests(0, expected, 42D))).get().close(); + client.execute("INSERT INTO " + table + " VALUES (1, readWKTMultiPoint('MULTIPOINT(1 2, 3 4, 5 6)'), 42)") + .get().close(); + + List records = client.queryAll("SELECT * FROM " + table + " ORDER BY rowId"); + Assert.assertEquals(records.size(), 2); + for (GenericRecord record : records) { + Assert.assertTrue(Arrays.deepEquals((double[][]) record.getObject("geom"), expected)); + Assert.assertTrue(Arrays.deepEquals(record.getGeoRing("geom").getValue(), expected)); + Assert.assertEquals(record.getDouble("marker"), 42D); + } + + try (QueryResponse response = client.query("SELECT * FROM " + table + " ORDER BY rowId").get()) { + ClickHouseBinaryFormatReader reader = client.newBinaryFormatReader(response); + int rows = 0; + while (reader.next() != null) { + Assert.assertTrue(Arrays.deepEquals((double[][]) reader.readValue("geom"), expected)); + Assert.assertEquals(reader.getString("geom"), "[(1.0,2.0),(3.0,4.0),(5.0,6.0)]"); + Assert.assertEquals(reader.getDouble("marker"), 42D); + rows++; + } + Assert.assertEquals(rows, 2); + } + } + + @Test(groups = {"integration"}) + public void testGeometryWithMultiPoint() throws Exception { + if (isVersionMatch(MULTI_POINT_UNSUPPORTED_VERSIONS)) { + return; + } + + final String table = "test_geometry_multi_point"; + final double[][] points = new double[][] {{1D, 2D}, {3D, 4D}, {5D, 6D}}; + final double[][] ring = new double[][] {{1D, 2D}, {3D, 4D}, {1D, 2D}}; + + client.execute("DROP TABLE IF EXISTS " + table).get().close(); + client.execute(tableDefinition(table, "rowId Int32", "geom Geometry", "marker Float64"), + (CommandSettings) new CommandSettings().serverSetting("allow_suspicious_variant_types", "1")) + .get().close(); + client.execute("INSERT INTO " + table + " VALUES " + + "(0, readWKTMultiPoint('MULTIPOINT(1 2, 3 4, 5 6)'), 42), " + + "(1, CAST([(1, 2), (3, 4), (1, 2)] AS Ring), 42)").get().close(); + + List records = client.queryAll("SELECT * FROM " + table + " ORDER BY rowId"); + Assert.assertEquals(records.size(), 2); + // A MultiPoint value stored in a Geometry column decodes to the same double[][] shape as a + // Ring value, which keeps decoding unchanged. + Assert.assertTrue(Arrays.deepEquals((double[][]) records.get(0).getObject("geom"), points)); + Assert.assertTrue(Arrays.deepEquals((double[][]) records.get(1).getObject("geom"), ring)); + Assert.assertEquals(records.get(0).getDouble("marker"), 42D); + Assert.assertEquals(records.get(1).getDouble("marker"), 42D); + } + @Test(groups = {"integration"}) public void testDates() throws Exception { LocalDate date = LocalDate.of(2024, 1, 15); diff --git a/docs/features.md b/docs/features.md index abcb55dc8..748d525de 100644 --- a/docs/features.md +++ b/docs/features.md @@ -23,7 +23,8 @@ This document lists stable, user-visible behavior in `client-v2` and `jdbc-v2` t - Data type conversion: Maps ClickHouse types to Java values for binary reads, POJO binding, and SQL parameter formatting, including date/time handling. - BFloat16 type support: For ClickHouse `24.11+`, reads and writes the `BFloat16` type through generic records, binary readers, POJO binding, and `Nullable`/`Dynamic`/`Variant` wrappers. `BFloat16` maps to the Java `float` type: a read widens the stored 16-bit value losslessly, while a write keeps the high 16 bits of the `float`. In `jdbc-v2`, `BFloat16` maps to `java.sql.Types.FLOAT` / `java.lang.Float` and is read and written through the standard `getFloat`/`setFloat` and `getObject` accessors. - QBit type support: For ClickHouse `25.10+` (requires the `allow_experimental_qbit_type` server setting to create a column), reads and writes the experimental `QBit(element_type, dimension[, stride])` vector type. The type-name parser accepts two or three parameters (the optional third parameter is the stride) and recognizes the documented element types `Int8`, `BFloat16`, `Float32`, and `Float64`; an element type outside that documented set is parsed with a warning rather than rejected, so a newer server-side element type does not require a client change to parse. On the wire a `QBit` value is encoded exactly like `Array(element_type)` (a length-prefixed list of elements), so the client reads and writes it as a Java array of the element type — `float[]` for `BFloat16`/`Float32`, `double[]` for `Float64` — through generic records, binary readers, and POJO binding, using a dedicated `QBit` read/serialize path (the shared `Array`-like wire encoding is an implementation detail, not a type equivalence). A `QBit` held inside a `Dynamic`/`Variant`/`JSON` column is decoded on read: its binary type encoding (`0x36 `) is read back to the concrete `QBit(...)` type. The client never infers a `QBit` from a Java value, so writing a `QBit` into a `Dynamic` column is not supported and is rejected with a clear `ClientException` rather than emitting an incomplete tag that would desynchronize the stream. This `Array`-like encoding is what the server uses over `RowBinary` formats; the `Native` format instead transmits `QBit` using its internal bit-transposed `Tuple(FixedString(...))` layout, which the client does not decode, so reading any column that is or contains a `QBit` (including a nested `QBit`, e.g. `Map(String, QBit(...))`) through the `Native` format is rejected with a clear `ClientException` (use a `RowBinary` format such as `RowBinaryWithNamesAndTypes` instead). -- Geometry type support: For ClickHouse `25.11+`, where `Geometry` changed from a string alias to `Variant(Point, Ring, LineString, MultiLineString, Polygon, MultiPolygon)`, the client reads and writes `Geometry` values through generic records, binary readers, POJO binding, and SQL parameter formatting, using Java array dimensionality to represent the geometry shape. +- Geometry type support: For ClickHouse `25.11+`, where `Geometry` changed from a string alias to `Variant(Point, Ring, LineString, MultiLineString, Polygon, MultiPolygon)`, the client reads and writes `Geometry` values through generic records, binary readers, POJO binding, and SQL parameter formatting, using Java array dimensionality to represent the geometry shape. ClickHouse `26.8+` adds `MultiPoint` to that variant, appended after the existing six so their discriminators are unchanged; a `MultiPoint` value read from a `Geometry` column is returned as `double[][]`, the same shape as `Ring` and `LineString`. +- MultiPoint type support: For ClickHouse `26.8+`, reads and writes the `MultiPoint` geo type, which is transmitted exactly like `Ring` and `LineString` (`Array(Point)`) and is represented as `double[][]` through generic records, binary readers, POJO binding, and SQL parameter formatting. A `MultiPoint` held in a `Dynamic` column is decoded on read; it cannot be written into a `Dynamic` column, because the client infers the type from the Java value and `double[][]` resolves to `Ring`. - Nested type support: Un-flattened `Nested(f1 T1, ..., fN TN)` columns (tables created with `flatten_nested = 0`) can be written through the insert path (`Client#insert`) using `RowBinaryFormatWriter#setValue`, and are read back through the binary readers and generic records. The column is serialized the same way it is read — identically to `Array(Tuple(T1, ..., TN))`, a var-uint row count followed by one tuple per nested row — so the value supplied for the column is a `List` (or array) of tuples, one tuple per nested row, each carrying the N field values in declaration order. - Insert APIs: Supports inserting registered POJOs, raw streams, and callback-driven writers, with optional column lists and format selection. - Insert controls: Supports insert-specific settings such as deduplication token, query id, compression behavior, and request headers. @@ -51,7 +52,7 @@ Compatibility-sensitive traits: - `QBit` is wire-compatible with `Array(element_type)` and should not drift: the client transmits the logical vector as a length-prefixed array of its element type (`float[]` for `BFloat16`/`Float32`, `double[]` for `Float64`) rather than the server's bit-transposed on-disk layout, so a `QBit(E, N)` value read from or written to the server round-trips as an array of `E`. A written `QBit(E, N)` value must be a Java array or `List` holding exactly `N` elements: a wrong-sized (including empty) vector, or a non-null value that is neither an array nor a `List`, is rejected during binary serialization with an `IllegalArgumentException` rather than deferred to a server error or silently writing a misaligned stream, matching the fixed dimension the server enforces. Symmetrically, a `QBit(E, N)` read whose on-wire element count does not equal the declared dimension `N` is rejected with a `ClientException` rather than returning a wrong-length vector. - Timezone conversion helpers preserve nanoseconds and can intentionally shift local date or time when interpreted in a different timezone; this behavior is covered by tests and should not be normalized away. - `Geometry` handling is shape-sensitive: supported values are 1D through 4D Java arrays representing the nested geometry variants, and unsupported shapes or non-array values are rejected during serialization. -- `Geometry` write inference is dimension-based rather than fully type-specific: point, ring/line string, polygon/multi-line string, and multi-polygon are selected from array depth, so writing `Geometry` cannot currently distinguish `Ring` from `LineString` or `Polygon` from `MultiLineString`. +- `Geometry` write inference is dimension-based rather than fully type-specific: point, ring/line string, polygon/multi-line string, and multi-polygon are selected from array depth, so writing `Geometry` cannot currently distinguish `Ring` from `LineString` or `Polygon` from `MultiLineString`. `MultiPoint` shares the same 2D shape and is deliberately not selectable through the generic `Geometry` write path — a 2D value keeps resolving to `Ring`, so writing `MultiPoint` requires a concrete `MultiPoint` column. - Session precedence is part of the contract: client session defaults apply to each request, operation settings may override them, and only the client `session_id` is mutable at runtime while other client session properties remain fixed for the lifetime of the client. - SSL mode behavior is compatibility-sensitive: the default is `STRICT`. `ssl_mode` does not enable or disable encryption - the endpoint scheme decides that. `DISABLED` is only valid with a plain `http://` endpoint; combining it with an `https://` endpoint throws `ClientMisconfigurationException`. `TRUST` accepts any server certificate and skips hostname verification; a configured trust store or CA certificate has no effect in this mode and is ignored (a warning is logged), while a client certificate/key is still applied for mTLS. For `VERIFY_CA` and `STRICT`, a trust store and a CA certificate cannot both take effect: when both are configured the trust store is used and the CA certificate is ignored (a warning is logged). A trust store and a client certificate (`sslcert`) still cannot be configured together and throw `ClientMisconfigurationException`. `ssl_mode` values are matched case-insensitively and normalized to the canonical enum name (`DISABLED`, `TRUST`, `VERIFY_CA`, `STRICT`) when the client is built; an unrecognized value throws `ClientMisconfigurationException`. - Custom SSL context precedence is compatibility-sensitive: when an application-supplied `SSLContext` is set (`Client.Builder.setSSLContext(SSLContext)`), it is used as is. `ssl_mode` still applies but only to server hostname verification (`TRUST`/`VERIFY_CA` skip it, `STRICT` enforces it). Because the supplied context replaces any context the client would build, the trust/key material options (trust store, key-store type/password, client key, CA certificate, client certificate) cannot be combined with it and are rejected with `ClientMisconfigurationException`; only `ssl_mode` may be set alongside a custom context. The supplied context is a live object and is never parsed from or represented as a string: a textual `ssl_context` value (from a string option or URL query parameter) is rejected with `ClientMisconfigurationException` rather than silently ignored. @@ -86,10 +87,11 @@ Compatibility-sensitive traits: - Parameter metadata: Reports prepared-statement parameter counts. - Type mapping and conversions: Maps ClickHouse types to JDBC types and Java classes, including date/time handling and `java.time` support. - 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`, `Polygon`, `MultiPolygon`, `MultiLineString`, `Geometry`) bypass the type map and are returned in their native form. +- 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. -- 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. +- 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. - Wrapper support: Implements standard JDBC `Wrapper` and `unwrap` behavior on major JDBC objects. - Packaging and runtime compatibility: Ships as a JDBC 4.2 driver, depends on `client-v2`, and includes native-image metadata for GraalVM users. @@ -103,7 +105,7 @@ Compatibility-sensitive traits: - String-like ClickHouse values have stable JDBC expectations: `String`, `FixedString`, and `Enum` values are returned as strings, while `UUID` is available both as `getString()` and `getObject(..., UUID.class)`. - Binary access to `String`/`FixedString` columns is compatibility-sensitive: `getBytes(...)` and `getBinaryStream(...)` expose the raw column bytes (not a re-encoded text literal), and a `NULL` column returns `null` with `wasNull()` reporting `true`. The `binary_string_support` connection property is passed through to the underlying `client-v2` transport. - `Geometry` has a stable JDBC mapping: metadata reports SQL type `ARRAY` with type name `Geometry`, read paths return nested Java arrays rather than custom wrappers, and write paths depend on the caller preserving the intended point/array nesting shape. -- JDBC `Geometry` writes share the same ambiguity as the client serializer: variant selection is inferred from nesting depth, so `Ring` versus `LineString` and `Polygon` versus `MultiLineString` are not currently distinguishable when writing through the generic `Geometry` path. +- JDBC `Geometry` writes share the same ambiguity as the client serializer: variant selection is inferred from nesting depth, so `Ring` versus `LineString` versus `MultiPoint`, and `Polygon` versus `MultiLineString`, are not currently distinguishable when writing through the generic `Geometry` path. - JDBC `FORMAT JSONEachRow` support is opt-in through the `jdbc_json_parser_factory` driver property, whose value must be a fully-qualified `JsonParserFactory` class name with a public no-argument constructor; JSONEachRow numeric and structured value behavior follows the selected parser and configured server output settings. Inferred JSON arrays are returned from `ResultSet.getObject(...)` as parser-native `List` values rather than JDBC `Array` values because JSONEachRow does not include element metadata. JDBC temporal typed accessors such as `getTimestamp(...)` are not guaranteed for JSONEachRow result sets; callers that need stable JDBC temporal conversions should use the binary default format or perform application-level conversion from string/object values. - Standard `FORMAT JSON` output has ClickHouse-specific `meta` and `data` fields and is not exposed as a JDBC `ResultSet`. JDBC callers that need it should unwrap to `ConnectionImpl`, call `getClient()`, and parse the `QueryResponse` stream directly. - Binary parameters passed through `setBytes()` are encoded as ClickHouse `unhex(...)` expressions rather than text literals; empty byte arrays map to an empty string expression. 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 dff5b4449..97c323911 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 @@ -94,6 +94,7 @@ private static Map generateTypeMap() { map.put(ClickHouseDataType.LineString, JDBCType.ARRAY); map.put(ClickHouseDataType.MultiPolygon, JDBCType.ARRAY); map.put(ClickHouseDataType.MultiLineString, JDBCType.ARRAY); + map.put(ClickHouseDataType.MultiPoint, JDBCType.ARRAY); map.put(ClickHouseDataType.Geometry, JDBCType.ARRAY); map.put(ClickHouseDataType.Tuple, JDBCType.OTHER); map.put(ClickHouseDataType.Nothing, JDBCType.OTHER); @@ -193,6 +194,7 @@ private static Map> getDataTypeClassMap() { map.put(e.getKey(), double[].class); break; case LineString: + case MultiPoint: case Ring: map.put(e.getKey(), double[][].class); break; diff --git a/jdbc-v2/src/main/java/com/clickhouse/jdbc/metadata/DatabaseMetaDataImpl.java b/jdbc-v2/src/main/java/com/clickhouse/jdbc/metadata/DatabaseMetaDataImpl.java index 2d27af9b9..7a5f42181 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/metadata/DatabaseMetaDataImpl.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/metadata/DatabaseMetaDataImpl.java @@ -1455,6 +1455,7 @@ public String getSuffix() { mBuilder.put("MultiPolygon", new TypeLiteralInfo("[", "]")); mBuilder.put("LineString", new TypeLiteralInfo("[", "]")); mBuilder.put("MultiLineString", new TypeLiteralInfo("[", "]")); + mBuilder.put("MultiPoint", new TypeLiteralInfo("[", "]")); TYPE_LITERAL_INFO_MAP = mBuilder.buildOrThrow(); } diff --git a/jdbc-v2/src/main/java/com/clickhouse/jdbc/metadata/ResultSetMetaDataImpl.java b/jdbc-v2/src/main/java/com/clickhouse/jdbc/metadata/ResultSetMetaDataImpl.java index a0f64a17f..05b23c127 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/metadata/ResultSetMetaDataImpl.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/metadata/ResultSetMetaDataImpl.java @@ -146,6 +146,7 @@ public Class resolveColumnClass(String columnName, Map> type case Polygon: case MultiPolygon: case MultiLineString: + case MultiPoint: case Geometry: return null; // read as is default: 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 332c05e01..f00057bf5 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/JdbcDataTypeTests.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/JdbcDataTypeTests.java @@ -3043,6 +3043,68 @@ public void testGeoLineString() throws Exception { } } + private static final String MULTI_POINT_UNSUPPORTED_VERSIONS = "(,26.7]"; + + @Test(groups = { "integration" }) + public void testGeoMultiPoint() throws Exception { + if (ClickHouseVersion.of(getServerVersion()).check(MULTI_POINT_UNSUPPORTED_VERSIONS)) { + return; + } + + final Double[][] row = new Double[][] { + {10.123456789, 11.123456789}, + {12.123456789, 13.123456789}, + {14.123456789, 15.123456789}, + }; + + final double[][] expected = new double[][] { + {10.123456789, 11.123456789}, + {12.123456789, 13.123456789}, + {14.123456789, 15.123456789}, + }; + + try (Connection conn = getJdbcConnection(); Statement stmt = conn.createStatement()) { + final String table = "test_geo_multi_point"; + stmt.executeUpdate("DROP TABLE IF EXISTS " + table); + stmt.executeUpdate("CREATE TABLE " + table + + " (rowId Int32, geom MultiPoint, marker Float64) ENGINE = MergeTree ORDER BY rowId"); + + try (PreparedStatement pstmt = + conn.prepareStatement("INSERT INTO " + table + " VALUES (?, ?, ?)")) { + pstmt.setInt(1, 1); + pstmt.setObject(2, conn.createArrayOf("Array(Point)", row)); + pstmt.setDouble(3, 42D); + pstmt.executeUpdate(); + } + stmt.executeUpdate("INSERT INTO " + table + + " VALUES (2, readWKTMultiPoint('MULTIPOINT(10.123456789 11.123456789, " + + "12.123456789 13.123456789, 14.123456789 15.123456789)'), 42)"); + + try (ResultSet rs = stmt.executeQuery("SELECT * FROM " + table + " ORDER BY rowId")) { + int geomColumn = 2; + ResultSetMetaData rsMd = rs.getMetaData(); + assertEquals(rsMd.getColumnTypeName(geomColumn), ClickHouseDataType.MultiPoint.name()); + assertEquals(rsMd.getColumnType(geomColumn), Types.ARRAY); + assertEquals(rsMd.getColumnClassName(geomColumn), Array.class.getName()); + + int rows = 0; + while (rs.next()) { + rows++; + assertEquals(rs.getInt(1), rows); + Object asObject = rs.getObject(geomColumn); + assertTrue(asObject instanceof double[][]); + assertTrue(Arrays.deepEquals((double[][]) asObject, expected)); + Array asArray = rs.getArray(geomColumn); + assertEquals(asArray.getArray(), row); + assertEquals(asArray.getBaseTypeName(), ClickHouseDataType.MultiPoint.name()); + assertEquals(asArray.getBaseType(), Types.ARRAY); + assertEquals(rs.getDouble(3), 42D); + } + assertEquals(rows, 2); + } + } + } + @Test(groups = { "integration" }) public void testGeoMultiLineString() throws Exception { final Double[][][] row = new Double[][][] { diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/metadata/DatabaseMetaDataTest.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/metadata/DatabaseMetaDataTest.java index c9ca30edd..45e7aded5 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/metadata/DatabaseMetaDataTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/metadata/DatabaseMetaDataTest.java @@ -763,6 +763,7 @@ public void testGetServerVersions() throws Exception { map.put(ClickHouseDataType.MultiPolygon, bracket); map.put(ClickHouseDataType.LineString, bracket); map.put(ClickHouseDataType.MultiLineString, bracket); + map.put(ClickHouseDataType.MultiPoint, bracket); String[] brace = new String[]{"{", "}"}; map.put(ClickHouseDataType.Map, brace); @@ -899,11 +900,17 @@ public void testFindNestedTypes() throws Exception { nestedTypes.remove(typeName); } - if (ClickHouseVersion.of(getServerVersion()).check("(,25.10]")) { - assertEquals(nestedTypes, Arrays.asList("Geometry")); // Geometry was introduced in 25.11 + ClickHouseVersion serverVersion = ClickHouseVersion.of(getServerVersion()); + Set expectedMissing = new HashSet<>(); + if (serverVersion.check("(,25.10]")) { + expectedMissing.add("Geometry"); // Geometry was introduced in 25.11 } else { - assertEquals(nestedTypes, Arrays.asList("Object")); // Object is deprecated in 25.11 + expectedMissing.add("Object"); // Object is deprecated in 25.11 } + if (serverVersion.check("(,26.7]")) { + expectedMissing.add("MultiPoint"); // MultiPoint was introduced in 26.8 + } + assertEquals(nestedTypes, expectedMissing); } } } From 58487f567562ff4a83522d5acdccee6bf5802699 Mon Sep 17 00:00:00 2001 From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:14:03 +0000 Subject: [PATCH 2/2] test(client-v2): cover two dimensional geo variant matching The Sonar quality gate reported 75% coverage on new code. The single uncovered spot was the two dimensional branch of DataTypeConverter.isGeoTypeForDimensions, where only one of the three type comparisons was exercised. Add a unit test that converts the same double[][] value through Variant(String, Ring), Variant(String, LineString) and Variant(String, MultiPoint), plus a contrast case with Variant(String, Polygon) that matches no two dimensional geo type and thus keeps the plain array form. --- .../api/internal/DataTypeConverterTest.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/client-v2/src/test/java/com/clickhouse/client/api/internal/DataTypeConverterTest.java b/client-v2/src/test/java/com/clickhouse/client/api/internal/DataTypeConverterTest.java index 8b9bf4681..40416db86 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/internal/DataTypeConverterTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/internal/DataTypeConverterTest.java @@ -177,6 +177,24 @@ public void testVariantOrDynamicGeoToString() { "[[1.0, 2.0, 3.0]]"); } + @Test + public void testVariantTwoDimensionalGeoToString() { + DataTypeConverter converter = new DataTypeConverter(); + double[][] value = new double[][] {{1D, 2D}, {3D, 4D}}; + + // every two dimensional geo type is written as a point sequence + assertEquals(converter.convertToString(value, ClickHouseColumn.of("field", "Variant(String, Ring)")), + "[(1.0,2.0),(3.0,4.0)]"); + assertEquals(converter.convertToString(value, ClickHouseColumn.of("field", "Variant(String, LineString)")), + "[(1.0,2.0),(3.0,4.0)]"); + assertEquals(converter.convertToString(value, ClickHouseColumn.of("field", "Variant(String, MultiPoint)")), + "[(1.0,2.0),(3.0,4.0)]"); + + // no variant matches the two dimensional shape, thus the value keeps the plain array form + assertEquals(converter.convertToString(value, ClickHouseColumn.of("field", "Variant(String, Polygon)")), + "[[1.0, 2.0], [3.0, 4.0]]"); + } + @DataProvider(name = "queryParameters") public static Object[][] queryParameters() { return new Object[][] {