diff --git a/client-v2/src/main/java/com/clickhouse/client/api/internal/ClientUtils.java b/client-v2/src/main/java/com/clickhouse/client/api/internal/ClientUtils.java index 517530c83..235d55cad 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/internal/ClientUtils.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/internal/ClientUtils.java @@ -8,6 +8,10 @@ public final class ClientUtils { private ClientUtils() {} public static boolean isNotBlank(String str) { - return str != null && !str.trim().isEmpty(); + return !isBlank(str); + } + + public static boolean isBlank(String str) { + return str == null || str.trim().isEmpty(); } } diff --git a/jdbc-v2/src/main/java/com/clickhouse/jdbc/ConnectionImpl.java b/jdbc-v2/src/main/java/com/clickhouse/jdbc/ConnectionImpl.java index 4f41865ce..261acc5b7 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/ConnectionImpl.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/ConnectionImpl.java @@ -13,6 +13,7 @@ import com.clickhouse.jdbc.internal.ParsedPreparedStatement; import com.clickhouse.jdbc.internal.SqlParserFacade; import com.clickhouse.jdbc.metadata.DatabaseMetaDataImpl; +import com.google.common.collect.ImmutableMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,7 +37,6 @@ import java.time.Duration; import java.time.temporal.ChronoUnit; import java.util.Calendar; -import java.util.Collections; import java.util.HashSet; import java.util.Map; import java.util.Properties; @@ -68,6 +68,7 @@ public class ConnectionImpl implements Connection, JdbcV2Wrapper { private Executor networkTimeoutExecutor; private final FeatureManager featureManager; + private volatile ImmutableMap> typeMap; public ConnectionImpl(String url, Properties info) throws SQLException { try { @@ -119,6 +120,7 @@ public ConnectionImpl(String url, Properties info) throws SQLException { this.sqlParser = SqlParserFacade.getParser(config.getDriverProperty(DriverProperties.SQL_PARSER.getKey(), DriverProperties.SQL_PARSER.getDefaultValue()), config); this.featureManager = new FeatureManager(this.config); + this.typeMap = ImmutableMap.>builder().putAll(this.config.getTypeMap()).buildKeepingLast(); } catch (SQLException e) { throw e; } catch (Exception e) { @@ -297,14 +299,16 @@ public CallableStatement prepareCall(String sql, int resultSetType, int resultSe @Override public Map> getTypeMap() throws SQLException { ensureOpen(); - featureManager.unsupportedFeatureThrow("getTypeMap()"); - return Collections.emptyMap(); + return this.typeMap; } @Override public void setTypeMap(Map> map) throws SQLException { ensureOpen(); - featureManager.unsupportedFeatureThrow("setTypeMap(Map>)"); + if (map == null) { + throw new SQLException("Type map cannot be null", ExceptionUtils.SQL_STATE_CLIENT_ERROR); + } + this.typeMap = ImmutableMap.>builder().putAll(map).buildKeepingLast(); } @Override diff --git a/jdbc-v2/src/main/java/com/clickhouse/jdbc/DriverProperties.java b/jdbc-v2/src/main/java/com/clickhouse/jdbc/DriverProperties.java index f18071e3f..4be3deb9d 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/DriverProperties.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/DriverProperties.java @@ -129,6 +129,22 @@ public enum DriverProperties { */ CLUSTER_NAME("jdbc_cluster_name", null), + /** + * Define custom type mappings for JDBC ResultSet#getObject() method. + * Format of the property is 'key=value'. + * Key is the ClickHouse type name. + * Value is the Java class name. + * Example: 'UInt64=java.lang.String' + */ + JDBC_TYPE_MAPPINGS("jdbc_type_mappings", null), + + /** + * Deprecated and will be removed. + * This property is here to keep backward compatibility with `typeMappings` property. + * Use `jdbc_type_mappings` instead + */ + @Deprecated + TYPE_MAPPINGS("typeMappings", null), ; diff --git a/jdbc-v2/src/main/java/com/clickhouse/jdbc/PreparedStatementImpl.java b/jdbc-v2/src/main/java/com/clickhouse/jdbc/PreparedStatementImpl.java index bce734a6a..4a93f5bbb 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/PreparedStatementImpl.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/PreparedStatementImpl.java @@ -415,7 +415,7 @@ public ResultSetMetaData getMetaData() throws SQLException { TableSchema tSchema = connection.getClient().getTableSchemaFromQuery(sql); resultSetMetaData = new ResultSetMetaDataImpl(tSchema.getColumns(), connection.getSchema(), connection.getCatalog(), - tSchema.getTableName(), JdbcUtils.DATA_TYPE_CLASS_MAP); + tSchema.getTableName(), JdbcUtils.DATA_TYPE_CLASS_MAP, connection.getTypeMap()); } catch (Exception e) { LOG.warn("Failed to get schema for statement '{}'", originalSql); } @@ -427,7 +427,7 @@ public ResultSetMetaData getMetaData() throws SQLException { .collect(Collectors.toList()); resultSetMetaData = new ResultSetMetaDataImpl(columns, connection.getSchema(), connection.getCatalog(), - "", JdbcUtils.DATA_TYPE_CLASS_MAP); + "", JdbcUtils.DATA_TYPE_CLASS_MAP, connection.getTypeMap()); } } else if (currentResultSet != null) { resultSetMetaData = currentResultSet.getMetaData(); 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 1b47031f1..a0469dec8 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java @@ -10,7 +10,6 @@ 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; @@ -69,7 +68,8 @@ public class ResultSetImpl implements ResultSet, JdbcV2Wrapper { private final int maxRows; private Consumer onDataTransferException; - private final Map columnTypeBindings; + + private final Map> connTypeMap; public ResultSetImpl(StatementImpl parentStatement, QueryResponse response, ClickHouseBinaryFormatReader reader, Consumer onDataTransferException) throws SQLException { @@ -84,15 +84,18 @@ public ResultSetImpl(StatementImpl parentStatement, QueryResponse response, Clic this.reader = reader; this.featureManager = new FeatureManager(parentStatement.getConnection().getJdbcConfig()); TableSchema tableMetadata = reader.getSchema(); + this.connTypeMap = parentStatement.getConnection().getTypeMap(); 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) + // The metadata owns all column type bindings; this result set reuses them via resolveColumnClass(...). + // Use the single connection type map snapshot taken above so metadata binding and getObject(...) stay + // consistent even if the connection type map is replaced concurrently during iteration. this.metaData = new ResultSetMetaDataImpl(tableMetadata .getColumns(), response.getSettings().getDatabase(), "", tableMetadata.getTableName(), - resolvedDefaultTypeMap); + resolvedDefaultTypeMap, this.connTypeMap); this.closed = false; this.wasNull = false; this.defaultCalendar = parentStatement.getConnection().defaultCalendar; @@ -104,41 +107,6 @@ 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); @@ -1502,7 +1470,7 @@ public Object getObject(int columnIndex) throws SQLException { @Override public Object getObject(String columnLabel) throws SQLException { - return getObjectImpl(columnLabel, null, Collections.emptyMap()); + return getObjectImpl(columnLabel, null, connTypeMap); } @Override @@ -1540,7 +1508,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/main/java/com/clickhouse/jdbc/internal/JdbcConfiguration.java b/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcConfiguration.java index d4d1c4987..eca74aa53 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcConfiguration.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcConfiguration.java @@ -18,6 +18,8 @@ import java.nio.charset.StandardCharsets; import java.sql.DriverPropertyInfo; import java.sql.SQLException; +import java.util.Arrays; +import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.List; @@ -40,6 +42,31 @@ public class JdbcConfiguration { "[A-Za-z0-9!#$%&'*+\\.\\^_`\\|~-]+"); private final boolean disableFrameworkDetection; + private final Map> typeMap; + private static final Map> COMMON_CLASSES; + + static { + ImmutableMap.Builder> mapBuilder = ImmutableMap.builder(); + + Arrays.stream(new Class[] { + String.class, Byte.class, Short.class, Integer.class, Long.class, + Float.class, Double.class, Boolean.class, Character.class, Object.class, + java.math.BigDecimal.class, java.math.BigInteger.class, + java.util.UUID.class, java.util.Date.class, java.util.Map.class, java.util.List.class, + java.time.LocalDate.class, java.time.LocalDateTime.class, java.time.LocalTime.class, + java.time.OffsetDateTime.class, java.time.ZonedDateTime.class, + com.clickhouse.data.ClickHouseDataType.class + }).forEach(c -> mapBuilder.put(c.getName(), c)); + + + Arrays.stream(new Class[] { + String.class, Byte.class, Short.class, Integer.class, Long.class, + Float.class, Double.class, Boolean.class, + java.math.BigDecimal.class, java.math.BigInteger.class, + }).forEach(c -> mapBuilder.put(c.getSimpleName(), c)); + + COMMON_CLASSES = mapBuilder.buildKeepingLast(); + } final Map clientProperties; public Map getClientProperties() { @@ -60,6 +87,10 @@ public boolean isIgnoreUnsupportedRequests() { return isIgnoreUnsupportedRequests; } + public Map> getTypeMap() { + return typeMap; + } + private static final Set DRIVER_PROP_KEYS; static { ImmutableSet.Builder driverPropertiesMapBuilder = ImmutableSet.builder(); @@ -102,6 +133,36 @@ public JdbcConfiguration(String url, Properties info) throws SQLException { this.connectionUrl = createConnectionURL(tmpConnectionUrl, useSSL); this.isIgnoreUnsupportedRequests = Boolean.parseBoolean(getDriverProperty(DriverProperties.IGNORE_UNSUPPORTED_VALUES.getKey(), "false")); + + this.typeMap = loadTypeMap(); + } + + private Map> loadTypeMap() throws SQLException { + String typeMappings = driverProperties.get(DriverProperties.JDBC_TYPE_MAPPINGS.getKey()); + String legacyTypeMappings = driverProperties.get(DriverProperties.TYPE_MAPPINGS.getKey()); + if (typeMappings != null && legacyTypeMappings != null) { + throw new SQLException("Only one of " + DriverProperties.JDBC_TYPE_MAPPINGS.getKey() + " or " + DriverProperties.TYPE_MAPPINGS.getKey() + " can be specified."); + } + String mappingsStr = typeMappings != null ? typeMappings : legacyTypeMappings; + return (mappingsStr == null || mappingsStr.trim().isEmpty()) ? Collections.emptyMap() : parseTypeMappings(mappingsStr); + } + + private Map> parseTypeMappings(String mappingsStr) throws SQLException { + Map> map = new HashMap<>(); + Map parsed = ClientConfigProperties.toKeyValuePairs(mappingsStr); + for (Map.Entry entry : parsed.entrySet()) { + String className = entry.getValue(); + Class clazz = COMMON_CLASSES.get(className); + if (clazz == null) { + try { + clazz = Class.forName(className); + } catch (ClassNotFoundException e) { + throw new SQLException("Class not found for type mapping: " + className, e); + } + } + map.put(entry.getKey(), clazz); + } + return ImmutableMap.>builder().putAll(map).buildKeepingLast(); } /** 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 b9d6e784a..bf7292428 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 @@ -163,6 +163,21 @@ private static Map> generateClassMap() { return ImmutableMap.copyOf(map); } + // Reverse of SQL_TYPE_TO_CLASS_MAP. Used to resolve a JDBC type when a column's Java class has been + // overridden via a user supplied type map. Several SQL types share the same Java class, so a preferred + // SQL type is registered first for the ambiguous classes. + public static final Map, SQLType> CLASS_TO_SQL_TYPE_MAP = generateClassToSqlTypeMap(); + private static Map, SQLType> generateClassToSqlTypeMap() { + Map, SQLType> map = new HashMap<>(); + map.put(String.class, JDBCType.VARCHAR); + map.put(Float.class, JDBCType.FLOAT); // prefer FLOAT over REAL + map.put(byte[].class, JDBCType.VARBINARY); + for (Map.Entry> entry : SQL_TYPE_TO_CLASS_MAP.entrySet()) { + map.putIfAbsent(entry.getValue(), entry.getKey()); + } + return ImmutableMap.copyOf(map); + } + public static final Set INVALID_TARGET_TYPES = EnumSet.of(ClickHouseDataType.Nested, ClickHouseDataType.Enum8, ClickHouseDataType.Enum16, ClickHouseDataType.Enum, ClickHouseDataType.Tuple, ClickHouseDataType.Map, ClickHouseDataType.Nothing, ClickHouseDataType.Nullable, ClickHouseDataType.Variant); 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 e7399a413..a0f64a17f 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 @@ -8,6 +8,9 @@ import com.google.common.collect.ImmutableList; import java.sql.SQLException; +import java.sql.SQLType; +import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -21,15 +24,143 @@ public class ResultSetMetaDataImpl implements java.sql.ResultSetMetaData, JdbcV2 private final String tableName; - private final Map> typeClassMap; + // Per-column type information resolved once at construction time. This is the single source of truth for + // data binding: ResultSetImpl reuses it (see resolveColumnClass) instead of recomputing the mapping itself. + private final List columnTypeBindings; + private final Map columnTypeBindingsByName; public ResultSetMetaDataImpl(List columns, String schema, String catalog, String tableName, - Map> typeClassMap) { + Map> typeClassMap, Map> customTypeMap) { this.columns = ImmutableList.copyOf(columns); this.schema = schema; this.catalog = catalog; this.tableName = tableName; - this.typeClassMap = typeClassMap; + this.columnTypeBindings = buildColumnTypeBindings(this.columns, + typeClassMap != null ? typeClassMap : JdbcUtils.DATA_TYPE_CLASS_MAP, + customTypeMap != null ? customTypeMap : Collections.emptyMap()); + + Map byName = new HashMap<>(); + for (ColumnTypeBinding binding : columnTypeBindings) { + byName.put(binding.column.getColumnName(), binding); // keep last on duplicate names + } + this.columnTypeBindingsByName = Collections.unmodifiableMap(byName); + } + + private static List buildColumnTypeBindings(List columns, + Map> typeClassMap, + Map> customTypeMap) { + ImmutableList.Builder bindings = ImmutableList.builder(); + for (ClickHouseColumn column : columns) { + ClickHouseDataType dataType = column.getDataType(); + SQLType jdbcType = JdbcUtils.convertToSqlType(dataType); + Class defaultClass = typeClassMap.get(dataType); + + // A user supplied type map can override the Java class for a given ClickHouse/JDBC type. + Class customClass = customTypeMap.get(dataType.name()); + if (customClass == null) { + customClass = customTypeMap.get(jdbcType.getName()); + } + + final Class columnClass; + final int columnType; + if (customClass != null) { + columnClass = customClass; + // Keep getColumnType() consistent with the overridden class. + SQLType mappedSqlType = JdbcUtils.CLASS_TO_SQL_TYPE_MAP.get(customClass); + columnType = (mappedSqlType != null ? mappedSqlType : jdbcType).getVendorTypeNumber(); + } else { + columnClass = defaultClass != null ? defaultClass : Object.class; + columnType = jdbcType.getVendorTypeNumber(); + } + bindings.add(new ColumnTypeBinding(column, jdbcType, columnType, columnClass)); + } + return bindings.build(); + } + + /** + * Immutable per-column type binding. Holds both the values reported by the metadata methods + * ({@code columnType}/{@code columnClass}, with the connection type map applied) and the raw inputs + * ({@code column}, {@code jdbcType}, {@code defaultClass}) used by {@link #resolveColumnClass} when a caller + * supplies its own type map. + */ + private static final class ColumnTypeBinding { + private final ClickHouseColumn column; + private final SQLType jdbcType; + private final int columnType; + private final Class columnClass; + + ColumnTypeBinding(ClickHouseColumn column, SQLType jdbcType, int columnType, Class columnClass) { + this.column = column; + this.jdbcType = jdbcType; + this.columnType = columnType; + this.columnClass = columnClass; + } + + public ClickHouseColumn getColumn() { + return column; + } + + public SQLType getJdbcType() { + return jdbcType; + } + + public Class getColumnClass() { + return columnClass; + } + + public int getColumnType() { + return columnType; + } + } + + private ColumnTypeBinding getColumnTypeBinding(int column) throws SQLException { + try { + return columnTypeBindings.get(column - 1); + } catch (IndexOutOfBoundsException e) { + throw new SQLException("Column index out of range: " + column, ExceptionUtils.SQL_STATE_CLIENT_ERROR); + } + } + + /** + * Resolves the Java class a value of the given column should be materialized as when read through + * {@code getObject}. This centralizes the data-binding logic so {@link com.clickhouse.jdbc.ResultSetImpl} + * can reuse it instead of maintaining a parallel implementation. + * + * @param columnName column name (label) + * @param typeMap optional caller supplied type map (e.g. from {@code getObject(col, map)}); when {@code null} + * or empty the column's default class is used + * @return target Java class, or {@code null} to indicate the value should be read as-is + * @throws SQLException if the column does not exist + */ + public Class resolveColumnClass(String columnName, Map> typeMap) throws SQLException { + ColumnTypeBinding binding = columnTypeBindingsByName.get(columnName); + if (binding == null) { + throw new SQLException("Column \"" + columnName + "\" does not exist.", ExceptionUtils.SQL_STATE_CLIENT_ERROR); + } + + ClickHouseDataType dataType = binding.getColumn().getDataType(); + switch (dataType) { + case Point: + case Ring: + case LineString: + case Polygon: + case MultiPolygon: + case MultiLineString: + case Geometry: + return null; // read as is + default: + break; + } + + if (typeMap == null || typeMap.isEmpty()) { + return binding.getColumnClass(); + } + + Class resolved = typeMap.get(dataType.name()); + if (resolved == null) { + resolved = typeMap.getOrDefault(binding.getJdbcType().getName(), binding.getColumnClass()); + } + return resolved; } private void checkColumnIndex(int column) throws SQLException { @@ -130,7 +261,7 @@ public String getCatalogName(int column) throws SQLException { @Override public int getColumnType(int column) throws SQLException { - return JdbcUtils.convertToSqlType(getColumn(column).getDataType()).getVendorTypeNumber(); + return getColumnTypeBinding(column).getColumnType(); } @Override @@ -158,14 +289,6 @@ public boolean isDefinitelyWritable(int column) throws SQLException { @Override public String getColumnClassName(int column) throws SQLException { - try { - Class columnClassType = typeClassMap.get(getColumn(column).getDataType()); - if (columnClassType == null) { - columnClassType = Object.class; - } - return columnClassType.getName(); - } catch (Exception e) { - throw ExceptionUtils.toSqlState(e); - } + return getColumnTypeBinding(column).getColumnClass().getName(); } } diff --git a/jdbc-v2/src/main/java/com/clickhouse/jdbc/types/ArrayResultSet.java b/jdbc-v2/src/main/java/com/clickhouse/jdbc/types/ArrayResultSet.java index dcdb0d6f8..9965978dd 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/types/ArrayResultSet.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/types/ArrayResultSet.java @@ -68,7 +68,7 @@ public ArrayResultSet(Object array, ClickHouseColumn column) { ClickHouseColumn valueColumn = column.getArrayNestedLevel() == 1 ? column.getArrayBaseColumn() : nestedColumns.get(0); this.metadata = new ResultSetMetaDataImpl(Arrays.asList(INDEX_COLUMN, ClickHouseColumn.parse(VALUE_COLUMN + " " + valueColumn.getOriginalTypeName()).get(0)) - , "", "", "", JdbcUtils.DATA_TYPE_CLASS_MAP); + , "", "", "", JdbcUtils.DATA_TYPE_CLASS_MAP, java.util.Collections.emptyMap()); this.componentDataType = valueColumn.getDataType(); this.defaultClass = JdbcUtils.DATA_TYPE_CLASS_MAP.get(componentDataType); indexConverterMap = defaultValueConverters.getConvertersForType(Integer.class); diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/ConnectionTest.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/ConnectionTest.java index d4c459976..0ce66c84c 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/ConnectionTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/ConnectionTest.java @@ -240,15 +240,19 @@ public void clearWarningsTest() throws SQLException { @Test(groups = { "integration" }) - public void getTypeMapTest() throws SQLException { - Connection localConnection = this.getJdbcConnection(); - assertThrows(SQLFeatureNotSupportedException.class, localConnection::getTypeMap); - } + public void typeMapTest() throws SQLException { + try (Connection localConnection = this.getJdbcConnection()) { + java.util.Map> typeMap = localConnection.getTypeMap(); + Assert.assertNotNull(typeMap); - @Test(groups = { "integration" }) - public void setTypeMapTest() throws SQLException { - Connection localConnection = this.getJdbcConnection(); - assertThrows(SQLFeatureNotSupportedException.class, () -> localConnection.setTypeMap(null)); + java.util.Map> newMap = new java.util.HashMap<>(typeMap); + newMap.put("UInt64", String.class); + localConnection.setTypeMap(newMap); + + Assert.assertEquals(localConnection.getTypeMap().get("UInt64"), String.class); + + assertThrows(SQLException.class, () -> localConnection.setTypeMap(null)); + } } @Test(groups = { "integration" }) diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java index 6c76fe6f3..706e95815 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java @@ -35,6 +35,7 @@ import java.time.temporal.ChronoUnit; import java.util.*; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; @@ -653,6 +654,59 @@ static Object[][] testGetMetadataDataProvider() { }; } + @Test(groups = { "integration" }) + void testGetMetadataAppliesCustomTypeMapping() throws Exception { + // Custom mapping overrides the Java class / JDBC type reported for integer columns. The metadata + // returned by PreparedStatementImpl#getMetaData must honor this mapping both before the statement is + // executed (resolved from the parsed query schema) and after it is executed (resolved from the result set). + Properties properties = new Properties(); + properties.setProperty(DriverProperties.JDBC_TYPE_MAPPINGS.getKey(), "Int32=Long,UInt64=String"); + + Consumer verify = (metaData) -> { + try { + assertEquals(metaData.getColumnCount(), 3); + + assertEquals(metaData.getColumnName(1), "i32"); + assertEquals(metaData.getColumnClassName(1), Long.class.getName()); + assertEquals(metaData.getColumnType(1), Types.BIGINT); + + assertEquals(metaData.getColumnName(2), "u64"); + assertEquals(metaData.getColumnClassName(2), String.class.getName()); + assertEquals(metaData.getColumnType(2), Types.VARCHAR); + + assertEquals(metaData.getColumnName(3), "u16"); + assertEquals(metaData.getColumnClassName(3), Integer.class.getName()); + assertEquals(metaData.getColumnType(3), Types.INTEGER); + + } catch (SQLException e) { + fail("no exception expected", e); + } + }; + + try (Connection conn = getJdbcConnection(properties); + PreparedStatement stmt = conn.prepareStatement("SELECT toInt32(?) AS i32, toUInt64(?) AS u64, toUInt16(?) as u16")) { + // Before execution: metadata is derived from the query schema and must already reflect the mapping. + ResultSetMetaData beforeExec = stmt.getMetaData(); + assertNotNull(beforeExec); + + stmt.setInt(1, 42); + stmt.setLong(2, 7L); + stmt.setInt(3, 20); + try (ResultSet rs = stmt.executeQuery()) { + assertTrue(rs.next()); + // Values are materialized using the same mapping. + assertTrue(rs.getObject("i32") instanceof Long, "i32 should be mapped to Long"); + assertTrue(rs.getObject("u64") instanceof String, "u64 should be mapped to String"); + assertTrue(rs.getObject("u16") instanceof Integer, "u16 should be mapped to Integer"); + + // After execution: metadata is taken from the result set but must stay in sync with the mapping. + ResultSetMetaData afterExec = stmt.getMetaData(); + assertNotNull(afterExec); + verify.accept(afterExec); + } + } + } + @Test(groups = { "integration" }) void testMetabaseBug01() throws Exception { try (Connection conn = getJdbcConnection()) { 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 6adf513f0..460def882 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/ResultSetImplTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/ResultSetImplTest.java @@ -572,4 +572,32 @@ public void testGetObjectWithTypeMapMissingEntry() throws SQLException { Assert.assertNotNull(rs.getObject("s", partial)); } } + + @Test(groups = {"integration"}) + public void testCustomTypeMappingToResultSet() throws SQLException { + Properties properties = new Properties(); + properties.setProperty(DriverProperties.JDBC_TYPE_MAPPINGS.getKey(), + "UInt64=String,UInt128=String,Int128=String,UInt256=String,Int256=String," + + "Float32=String,Float64=String,BFloat16=String,Decimal=String,Decimal32=String," + + "Decimal64=String,Decimal128=String,Decimal256=String"); + + try (Connection conn = getJdbcConnection(properties); Statement stmt = conn.createStatement()) { + try (ResultSet rs = stmt.executeQuery("SELECT " + + "toUInt64(1) AS u64, " + + "toUInt128(1) AS u128, " + + "toInt128(1) AS i128, " + + "toUInt256(1) AS u256, " + + "toInt256(1) AS i256, " + + "toFloat32(1.1) AS f32, " + + "toFloat64(1.1) AS f64, " + + "toDecimal32(1.1, 1) AS d32")) { + assertTrue(rs.next()); + + for (int i = 1; i <= rs.getMetaData().getColumnCount(); i++) { + Object val = rs.getObject(i); + assertTrue(val instanceof String, rs.getMetaData().getColumnName(i) + " should be mapped to String"); + } + } + } + } } diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/internal/JdbcConfigurationTest.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/internal/JdbcConfigurationTest.java index a5a3e972f..9e0abe8bc 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/internal/JdbcConfigurationTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/internal/JdbcConfigurationTest.java @@ -3,6 +3,7 @@ import com.clickhouse.client.api.Client; import com.clickhouse.client.api.ClientConfigProperties; +import com.clickhouse.data.ClickHouseDataType; import com.clickhouse.jdbc.DriverProperties; import org.testng.Assert; import org.testng.annotations.DataProvider; @@ -12,11 +13,14 @@ import java.sql.SQLException; import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; import java.util.Properties; import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; import static org.testng.Assert.assertThrows; import static org.testng.Assert.assertTrue; @@ -165,6 +169,113 @@ public void testConfigurationProperties() throws Exception { assertEquals(p.value, "default1"); } + @DataProvider(name = "typeMappingsPropertyKey") + public Object[][] typeMappingsPropertyKey() { + return new Object[][] { + { DriverProperties.JDBC_TYPE_MAPPINGS.getKey() }, // "jdbc_type_mappings" + { DriverProperties.TYPE_MAPPINGS.getKey() }, // deprecated alias "typeMappings" + }; + } + + /** + * Verifies that both {@code jdbc_type_mappings} and the deprecated {@code typeMappings} + * are accepted via {@link Properties} and URL parameters in the comma-separated {@code key=value} format + * (e.g. {@code UInt64=java.lang.String}), are recognized as driver properties (kept in + * driver properties and NOT leaked into client properties), and survive round-tripping + * untouched so downstream consumers can parse them with + * {@link ClientConfigProperties#toKeyValuePairs(String)}. + */ + @SuppressWarnings("deprecation") + @Test(dataProvider = "typeMappingsPropertyKey") + public void testTypeMappingsPropertyAcceptsCommaSeparatedKeyValuePairs(String propertyKey) + throws SQLException + { + String[] stringMappedTypes = { + "UInt64", "UInt128", "Int128", "UInt256", "Int256", + "Float32", "Float64", "BFloat16", + "Decimal", "Decimal32", "Decimal64", "Decimal128", "Decimal256", + "DateTime64" + }; + + Map expected = new LinkedHashMap<>(); + for (String type : stringMappedTypes) { + expected.put(type, "String"); + } + expected.put("Enum8", "Byte"); + expected.put("UInt256", "String"); + + StringBuilder mappingValueBuilder = new StringBuilder(); + for (Map.Entry e : expected.entrySet()) { + if (mappingValueBuilder.length() > 0) { + mappingValueBuilder.append(','); + } + mappingValueBuilder.append(e.getKey()).append('=').append(e.getValue()); + } + String mappingValue = mappingValueBuilder.toString(); + + // 1. Test via Properties object + Properties properties = new Properties(); + properties.setProperty(propertyKey, mappingValue); + JdbcConfiguration configurationProp = new JdbcConfiguration("jdbc:clickhouse://localhost:8123", properties); + verifyConfiguration(configurationProp, propertyKey, mappingValue, expected); + + // 2. Test via URL encoded value + String encodedMappingValue = java.net.URLEncoder.encode(mappingValue, java.nio.charset.StandardCharsets.UTF_8); + JdbcConfiguration configurationUrl = new JdbcConfiguration("jdbc:clickhouse://localhost:8123?" + propertyKey + "=" + encodedMappingValue, new Properties()); + verifyConfiguration(configurationUrl, propertyKey, mappingValue, expected); + + // 3. Test with raw (unencoded) value in URL which should also work due to how split("=", 2) works + JdbcConfiguration configurationRawUrl = new JdbcConfiguration("jdbc:clickhouse://localhost:8123?" + propertyKey + "=" + mappingValue, new Properties()); + verifyConfiguration(configurationRawUrl, propertyKey, mappingValue, expected); + } + + private void verifyConfiguration(JdbcConfiguration configuration, String propertyKey, String mappingValue, Map expected) { + assertEquals( + configuration.getDriverProperty(propertyKey, null), + mappingValue, + "Driver property '" + propertyKey + "' should be preserved verbatim"); + + assertFalse( + configuration.getClientProperties().containsKey(propertyKey), + "Driver property '" + propertyKey + "' must not leak into client properties"); + + Map parsed = ClientConfigProperties.toKeyValuePairs( + configuration.getDriverProperty(propertyKey, null)); + assertEquals(parsed, expected, "Value should be parseable as CSV key=value pairs"); + + Map> expectedClasses = new HashMap<>(); + expectedClasses.put("String", String.class); + expectedClasses.put("Byte", Byte.class); + expectedClasses.put("ClickHouseDataType", ClickHouseDataType.class); + + Map> typeMap = configuration.getTypeMap(); + for (String key : typeMap.keySet()) { + Class expectedClass = expectedClasses.get(expected.get(key)); + assertEquals(typeMap.get(key), expectedClass, "Type mapping for '" + key + "'"); + } + } + + @Test + public void testTypeMappingsThrowsIfBothPropertiesProvided() { + Properties properties = new Properties(); + properties.setProperty(DriverProperties.JDBC_TYPE_MAPPINGS.getKey(), "UInt64=java.lang.String"); + properties.setProperty(DriverProperties.TYPE_MAPPINGS.getKey(), "UInt64=java.lang.String"); + + assertThrows(SQLException.class, () -> + new JdbcConfiguration("jdbc:clickhouse://localhost:8123", properties) + ); + } + + @Test + public void testTypeMappingsThrowsIfClassNotFound() { + Properties properties = new Properties(); + properties.setProperty(DriverProperties.JDBC_TYPE_MAPPINGS.getKey(), "UInt64=java.lang.UnknownClassXXX"); + + assertThrows(SQLException.class, () -> + new JdbcConfiguration("jdbc:clickhouse://localhost:8123", properties) + ); + } + @DataProvider(name = "validURLTestData") public Object[][] createValidConnectionURLTestData() { return Arrays.stream(VALID_URLs) diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/metadata/ResultSetMetaDataImplTest.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/metadata/ResultSetMetaDataImplTest.java index a24e1d197..e515cc7f7 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/metadata/ResultSetMetaDataImplTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/metadata/ResultSetMetaDataImplTest.java @@ -10,6 +10,7 @@ import java.sql.SQLException; import java.sql.Statement; import java.sql.Types; +import java.util.Properties; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; @@ -292,4 +293,34 @@ static void assertColumnTypes(ResultSet rs, String... types) throws Exception { assertEquals(types[i], metadata.getColumnTypeName(i + 1)); } } + + @Test(groups = { "integration" }) + public void testCustomTypeMappingToMetaData() throws Exception { + Properties properties = new Properties(); + properties.setProperty(com.clickhouse.jdbc.DriverProperties.JDBC_TYPE_MAPPINGS.getKey(), + "UInt64=String,UInt128=String,Int128=String,UInt256=String,Int256=String," + + "Float32=String,Float64=String,BFloat16=String,Decimal=String,Decimal32=String," + + "Decimal64=String,Decimal128=String,Decimal256=String"); + + try (Connection conn = getJdbcConnection(properties)) { + try (Statement stmt = conn.createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT " + + "toUInt64(1) AS u64, " + + "toUInt128(1) AS u128, " + + "toInt128(1) AS i128, " + + "toUInt256(1) AS u256, " + + "toInt256(1) AS i256, " + + "toFloat32(1.1) AS f32, " + + "toFloat64(1.1) AS f64, " + + "toDecimal32(1.1, 1) AS d32"); + + ResultSetMetaData rsmd = rs.getMetaData(); + + for (int i = 1; i <= rsmd.getColumnCount(); i++) { + assertEquals(rsmd.getColumnClassName(i), String.class.getName()); + assertEquals(rsmd.getColumnType(i), Types.VARCHAR); + } + } + } + } }