diff --git a/CHANGELOG.md b/CHANGELOG.md index c1bb69f17..c8e28090c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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>)` 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) diff --git a/docs/features.md b/docs/features.md index d44549284..be63e9f99 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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>)` 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. diff --git a/jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java b/jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java index e57282967..1b47031f1 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java @@ -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; @@ -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; @@ -72,19 +69,30 @@ public class ResultSetImpl implements ResultSet, JdbcV2Wrapper { private final int maxRows; private Consumer onDataTransferException; + private final Map columnTypeBindings; public ResultSetImpl(StatementImpl parentStatement, QueryResponse response, ClickHouseBinaryFormatReader reader, Consumer onDataTransferException) throws SQLException { + this(parentStatement, response, reader, onDataTransferException, JdbcUtils.DATA_TYPE_CLASS_MAP); + } + + public ResultSetImpl(StatementImpl parentStatement, QueryResponse response, ClickHouseBinaryFormatReader reader, + Consumer onDataTransferException, + Map> 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> 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; @@ -96,6 +104,41 @@ public ResultSetImpl(StatementImpl parentStatement, QueryResponse response, Clic this.onDataTransferException = onDataTransferException; } + private static Map buildColumnTypeBindings(TableSchema schema, + Map> typeMap) { + ImmutableMap.Builder 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 { + 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); @@ -1497,22 +1540,7 @@ public T getObjectImpl(String columnLabel, Class type, Map T getObjectImpl(String columnLabel, Class type, Map resolveTargetType(String columnLabel, ClickHouseColumn column, Map> 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; + } + + 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); diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/ResultSetImplTest.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/ResultSetImplTest.java index 4c3262dd6..6adf513f0 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/ResultSetImplTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/ResultSetImplTest.java @@ -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; @@ -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> 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> 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> 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> 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> 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)); + } + } }