DATAJDBC-166 - Allow registration of RowMappers based on result type.

RowMapper can be configured either via the @Query(rowMapperClass = …​.) or by registerign a RowMapperMap bean and register RowMapper per method return type.

                @Bean
                RowMapperMap rowMappers() {
                        return new ConfigurableRowMapperMap() //
                                        .register(Person.class, new PersonRowMapper()) //
                                        .register(Address.class, new AddressRowMapper());
                }

When determining the RowMapper to use for a method the following steps are followed based on the return type of the method:

1. If the type is a simple type no RowMapper is used. Instead the query is expected to return a single row with a single column and a conversion to the return type is applied to that value.

2. The entity classes in the RowMapperMap are iterated until one is found that is a superclass or interface of the return type in question. The RowMapper registered for that class is used. Iterating happens in the order of registration, so make sure to register more general types after specific ones.

If applicable wrapper type like collections or Optional are unwrapped. So a return type of Optional<Person> will use the type Person in the steps above.
This commit is contained in:
Jens Schauder
2018-01-17 13:13:29 +01:00
committed by Greg Turnquist
parent b49b7674f2
commit a2b7699256
12 changed files with 405 additions and 18 deletions

View File

@@ -124,6 +124,34 @@ List<DummyEntity> findByNameRange(@Param("lower") String lower, @Param("upper")
If you compile your sources with the `-parameters` compiler flag you can omit the `@Param` annotations.
==== Custom RowMapper
You can configure the `RowMapper` to use using either the `@Query(rowMapperClass = ....)` or you can register a `RowMapperMap` bean and register `RowMapper` per method return type.
[source,java]
----
@Bean
RowMapperMap rowMappers() {
return new ConfigurableRowMapperMap() //
.register(Person.class, new PersonRowMapper()) //
.register(Address.class, new AddressRowMapper());
}
----
When determining the `RowMapper` to use for a method the following steps are followed based on the return type of the method:
1. If the type is a simple type no `RowMapper` is used.
Instead the query is expected to return a single row with a single column and a conversion to the return type is applied to that value.
2. The entity classes in the `RowMapperMap` are iterated until one is found that is a superclass or interface of the return type in question.
The `RowMapper` registered for that class is used.
Iterating happens in the order of registration, so make sure to register more general types after specific ones.
If applicable wrapper type like collections or `Optional` are unwrapped.
So a return type of `Optional<Person>` will use the type `Person` in the steps above.
=== Id generation
Spring Data JDBC uses the id to identify entities but also to determine if an entity is new or already existing in the database.

View File

@@ -283,7 +283,7 @@ public class DefaultDataAccessStrategy implements DataAccessStrategy {
}
public <T> EntityRowMapper<T> getEntityRowMapper(Class<T> domainType) {
return new EntityRowMapper<>(getRequiredPersistentEntity(domainType), context.getConversions(), context, accessStrategy);
return new EntityRowMapper<>(getRequiredPersistentEntity(domainType), context, accessStrategy);
}
private RowMapper getMapEntityRowMapper(JdbcPersistentProperty property) {

View File

@@ -53,11 +53,11 @@ public class EntityRowMapper<T> implements RowMapper<T> {
private final DataAccessStrategy accessStrategy;
private final JdbcPersistentProperty idProperty;
public EntityRowMapper(JdbcPersistentEntity<T> entity, ConversionService conversions, JdbcMappingContext context,
DataAccessStrategy accessStrategy) {
public EntityRowMapper(JdbcPersistentEntity<T> entity, JdbcMappingContext context,
DataAccessStrategy accessStrategy) {
this.entity = entity;
this.conversions = conversions;
this.conversions = context.getConversions();
this.context = context;
this.accessStrategy = accessStrategy;

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2018 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.jdbc.repository;
import org.springframework.jdbc.core.RowMapper;
/**
* A map from a type to a {@link RowMapper} to be used for extracting that type from {@link java.sql.ResultSet}s.
*
* @author Jens Schauder
*/
public interface RowMapperMap {
/**
* An immutable empty instance that will return {@literal null} for all arguments.
*/
RowMapperMap EMPTY = new RowMapperMap() {
public <T> RowMapper<? extends T> rowMapperFor(Class<T> type) {
return null;
}
};
<T> RowMapper<? extends T> rowMapperFor(Class<T> type);
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright 2018 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.jdbc.repository.config;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.data.jdbc.repository.RowMapperMap;
import org.springframework.jdbc.core.RowMapper;
/**
* A {@link RowMapperMap} that allows for registration of {@link RowMapper}s via a fluent Api.
*
* @author Jens Schauder
*/
public class ConfigurableRowMapperMap implements RowMapperMap {
private Map<Class<?>, RowMapper<?>> rowMappers = new LinkedHashMap<>();
/**
* Registers a the given {@link RowMapper} as to be used for the given type.
*
* @return this instance, so this can be used as a fluent interface.
*/
public <T> ConfigurableRowMapperMap register(Class<T> type, RowMapper<? extends T> rowMapper) {
rowMappers.put(type, rowMapper);
return this;
}
@SuppressWarnings("unchecked")
public <T> RowMapper<? extends T> rowMapperFor(Class<T> type) {
RowMapper<? extends T> candidate = (RowMapper<? extends T>) rowMappers.get(type);
if (candidate == null) {
for (Map.Entry<Class<?>, RowMapper<?>> entry : rowMappers.entrySet()) {
if (type.isAssignableFrom(entry.getKey())) {
candidate = (RowMapper<? extends T>) entry.getValue();
}
}
}
return candidate;
}
}

View File

@@ -21,6 +21,7 @@ import org.springframework.core.convert.ConversionService;
import org.springframework.data.jdbc.core.DataAccessStrategy;
import org.springframework.data.jdbc.core.EntityRowMapper;
import org.springframework.data.jdbc.mapping.model.JdbcMappingContext;
import org.springframework.data.jdbc.repository.RowMapperMap;
import org.springframework.data.projection.ProjectionFactory;
import org.springframework.data.repository.core.NamedQueries;
import org.springframework.data.repository.core.RepositoryMetadata;
@@ -40,13 +41,15 @@ class JdbcQueryLookupStrategy implements QueryLookupStrategy {
private final JdbcMappingContext context;
private final DataAccessStrategy accessStrategy;
private final RowMapperMap rowMapperMap;
private final ConversionService conversionService;
JdbcQueryLookupStrategy(EvaluationContextProvider evaluationContextProvider, JdbcMappingContext context,
DataAccessStrategy accessStrategy) {
DataAccessStrategy accessStrategy, RowMapperMap rowMapperMap) {
this.context = context;
this.accessStrategy = accessStrategy;
this.rowMapperMap = rowMapperMap;
this.conversionService = context.getConversions();
}
@@ -55,22 +58,32 @@ class JdbcQueryLookupStrategy implements QueryLookupStrategy {
ProjectionFactory projectionFactory, NamedQueries namedQueries) {
JdbcQueryMethod queryMethod = new JdbcQueryMethod(method, repositoryMetadata, projectionFactory);
Class<?> returnedObjectType = queryMethod.getReturnedObjectType();
RowMapper<?> rowMapper = queryMethod.isModifyingQuery() ? null : createRowMapper(returnedObjectType);
RowMapper<?> rowMapper = queryMethod.isModifyingQuery() ? null : createRowMapper(queryMethod);
return new JdbcRepositoryQuery(queryMethod, context, rowMapper);
}
private RowMapper<?> createRowMapper(Class<?> returnedObjectType) {
private RowMapper<?> createRowMapper(JdbcQueryMethod queryMethod) {
Class<?> returnedObjectType = queryMethod.getReturnedObjectType();
return context.getSimpleTypeHolder().isSimpleType(returnedObjectType)
? SingleColumnRowMapper.newInstance(returnedObjectType, conversionService)
: new EntityRowMapper<>( //
context.getRequiredPersistentEntity(returnedObjectType), //
conversionService, //
: determineDefaultRowMapper(queryMethod);
}
private RowMapper<?> determineDefaultRowMapper(JdbcQueryMethod queryMethod) {
Class<?> domainType = queryMethod.getReturnedObjectType();
RowMapper typeMappedRowMapper = rowMapperMap.rowMapperFor(domainType);
return typeMappedRowMapper == null //
? new EntityRowMapper<>( //
context.getRequiredPersistentEntity(domainType), //
context, //
accessStrategy //
);
accessStrategy) //
: typeMappedRowMapper;
}
}

View File

@@ -22,6 +22,7 @@ import org.springframework.data.jdbc.core.DataAccessStrategy;
import org.springframework.data.jdbc.core.JdbcEntityTemplate;
import org.springframework.data.jdbc.mapping.model.JdbcMappingContext;
import org.springframework.data.jdbc.mapping.model.JdbcPersistentEntityInformation;
import org.springframework.data.jdbc.repository.RowMapperMap;
import org.springframework.data.jdbc.repository.SimpleJdbcRepository;
import org.springframework.data.repository.core.EntityInformation;
import org.springframework.data.repository.core.RepositoryInformation;
@@ -42,6 +43,7 @@ public class JdbcRepositoryFactory extends RepositoryFactorySupport {
private final JdbcMappingContext context;
private final ApplicationEventPublisher publisher;
private final DataAccessStrategy accessStrategy;
private RowMapperMap rowMapperMap = RowMapperMap.EMPTY;
public JdbcRepositoryFactory(ApplicationEventPublisher publisher, JdbcMappingContext context,
DataAccessStrategy dataAccessStrategy) {
@@ -84,6 +86,10 @@ public class JdbcRepositoryFactory extends RepositoryFactorySupport {
throw new IllegalArgumentException(String.format("Unsupported query lookup strategy %s!", key));
}
return Optional.of(new JdbcQueryLookupStrategy(evaluationContextProvider, context, accessStrategy));
return Optional.of(new JdbcQueryLookupStrategy(evaluationContextProvider, context, accessStrategy, rowMapperMap));
}
public void setRowMapperMap(RowMapperMap rowMapperMap) {
this.rowMapperMap = rowMapperMap;
}
}

View File

@@ -22,6 +22,7 @@ import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.ApplicationEventPublisherAware;
import org.springframework.data.jdbc.core.DataAccessStrategy;
import org.springframework.data.jdbc.mapping.model.JdbcMappingContext;
import org.springframework.data.jdbc.repository.RowMapperMap;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.core.support.RepositoryFactorySupport;
import org.springframework.data.repository.core.support.TransactionalRepositoryFactoryBeanSupport;
@@ -41,6 +42,7 @@ public class JdbcRepositoryFactoryBean<T extends Repository<S, ID>, S, ID extend
private ApplicationEventPublisher publisher;
private JdbcMappingContext mappingContext;
private DataAccessStrategy dataAccessStrategy;
private RowMapperMap rowMapperMap = RowMapperMap.EMPTY;
JdbcRepositoryFactoryBean(Class<? extends T> repositoryInterface) {
super(repositoryInterface);
@@ -60,7 +62,15 @@ public class JdbcRepositoryFactoryBean<T extends Repository<S, ID>, S, ID extend
*/
@Override
protected RepositoryFactorySupport doCreateRepositoryFactory() {
return new JdbcRepositoryFactory(publisher, mappingContext, dataAccessStrategy);
JdbcRepositoryFactory jdbcRepositoryFactory = new JdbcRepositoryFactory(publisher, mappingContext,
dataAccessStrategy);
if (rowMapperMap != null) {
jdbcRepositoryFactory.setRowMapperMap(rowMapperMap);
}
return jdbcRepositoryFactory;
}
@Autowired
@@ -75,6 +85,11 @@ public class JdbcRepositoryFactoryBean<T extends Repository<S, ID>, S, ID extend
this.dataAccessStrategy = dataAccessStrategy;
}
@Autowired(required = false)
public void setRowMapperMap(RowMapperMap rowMapperMap) {
this.rowMapperMap = rowMapperMap;
}
@Override
public void afterPropertiesSet() {

View File

@@ -28,7 +28,6 @@ import org.springframework.data.jdbc.mapping.model.JdbcMappingContext;
import org.springframework.data.jdbc.mapping.model.JdbcPersistentEntity;
import org.springframework.data.jdbc.mapping.model.JdbcPersistentProperty;
import org.springframework.data.jdbc.mapping.model.NamingStrategy;
import org.springframework.data.repository.query.Param;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations;
import org.springframework.util.Assert;
@@ -204,7 +203,7 @@ public class EntityRowMapperUnitTests {
Jsr310Converters.getConvertersToRegister().forEach(conversionService::addConverter);
return new EntityRowMapper<>((JdbcPersistentEntity<T>) context.getRequiredPersistentEntity(type),
conversionService, context, accessStrategy);
context, accessStrategy);
}
private static ResultSet mockResultSet(List<String> columns, Object... values) {

View File

@@ -0,0 +1,95 @@
/*
* Copyright 2018 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.jdbc.repository.config;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.Test;
import org.springframework.data.jdbc.repository.RowMapperMap;
import org.springframework.jdbc.core.RowMapper;
/**
* Unit tests for {@link ConfigurableRowMapperMap}.
*
* @author Jens Schauder
*/
public class ConfigurableRowMapperMapUnitTests {
@Test
public void freshInstanceReturnsNull() {
RowMapperMap map = new ConfigurableRowMapperMap();
assertThat(map.rowMapperFor(Object.class)).isNull();
}
@Test
public void returnsConfiguredInstanceForClass() {
RowMapper rowMapper = mock(RowMapper.class);
RowMapperMap map = new ConfigurableRowMapperMap().register(Object.class, rowMapper);
assertThat(map.rowMapperFor(Object.class)).isEqualTo(rowMapper);
}
@Test
public void returnsNullForClassNotConfigured() {
RowMapper rowMapper = mock(RowMapper.class);
RowMapperMap map = new ConfigurableRowMapperMap().register(Number.class, rowMapper);
assertThat(map.rowMapperFor(Integer.class)).isNull();
assertThat(map.rowMapperFor(String.class)).isNull();
}
@Test
public void returnsInstanceRegisteredForSubClass() {
RowMapper rowMapper = mock(RowMapper.class);
RowMapperMap map = new ConfigurableRowMapperMap().register(String.class, rowMapper);
assertThat(map.rowMapperFor(Object.class)).isEqualTo(rowMapper);
}
@Test
public void prefersExactTypeMatchClass() {
RowMapper rowMapper = mock(RowMapper.class);
RowMapperMap map = new ConfigurableRowMapperMap() //
.register(Object.class, mock(RowMapper.class)) //
.register(Integer.class, rowMapper) //
.register(Number.class, mock(RowMapper.class));
assertThat(map.rowMapperFor(Integer.class)).isEqualTo(rowMapper);
}
@Test
public void prefersLatestRegistrationForSuperTypeMatch() {
RowMapper rowMapper = mock(RowMapper.class);
RowMapperMap map = new ConfigurableRowMapperMap() //
.register(Integer.class, mock(RowMapper.class)) //
.register(Number.class, rowMapper);
assertThat(map.rowMapperFor(Object.class)).isEqualTo(rowMapper);
}
}

View File

@@ -15,20 +15,28 @@
*/
package org.springframework.data.jdbc.repository.config;
import static org.junit.Assert.assertNotNull;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import lombok.Data;
import java.lang.reflect.Field;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.data.annotation.Id;
import org.springframework.data.jdbc.repository.RowMapperMap;
import org.springframework.data.jdbc.repository.config.EnableJdbcRepositoriesIntegrationTests.TestConfiguration;
import org.springframework.data.jdbc.repository.support.JdbcRepositoryFactoryBean;
import org.springframework.data.repository.CrudRepository;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.util.ReflectionUtils;
/**
* Tests the {@link EnableJdbcRepositories} annotation.
@@ -40,8 +48,18 @@ import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
@ContextConfiguration(classes = TestConfiguration.class)
public class EnableJdbcRepositoriesIntegrationTests {
static final Field ROW_MAPPER_MAP = ReflectionUtils.findField(JdbcRepositoryFactoryBean.class, "rowMapperMap");
public static final RowMapper DUMMY_ENTITY_ROW_MAPPER = mock(RowMapper.class);
public static final RowMapper STRING_ROW_MAPPER = mock(RowMapper.class);
@Autowired JdbcRepositoryFactoryBean factoryBean;
@Autowired DummyRepository repository;
@BeforeClass
public static void setup() {
ROW_MAPPER_MAP.setAccessible(true);
}
@Test // DATAJDBC-100
public void repositoryGetsPickedUp() {
@@ -52,6 +70,15 @@ public class EnableJdbcRepositoriesIntegrationTests {
assertNotNull(all);
}
@Test // DATAJDBC-166
public void customRowMapperConfigurationGetsPickedUp() {
RowMapperMap mapping = (RowMapperMap) ReflectionUtils.getField(ROW_MAPPER_MAP, factoryBean);
assertThat(mapping.rowMapperFor(String.class)).isEqualTo(STRING_ROW_MAPPER);
assertThat(mapping.rowMapperFor(DummyEntity.class)).isEqualTo(DUMMY_ENTITY_ROW_MAPPER);
}
interface DummyRepository extends CrudRepository<DummyEntity, Long> {
}
@@ -69,5 +96,13 @@ public class EnableJdbcRepositoriesIntegrationTests {
Class<?> testClass() {
return EnableJdbcRepositoriesIntegrationTests.class;
}
@Bean
RowMapperMap rowMappers() {
return new ConfigurableRowMapperMap() //
.register(DummyEntity.class, DUMMY_ENTITY_ROW_MAPPER) //
.register(String.class, STRING_ROW_MAPPER);
}
}
}

View File

@@ -0,0 +1,97 @@
/*
* Copyright 2018 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.jdbc.repository.support;
import static org.mockito.Mockito.*;
import java.lang.reflect.Method;
import java.text.NumberFormat;
import org.junit.Before;
import org.junit.Test;
import org.springframework.data.jdbc.core.DataAccessStrategy;
import org.springframework.data.jdbc.mapping.model.JdbcMappingContext;
import org.springframework.data.jdbc.repository.RowMapperMap;
import org.springframework.data.jdbc.repository.config.ConfigurableRowMapperMap;
import org.springframework.data.jdbc.repository.query.Query;
import org.springframework.data.projection.ProjectionFactory;
import org.springframework.data.repository.core.NamedQueries;
import org.springframework.data.repository.core.RepositoryMetadata;
import org.springframework.data.repository.query.EvaluationContextProvider;
import org.springframework.data.repository.query.RepositoryQuery;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
/**
* Unit tests for {@link JdbcQueryLookupStrategy}.
*
* @author Jens Schauder
*/
public class JdbcQueryLookupStrategyUnitTests {
EvaluationContextProvider evaluationContextProvider = mock(EvaluationContextProvider.class);
JdbcMappingContext mappingContext = mock(JdbcMappingContext.class, RETURNS_DEEP_STUBS);
DataAccessStrategy accessStrategy = mock(DataAccessStrategy.class);
ProjectionFactory projectionFactory = mock(ProjectionFactory.class);
RepositoryMetadata metadata;
NamedQueries namedQueries = mock(NamedQueries.class);
@Before
public void setup() {
metadata = mock(RepositoryMetadata.class);
when(metadata.getReturnedDomainClass(any(Method.class))).thenReturn((Class) NumberFormat.class);
}
private Method getMethod(String name) {
try {
return this.getClass().getDeclaredMethod(name);
} catch (NoSuchMethodException e) {
throw new RuntimeException(e);
}
}
@Test // DATAJDBC-166
public void typeBasedRowMapperGetsUsedForQuery() {
RowMapper numberFormatMapper = mock(RowMapper.class);
RowMapperMap rowMapperMap = new ConfigurableRowMapperMap().register(NumberFormat.class, numberFormatMapper);
RepositoryQuery repositoryQuery = getRepositoryQuery("returningNumberFormat", rowMapperMap);
repositoryQuery.execute(new Object[] {});
verify(mappingContext.getTemplate()).queryForObject(anyString(), any(SqlParameterSource.class),
eq(numberFormatMapper));
}
private RepositoryQuery getRepositoryQuery(String name, RowMapperMap rowMapperMap) {
JdbcQueryLookupStrategy queryLookupStrategy = new JdbcQueryLookupStrategy(evaluationContextProvider, mappingContext,
accessStrategy, rowMapperMap);
return queryLookupStrategy.resolveQuery(getMethod(name), metadata, projectionFactory, namedQueries);
}
// NumberFormat is just used as an arbitrary non simple type.
@Query("some SQL")
private NumberFormat returningNumberFormat() {
return null;
}
}