Skip to content

Commit 7c4448c

Browse files
authored
Merge pull request #2865 from ClickHouse/06/02/26/result_set_type_mapping
[jdbc-v2] Improves Type mapping in ResultSet
2 parents 4833321 + 06adce5 commit 7c4448c

4 files changed

Lines changed: 170 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535

3636
- **[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)
3737

38+
- **[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.
39+
3840
### Bug Fixes
3941

4042
- **[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)

docs/features.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ Compatibility-sensitive traits:
6464
- Database metadata: Implements JDBC `DatabaseMetaData` for ClickHouse catalogs, schemas, tables, columns, and related capability reporting.
6565
- Parameter metadata: Reports prepared-statement parameter counts.
6666
- Type mapping and conversions: Maps ClickHouse types to JDBC types and Java classes, including date/time handling and `java.time` support.
67+
- 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.
6768
- Arrays and tuples: Supports JDBC arrays plus ClickHouse tuple values through custom `Array` and `Struct` implementations.
6869
- 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.
6970
- Client info propagation: Supports JDBC client info such as `ApplicationName` and forwards it to the underlying client name.

jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java

Lines changed: 76 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
import com.clickhouse.client.api.metadata.TableSchema;
66
import com.clickhouse.client.api.query.QueryResponse;
77
import com.clickhouse.data.ClickHouseColumn;
8+
import com.clickhouse.data.ClickHouseDataType;
89
import com.clickhouse.jdbc.internal.ExceptionUtils;
910
import com.clickhouse.jdbc.internal.FeatureManager;
1011
import com.clickhouse.jdbc.internal.JdbcUtils;
1112
import com.clickhouse.jdbc.metadata.ResultSetMetaDataImpl;
13+
import com.google.common.collect.ImmutableMap;
1214
import org.slf4j.Logger;
1315
import org.slf4j.LoggerFactory;
1416

@@ -34,13 +36,8 @@
3436
import java.sql.Statement;
3537
import java.sql.Time;
3638
import java.sql.Timestamp;
37-
import java.time.Instant;
3839
import java.time.LocalDate;
3940
import java.time.LocalDateTime;
40-
import java.time.LocalDate;
41-
import java.time.LocalDateTime;
42-
import java.time.LocalTime;
43-
import java.time.ZoneOffset;
4441
import java.time.ZonedDateTime;
4542
import java.util.Calendar;
4643
import java.util.Collections;
@@ -72,19 +69,30 @@ public class ResultSetImpl implements ResultSet, JdbcV2Wrapper {
7269
private final int maxRows;
7370

7471
private Consumer<Exception> onDataTransferException;
72+
private final Map<String, ColumnTypeBinding> columnTypeBindings;
7573

7674
public ResultSetImpl(StatementImpl parentStatement, QueryResponse response, ClickHouseBinaryFormatReader reader,
7775
Consumer<Exception> onDataTransferException) throws SQLException {
76+
this(parentStatement, response, reader, onDataTransferException, JdbcUtils.DATA_TYPE_CLASS_MAP);
77+
}
78+
79+
public ResultSetImpl(StatementImpl parentStatement, QueryResponse response, ClickHouseBinaryFormatReader reader,
80+
Consumer<Exception> onDataTransferException,
81+
Map<ClickHouseDataType, Class<?>> defaultTypeMap) throws SQLException {
7882
this.parentStatement = parentStatement;
7983
this.response = response;
8084
this.reader = reader;
8185
this.featureManager = new FeatureManager(parentStatement.getConnection().getJdbcConfig());
8286
TableSchema tableMetadata = reader.getSchema();
8387

88+
final Map<ClickHouseDataType, Class<?>> resolvedDefaultTypeMap =
89+
defaultTypeMap != null ? defaultTypeMap : JdbcUtils.DATA_TYPE_CLASS_MAP;
90+
this.columnTypeBindings = buildColumnTypeBindings(tableMetadata, resolvedDefaultTypeMap);
91+
8492
// Result set contains columns from one database (there is a special table engine 'Merge' to do cross DB queries)
8593
this.metaData = new ResultSetMetaDataImpl(tableMetadata
8694
.getColumns(), response.getSettings().getDatabase(), "", tableMetadata.getTableName(),
87-
JdbcUtils.DATA_TYPE_CLASS_MAP);
95+
resolvedDefaultTypeMap);
8896
this.closed = false;
8997
this.wasNull = false;
9098
this.defaultCalendar = parentStatement.getConnection().defaultCalendar;
@@ -96,6 +104,41 @@ public ResultSetImpl(StatementImpl parentStatement, QueryResponse response, Clic
96104
this.onDataTransferException = onDataTransferException;
97105
}
98106

107+
private static Map<String, ColumnTypeBinding> buildColumnTypeBindings(TableSchema schema,
108+
Map<ClickHouseDataType, Class<?>> typeMap) {
109+
ImmutableMap.Builder<String, ColumnTypeBinding> bindings = ImmutableMap.builder();
110+
111+
for (ClickHouseColumn column : schema.getColumns()) {
112+
ClickHouseDataType dataType = column.getDataType();
113+
bindings.put(column.getColumnName(), new ColumnTypeBinding(typeMap.get(dataType),
114+
JdbcUtils.convertToSqlType(dataType)));
115+
}
116+
return bindings.buildKeepingLast();
117+
}
118+
119+
/**
120+
* Immutable pair of pre-resolved values for a single column: the Java class to materialize when
121+
* no typeMap is supplied, and the JDBC {@link SQLType} that corresponds to the column's ClickHouse
122+
* data type (used as a secondary key when looking up a user-provided typeMap).
123+
*/
124+
private static final class ColumnTypeBinding {
125+
private final Class<?> aClass;
126+
private final SQLType jdbcType;
127+
128+
ColumnTypeBinding(Class<?> aClass, SQLType jdbcType) {
129+
this.aClass = aClass;
130+
this.jdbcType = jdbcType;
131+
}
132+
133+
public Class<?> getAClass() {
134+
return aClass;
135+
}
136+
137+
public SQLType getJdbcType() {
138+
return jdbcType;
139+
}
140+
}
141+
99142
private void checkClosed() throws SQLException {
100143
if (closed) {
101144
throw new SQLException("ResultSet is closed.", ExceptionUtils.SQL_STATE_CONNECTION_EXCEPTION);
@@ -1497,22 +1540,7 @@ public <T> T getObjectImpl(String columnLabel, Class<?> type, Map<String, Class<
14971540
wasNull = false;
14981541

14991542
if (type == null) {
1500-
switch (column.getDataType()) {
1501-
case Point:
1502-
case Ring:
1503-
case LineString:
1504-
case Polygon:
1505-
case MultiPolygon:
1506-
case MultiLineString:
1507-
case Geometry:
1508-
break; // read as is
1509-
default:
1510-
if (typeMap == null || typeMap.isEmpty()) {
1511-
type = JdbcUtils.convertToJavaClass(column.getDataType());
1512-
} else {
1513-
type = typeMap.get(JdbcUtils.convertToSqlType(column.getDataType()).getName());
1514-
}
1515-
}
1543+
type = resolveTargetType(columnLabel, column, typeMap);
15161544
} else {
15171545
/// shortcut
15181546
if (type == Timestamp.class) {
@@ -1539,6 +1567,32 @@ public <T> T getObjectImpl(String columnLabel, Class<?> type, Map<String, Class<
15391567
}
15401568
}
15411569

1570+
private Class<?> resolveTargetType(String columnLabel, ClickHouseColumn column, Map<String, Class<?>> typeMap) {
1571+
switch (column.getDataType()) {
1572+
case Point:
1573+
case Ring:
1574+
case LineString:
1575+
case Polygon:
1576+
case MultiPolygon:
1577+
case MultiLineString:
1578+
case Geometry:
1579+
return null; // read as is
1580+
default:
1581+
break;
1582+
}
1583+
1584+
ColumnTypeBinding binding = columnTypeBindings.get(columnLabel);
1585+
if (typeMap == null || typeMap.isEmpty()) {
1586+
return binding.getAClass();
1587+
}
1588+
1589+
Class<?> resolved = typeMap.get(column.getDataType().name());
1590+
if (resolved == null) {
1591+
resolved = typeMap.get(binding.getJdbcType().getName());
1592+
}
1593+
return resolved;
1594+
}
1595+
15421596
@Override
15431597
public void updateObject(int columnIndex, Object x, SQLType targetSqlType, int scaleOrLength) throws SQLException {
15441598
updateObject(columnIndexToName(columnIndex), x, targetSqlType, scaleOrLength);

jdbc-v2/src/test/java/com/clickhouse/jdbc/ResultSetImplTest.java

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
import java.sql.Time;
2626
import java.sql.Timestamp;
2727
import java.sql.Types;
28+
import java.time.LocalDate;
29+
import java.time.LocalDateTime;
30+
import java.util.HashMap;
31+
import java.util.Map;
2832
import java.util.Properties;
2933

3034
import static org.testng.Assert.assertEquals;
@@ -481,4 +485,91 @@ public void testGetResultSetFromArrayTimestamp() throws Exception {
481485
}
482486
}
483487
}
488+
489+
@Test(groups = {"integration"})
490+
public void testGetObjectWithSqlTypeNameMap() throws SQLException {
491+
try (Connection conn = getJdbcConnection(); Statement stmt = conn.createStatement();
492+
ResultSet rs = stmt.executeQuery("SELECT 1::Int32 AS i, toDateTime('2024-12-01 12:34:56') AS ts, " +
493+
"toDate('2024-12-01') AS d")) {
494+
assertTrue(rs.next());
495+
496+
Map<String, Class<?>> sqlTypeMap = new HashMap<>();
497+
sqlTypeMap.put(JDBCType.INTEGER.getName(), Long.class);
498+
sqlTypeMap.put(JDBCType.TIMESTAMP.getName(), LocalDateTime.class);
499+
sqlTypeMap.put(JDBCType.DATE.getName(), LocalDate.class);
500+
501+
assertEquals(rs.getObject("i", sqlTypeMap), 1L);
502+
assertEquals(rs.getObject("ts", sqlTypeMap), LocalDateTime.of(2024, 12, 1, 12, 34, 56));
503+
assertEquals(rs.getObject("d", sqlTypeMap), LocalDate.of(2024, 12, 1));
504+
}
505+
}
506+
507+
@Test(groups = {"integration"})
508+
public void testGetObjectWithClickHouseTypeNameMap() throws SQLException {
509+
// typeMap keyed by ClickHouseDataType name (e.g. "Int32"): the V1-style direct lookup
510+
// that ResultSetImpl#getObjectImpl now supports in addition to JDBC SQLType names.
511+
try (Connection conn = getJdbcConnection(); Statement stmt = conn.createStatement();
512+
ResultSet rs = stmt.executeQuery("SELECT 1::Int32 AS i, 1::UInt64 AS u, " +
513+
"toDateTime('2024-12-01 12:34:56') AS ts")) {
514+
assertTrue(rs.next());
515+
516+
Map<String, Class<?>> chTypeMap = new HashMap<>();
517+
chTypeMap.put("Int32", Long.class);
518+
chTypeMap.put("UInt64", String.class);
519+
chTypeMap.put("DateTime", LocalDateTime.class);
520+
521+
assertEquals(rs.getObject("i", chTypeMap), 1L);
522+
assertEquals(rs.getObject("u", chTypeMap), "1");
523+
assertEquals(rs.getObject("ts", chTypeMap), LocalDateTime.of(2024, 12, 1, 12, 34, 56));
524+
}
525+
}
526+
527+
@Test(groups = {"integration"})
528+
public void testGetObjectCustomMappingWithWrappedTypes() throws SQLException {
529+
try (Connection conn = getJdbcConnection(); Statement stmt = conn.createStatement();
530+
ResultSet rs = stmt.executeQuery(
531+
"SELECT CAST(32 AS Nullable(Int32)) AS n")) {
532+
assertTrue(rs.next());
533+
534+
Map<String, Class<?>> typeMap = new HashMap<>();
535+
typeMap.put("Int32", Long.class);
536+
Object n = rs.getObject("n", typeMap);
537+
assertEquals(n, 32L, "Value was of " + n.getClass().getName() + " but should be Long");
538+
}
539+
}
540+
541+
@Test(groups = {"integration"})
542+
public void testGetObjectWithMixedTypeNameMap() throws SQLException {
543+
// Single typeMap mixing ClickHouseDataType names and SQLType names: CH-name lookup is tried
544+
// first, then SQLType-name lookup, so the user can address columns by either convention.
545+
try (Connection conn = getJdbcConnection(); Statement stmt = conn.createStatement();
546+
ResultSet rs = stmt.executeQuery("SELECT 1::Int32 AS i, 'abc'::String AS s")) {
547+
assertTrue(rs.next());
548+
549+
Map<String, Class<?>> mixed = new HashMap<>();
550+
mixed.put("Int32", Long.class);
551+
mixed.put(JDBCType.VARCHAR.getName(), String.class);
552+
553+
assertEquals(rs.getObject("i", mixed), 1L);
554+
assertEquals(rs.getObject("s", mixed), "abc");
555+
}
556+
}
557+
558+
@Test(groups = {"integration"})
559+
public void testGetObjectWithTypeMapMissingEntry() throws SQLException {
560+
// typeMap that does not contain an entry for the column type: ResultSetImpl#getObjectImpl
561+
// falls back to "read as is" (no conversion) as documented by JDBC.
562+
try (Connection conn = getJdbcConnection(); Statement stmt = conn.createStatement();
563+
ResultSet rs = stmt.executeQuery("SELECT 1::Int32 AS i, 'abc'::String AS s")) {
564+
assertTrue(rs.next());
565+
566+
Map<String, Class<?>> partial = new HashMap<>();
567+
partial.put("Int32", Long.class);
568+
569+
assertEquals(rs.getObject("i", partial), 1L);
570+
// String column has no entry: value comes back as the reader's native representation
571+
// and is not coerced into the default Java class.
572+
Assert.assertNotNull(rs.getObject("s", partial));
573+
}
574+
}
484575
}

0 commit comments

Comments
 (0)