From 0e1422820f1081fb7f6139106757a2a5c4df143d Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Fri, 21 Mar 2025 10:43:37 +0100 Subject: [PATCH] JdbcClient holds ConversionService for queries with mapped classes Closes gh-33467 --- .../jdbc/core/SingleColumnRowMapper.java | 18 ++++++++++++-- .../jdbc/core/simple/DefaultJdbcClient.java | 22 ++++++++++------- .../jdbc/core/simple/JdbcClient.java | 20 ++++++++++++++-- .../core/simple/JdbcClientQueryTests.java | 24 +++++++++++++++---- 4 files changed, 67 insertions(+), 17 deletions(-) diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/SingleColumnRowMapper.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/SingleColumnRowMapper.java index 1b8f3b6af50..5ecea46a8d2 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/SingleColumnRowMapper.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/SingleColumnRowMapper.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -70,6 +70,19 @@ public class SingleColumnRowMapper implements RowMapper { } } + /** + * Create a new {@code SingleColumnRowMapper}. + * @param requiredType the type that each result object is expected to match + * @param conversionService a {@link ConversionService} for converting a fetched value + * @since 7.0 + */ + public SingleColumnRowMapper(Class requiredType, @Nullable ConversionService conversionService) { + if (requiredType != Object.class) { + setRequiredType(requiredType); + } + setConversionService(conversionService); + } + /** * Set the type that each result object is expected to match. @@ -84,12 +97,13 @@ public class SingleColumnRowMapper implements RowMapper { * Set a {@link ConversionService} for converting a fetched value. *

Default is the {@link DefaultConversionService}. * @since 5.0.4 - * @see DefaultConversionService#getSharedInstance + * @see DefaultConversionService#getSharedInstance() */ public void setConversionService(@Nullable ConversionService conversionService) { this.conversionService = conversionService; } + /** * Extract a value for the single column in the current row. *

Validates that there is only one column selected, diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/DefaultJdbcClient.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/DefaultJdbcClient.java index c6f030456da..ea6630c79d1 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/DefaultJdbcClient.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/DefaultJdbcClient.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,8 @@ import javax.sql.DataSource; import org.jspecify.annotations.Nullable; import org.springframework.beans.BeanUtils; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.jdbc.core.JdbcOperations; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.PreparedStatementCreator; @@ -64,24 +66,25 @@ final class DefaultJdbcClient implements JdbcClient { private final NamedParameterJdbcOperations namedParamOps; + private final ConversionService conversionService; + private final Map, RowMapper> rowMapperCache = new ConcurrentHashMap<>(); public DefaultJdbcClient(DataSource dataSource) { - this.classicOps = new JdbcTemplate(dataSource); - this.namedParamOps = new NamedParameterJdbcTemplate(this.classicOps); + this(new JdbcTemplate(dataSource)); } public DefaultJdbcClient(JdbcOperations jdbcTemplate) { - Assert.notNull(jdbcTemplate, "JdbcTemplate must not be null"); - this.classicOps = jdbcTemplate; - this.namedParamOps = new NamedParameterJdbcTemplate(jdbcTemplate); + this(new NamedParameterJdbcTemplate(jdbcTemplate), null); } - public DefaultJdbcClient(NamedParameterJdbcOperations jdbcTemplate) { + public DefaultJdbcClient(NamedParameterJdbcOperations jdbcTemplate, @Nullable ConversionService conversionService) { Assert.notNull(jdbcTemplate, "JdbcTemplate must not be null"); this.classicOps = jdbcTemplate.getJdbcOperations(); this.namedParamOps = jdbcTemplate; + this.conversionService = + (conversionService != null ? conversionService : DefaultConversionService.getSharedInstance()); } @@ -201,8 +204,9 @@ final class DefaultJdbcClient implements JdbcClient { @Override public MappedQuerySpec query(Class mappedClass) { RowMapper rowMapper = rowMapperCache.computeIfAbsent(mappedClass, key -> - BeanUtils.isSimpleProperty(mappedClass) ? new SingleColumnRowMapper<>(mappedClass) : - new SimplePropertyRowMapper<>(mappedClass)); + BeanUtils.isSimpleProperty(mappedClass) ? + new SingleColumnRowMapper<>(mappedClass, conversionService) : + new SimplePropertyRowMapper<>(mappedClass, conversionService)); return query((RowMapper) rowMapper); } diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/JdbcClient.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/JdbcClient.java index 92f646fc69d..1fae8141d40 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/JdbcClient.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/JdbcClient.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,7 @@ import javax.sql.DataSource; import org.jspecify.annotations.Nullable; +import org.springframework.core.convert.ConversionService; import org.springframework.dao.support.DataAccessUtils; import org.springframework.jdbc.core.JdbcOperations; import org.springframework.jdbc.core.ResultSetExtractor; @@ -107,7 +108,22 @@ public interface JdbcClient { * @param jdbcTemplate the delegate to perform operations on */ static JdbcClient create(NamedParameterJdbcOperations jdbcTemplate) { - return new DefaultJdbcClient(jdbcTemplate); + return new DefaultJdbcClient(jdbcTemplate, null); + } + + /** + * Create a {@code JdbcClient} for the given {@link NamedParameterJdbcOperations} delegate, + * typically an {@link org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate}. + *

Use this factory method to reuse existing {@code NamedParameterJdbcTemplate} + * configuration, including its underlying {@code JdbcTemplate} and {@code DataSource}, + * along with a custom {@link ConversionService} for queries with mapped classes. + * @param jdbcTemplate the delegate to perform operations on + * @param conversionService a {@link ConversionService} for converting fetched JDBC values + * to mapped classes in {@link StatementSpec#query(Class)} + * @since 7.0 + */ + static JdbcClient create(NamedParameterJdbcOperations jdbcTemplate, ConversionService conversionService) { + return new DefaultJdbcClient(jdbcTemplate, conversionService); } diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/JdbcClientQueryTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/JdbcClientQueryTests.java index a8212b4a693..e2da04ddc12 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/JdbcClientQueryTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/JdbcClientQueryTests.java @@ -16,10 +16,12 @@ package org.springframework.jdbc.core.simple; +import java.math.BigInteger; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.ResultSetMetaData; +import java.sql.SQLFeatureNotSupportedException; import java.sql.Types; import java.util.ArrayList; import java.util.Arrays; @@ -33,6 +35,11 @@ import javax.sql.DataSource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.core.convert.converter.Converter; +import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.util.NumberUtils; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.mockito.ArgumentMatchers.anyString; @@ -593,13 +600,22 @@ class JdbcClientQueryTests { @Test void queryForMappedFieldHolderWithNamedParam() throws Exception { given(resultSet.next()).willReturn(true, false); - given(resultSet.getInt(1)).willReturn(22); - + given(resultSet.getObject(1, BigInteger.class)).willThrow(new SQLFeatureNotSupportedException()); + given(resultSet.getObject(1)).willReturn("big22"); + + GenericConversionService conversionService = new GenericConversionService(); + conversionService.addConverter(new Converter() { // explicit for generics + @Override + public BigInteger convert(String source) { + return NumberUtils.parseNumber(source.substring(3), BigInteger.class); + } + }); + client = JdbcClient.create(new NamedParameterJdbcTemplate(dataSource), conversionService); AgeFieldHolder value = client.sql("SELECT AGE FROM CUSTMR WHERE ID = :id") .param("id", 3) .query(AgeFieldHolder.class).single(); - assertThat(value.age).isEqualTo(22); + assertThat(value.age).isEqualTo(BigInteger.valueOf(22)); verify(connection).prepareStatement("SELECT AGE FROM CUSTMR WHERE ID = ?"); verify(preparedStatement).setObject(1, 3); verify(resultSet).close(); @@ -656,7 +672,7 @@ class JdbcClientQueryTests { static class AgeFieldHolder { - public int age; + public BigInteger age; } }