Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@

- **[client-v2, jdbc-v2]** Added support for ClickHouse `Geometry` type for ClickHouse `25.11+`, where `Geometry` changed from a `String` alias to `Variant(Point, Ring, LineString, MultiLineString, Polygon, MultiPolygon)` (client still compatible with older versions). Includes client read/write handling and JDBC type mapping for retrieving and inserting geometry values. Current writes infer the target geometry variant from array nesting depth, so `Ring` vs `LineString` and `Polygon` vs `MultiLineString` are not yet distinguishable through the generic `Geometry` write path. (https://github.com/ClickHouse/clickhouse-java/pull/2815)

- **[jdbc-v2]** `ResultSet#getObject(int|String, Map<String, Class<?>>)` now accepts ClickHouse type names as map keys in addition to the JDBC `SQLType` names it has always accepted. Only unwrapped type names are used for the lookup — `Nullable(...)` and `LowCardinality(...)` wrappers are stripped and do not affect resolution, so a key like `"Int32"` matches both `Int32` and `Nullable(Int32)` columns; keys like `"Nullable(Int32)"` are not recognized. Lookup order is the `ClickHouseDataType` enum name (e.g. `"Int32"`, `"String"`, `"DateTime"`) then the JDBC `SQLType` name (e.g. `"INTEGER"`, `"VARCHAR"`, `"TIMESTAMP"`); a missing entry leaves the value uncoerced. The feature is supported for primitive ClickHouse types only — `Array`, `Tuple`, `Map`, `Nested`, and geometry types are not supported and continue to be returned in their native form regardless of the user-supplied map. Existing maps keyed only by JDBC `SQLType` names continue to work unchanged.

### Bug Fixes

- **[client-v2]** Fixed inconsistent use of `executionTimeout` parameter in `Client` component. The timeout was previously set in milliseconds but mistakenly retrieved and used in seconds in some places. Now it correctly uses milliseconds consistently. (https://github.com/ClickHouse/clickhouse-java/issues/2358)
Expand Down
1 change: 1 addition & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Compatibility-sensitive traits:
- Database metadata: Implements JDBC `DatabaseMetaData` for ClickHouse catalogs, schemas, tables, columns, and related capability reporting.
- 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.
- Custom result-set type map: `ResultSet#getObject(int|String, Map<String, Class<?>>)` 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.
- 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.
Expand Down
98 changes: 76 additions & 22 deletions jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
import com.clickhouse.client.api.metadata.TableSchema;
import com.clickhouse.client.api.query.QueryResponse;
import com.clickhouse.data.ClickHouseColumn;
import com.clickhouse.data.ClickHouseDataType;
import com.clickhouse.jdbc.internal.ExceptionUtils;
import com.clickhouse.jdbc.internal.FeatureManager;
import com.clickhouse.jdbc.internal.JdbcUtils;
import com.clickhouse.jdbc.metadata.ResultSetMetaDataImpl;
import com.google.common.collect.ImmutableMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand All @@ -34,13 +36,8 @@
import java.sql.Statement;
import java.sql.Time;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.Calendar;
import java.util.Collections;
Expand Down Expand Up @@ -72,19 +69,30 @@
private final int maxRows;

private Consumer<Exception> onDataTransferException;
private final Map<String, ColumnTypeBinding> columnTypeBindings;

public ResultSetImpl(StatementImpl parentStatement, QueryResponse response, ClickHouseBinaryFormatReader reader,
Consumer<Exception> onDataTransferException) throws SQLException {
this(parentStatement, response, reader, onDataTransferException, JdbcUtils.DATA_TYPE_CLASS_MAP);
}

public ResultSetImpl(StatementImpl parentStatement, QueryResponse response, ClickHouseBinaryFormatReader reader,
Consumer<Exception> onDataTransferException,
Map<ClickHouseDataType, Class<?>> defaultTypeMap) throws SQLException {
this.parentStatement = parentStatement;
this.response = response;
this.reader = reader;
this.featureManager = new FeatureManager(parentStatement.getConnection().getJdbcConfig());
TableSchema tableMetadata = reader.getSchema();

final Map<ClickHouseDataType, Class<?>> resolvedDefaultTypeMap =
defaultTypeMap != null ? defaultTypeMap : JdbcUtils.DATA_TYPE_CLASS_MAP;
this.columnTypeBindings = buildColumnTypeBindings(tableMetadata, resolvedDefaultTypeMap);

// Result set contains columns from one database (there is a special table engine 'Merge' to do cross DB queries)
this.metaData = new ResultSetMetaDataImpl(tableMetadata
.getColumns(), response.getSettings().getDatabase(), "", tableMetadata.getTableName(),
JdbcUtils.DATA_TYPE_CLASS_MAP);
resolvedDefaultTypeMap);
this.closed = false;
this.wasNull = false;
this.defaultCalendar = parentStatement.getConnection().defaultCalendar;
Expand All @@ -96,6 +104,41 @@
this.onDataTransferException = onDataTransferException;
}

private static Map<String, ColumnTypeBinding> buildColumnTypeBindings(TableSchema schema,
Map<ClickHouseDataType, Class<?>> typeMap) {
ImmutableMap.Builder<String, ColumnTypeBinding> bindings = ImmutableMap.builder();

for (ClickHouseColumn column : schema.getColumns()) {
ClickHouseDataType dataType = column.getDataType();
bindings.put(column.getColumnName(), new ColumnTypeBinding(typeMap.get(dataType),
JdbcUtils.convertToSqlType(dataType)));
}
return bindings.buildKeepingLast();
}

/**
* Immutable pair of pre-resolved values for a single column: the Java class to materialize when
* no typeMap is supplied, and the JDBC {@link SQLType} that corresponds to the column's ClickHouse
* data type (used as a secondary key when looking up a user-provided typeMap).
*/
private static final class ColumnTypeBinding {

Check warning on line 124 in jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this class declaration to use 'record ColumnTypeBinding(Class<...> aClass, SQLType jdbcType)'.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ6KPNe8MLXHuiRVB3BK&open=AZ6KPNe8MLXHuiRVB3BK&pullRequest=2865
private final Class<?> aClass;
private final SQLType jdbcType;

ColumnTypeBinding(Class<?> aClass, SQLType jdbcType) {
this.aClass = aClass;
this.jdbcType = jdbcType;
}

public Class<?> getAClass() {
return aClass;
}

public SQLType getJdbcType() {
return jdbcType;
}
}

private void checkClosed() throws SQLException {
if (closed) {
throw new SQLException("ResultSet is closed.", ExceptionUtils.SQL_STATE_CONNECTION_EXCEPTION);
Expand Down Expand Up @@ -1497,22 +1540,7 @@
wasNull = false;

if (type == null) {
switch (column.getDataType()) {
case Point:
case Ring:
case LineString:
case Polygon:
case MultiPolygon:
case MultiLineString:
case Geometry:
break; // read as is
default:
if (typeMap == null || typeMap.isEmpty()) {
type = JdbcUtils.convertToJavaClass(column.getDataType());
} else {
type = typeMap.get(JdbcUtils.convertToSqlType(column.getDataType()).getName());
}
}
type = resolveTargetType(columnLabel, column, typeMap);
} else {
/// shortcut
if (type == Timestamp.class) {
Expand All @@ -1539,6 +1567,32 @@
}
}

private Class<?> resolveTargetType(String columnLabel, ClickHouseColumn column, Map<String, Class<?>> typeMap) {
switch (column.getDataType()) {
case Point:
case Ring:
case LineString:
case Polygon:
case MultiPolygon:
case MultiLineString:
case Geometry:
return null; // read as is
default:
break;
}
Comment thread
chernser marked this conversation as resolved.

ColumnTypeBinding binding = columnTypeBindings.get(columnLabel);
if (typeMap == null || typeMap.isEmpty()) {
return binding.getAClass();
}

Class<?> resolved = typeMap.get(column.getDataType().name());
if (resolved == null) {
resolved = typeMap.get(binding.getJdbcType().getName());
}
return resolved;
}

@Override
public void updateObject(int columnIndex, Object x, SQLType targetSqlType, int scaleOrLength) throws SQLException {
updateObject(columnIndexToName(columnIndex), x, targetSqlType, scaleOrLength);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
import java.sql.Time;
import java.sql.Timestamp;
import java.sql.Types;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;

import static org.testng.Assert.assertEquals;
Expand Down Expand Up @@ -481,4 +485,91 @@ public void testGetResultSetFromArrayTimestamp() throws Exception {
}
}
}

@Test(groups = {"integration"})
public void testGetObjectWithSqlTypeNameMap() throws SQLException {
try (Connection conn = getJdbcConnection(); Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT 1::Int32 AS i, toDateTime('2024-12-01 12:34:56') AS ts, " +
"toDate('2024-12-01') AS d")) {
assertTrue(rs.next());

Map<String, Class<?>> sqlTypeMap = new HashMap<>();
sqlTypeMap.put(JDBCType.INTEGER.getName(), Long.class);
sqlTypeMap.put(JDBCType.TIMESTAMP.getName(), LocalDateTime.class);
sqlTypeMap.put(JDBCType.DATE.getName(), LocalDate.class);

assertEquals(rs.getObject("i", sqlTypeMap), 1L);
assertEquals(rs.getObject("ts", sqlTypeMap), LocalDateTime.of(2024, 12, 1, 12, 34, 56));
assertEquals(rs.getObject("d", sqlTypeMap), LocalDate.of(2024, 12, 1));
}
}

@Test(groups = {"integration"})
public void testGetObjectWithClickHouseTypeNameMap() throws SQLException {
// typeMap keyed by ClickHouseDataType name (e.g. "Int32"): the V1-style direct lookup
// that ResultSetImpl#getObjectImpl now supports in addition to JDBC SQLType names.
try (Connection conn = getJdbcConnection(); Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT 1::Int32 AS i, 1::UInt64 AS u, " +
"toDateTime('2024-12-01 12:34:56') AS ts")) {
assertTrue(rs.next());

Map<String, Class<?>> chTypeMap = new HashMap<>();
chTypeMap.put("Int32", Long.class);
chTypeMap.put("UInt64", String.class);
chTypeMap.put("DateTime", LocalDateTime.class);

assertEquals(rs.getObject("i", chTypeMap), 1L);
assertEquals(rs.getObject("u", chTypeMap), "1");
assertEquals(rs.getObject("ts", chTypeMap), LocalDateTime.of(2024, 12, 1, 12, 34, 56));
}
}

@Test(groups = {"integration"})
public void testGetObjectCustomMappingWithWrappedTypes() throws SQLException {
try (Connection conn = getJdbcConnection(); Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(
"SELECT CAST(32 AS Nullable(Int32)) AS n")) {
assertTrue(rs.next());

Map<String, Class<?>> typeMap = new HashMap<>();
typeMap.put("Int32", Long.class);
Object n = rs.getObject("n", typeMap);
assertEquals(n, 32L, "Value was of " + n.getClass().getName() + " but should be Long");
}
}

@Test(groups = {"integration"})
public void testGetObjectWithMixedTypeNameMap() throws SQLException {
// Single typeMap mixing ClickHouseDataType names and SQLType names: CH-name lookup is tried
// first, then SQLType-name lookup, so the user can address columns by either convention.
try (Connection conn = getJdbcConnection(); Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT 1::Int32 AS i, 'abc'::String AS s")) {
assertTrue(rs.next());

Map<String, Class<?>> mixed = new HashMap<>();
mixed.put("Int32", Long.class);
mixed.put(JDBCType.VARCHAR.getName(), String.class);

assertEquals(rs.getObject("i", mixed), 1L);
assertEquals(rs.getObject("s", mixed), "abc");
}
}

@Test(groups = {"integration"})
public void testGetObjectWithTypeMapMissingEntry() throws SQLException {
// typeMap that does not contain an entry for the column type: ResultSetImpl#getObjectImpl
// falls back to "read as is" (no conversion) as documented by JDBC.
try (Connection conn = getJdbcConnection(); Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT 1::Int32 AS i, 'abc'::String AS s")) {
assertTrue(rs.next());

Map<String, Class<?>> partial = new HashMap<>();
partial.put("Int32", Long.class);

assertEquals(rs.getObject("i", partial), 1L);
// String column has no entry: value comes back as the reader's native representation
// and is not coerced into the default Java class.
Assert.assertNotNull(rs.getObject("s", partial));
}
}
}
Loading