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/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[][] { 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); } } }