From b8f4e8b765bf6408e741b42e4fa21524293e32ee Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Thu, 8 Oct 2020 15:04:14 +0200 Subject: [PATCH] #475 - Refine projection result mapping. We now differentiate the result mapping based on projection type and query type. Previously, all projections used the domain type to map results first and then serve as backend for the projection proxy/DTO creation. We now use direct result to DTO mapping for String-based queries to allow for a greater flexibility when declaring DTO result types. Interface-based projections and derived queries remain using the two-step process of result to domain type mapping and then mapping the domain type into the projection. --- .../reference/r2dbc-repositories.adoc | 15 +++++++ .../repository/query/AbstractR2dbcQuery.java | 16 +++---- .../query/StringBasedR2dbcQuery.java | 7 +++ ...stractR2dbcRepositoryIntegrationTests.java | 33 +++++++++++++- .../query/PartTreeR2dbcQueryUnitTests.java | 45 +++++++++++++++++++ .../query/StringBasedR2dbcQueryUnitTests.java | 28 ++++++++++++ 6 files changed, 134 insertions(+), 10 deletions(-) diff --git a/src/main/asciidoc/reference/r2dbc-repositories.adoc b/src/main/asciidoc/reference/r2dbc-repositories.adoc index a5d8ae1..d9a902b 100644 --- a/src/main/asciidoc/reference/r2dbc-repositories.adoc +++ b/src/main/asciidoc/reference/r2dbc-repositories.adoc @@ -354,6 +354,21 @@ template.update(other).subscribe(); // emits OptimisticLockingFailureException :projection-collection: Flux include::../{spring-data-commons-docs}/repository-projections.adoc[leveloffset=+2] +[[projections.resultmapping]] +==== Result Mapping + +A query method returning an Interface- or DTO projection is backed by results produced by the actual query. +Interface projections generally rely on mapping results onto the domain type first to consider potential `@Column` type mappings and the actual projection proxy uses a potentially partially materialized entity to expose projection data. + +Result mapping for DTO projections depends on the actual query type. +Derived queries use the domain type to map results, and Spring Data creates DTO instances solely from properties available on the domain type. +Declaring properties in your DTO that are not available on the domain type is not supported. + +String-based queries use a different approach since the actual query, specifically the field projection, and result type declaration are close together. +DTO projections used with query methods annotated with `@Query` map query results directly into the DTO type. +Field mappings on the domain type are not considered. +Using the DTO type directly, your query method can benefit from a more dynamic projection that isn't restricted to the domain model. + include::../{spring-data-commons-docs}/entity-callbacks.adoc[leveloffset=+1] include::./r2dbc-entity-callbacks.adoc[leveloffset=+2] diff --git a/src/main/java/org/springframework/data/r2dbc/repository/query/AbstractR2dbcQuery.java b/src/main/java/org/springframework/data/r2dbc/repository/query/AbstractR2dbcQuery.java index 019490c..27d80ad 100644 --- a/src/main/java/org/springframework/data/r2dbc/repository/query/AbstractR2dbcQuery.java +++ b/src/main/java/org/springframework/data/r2dbc/repository/query/AbstractR2dbcQuery.java @@ -103,10 +103,10 @@ public abstract class AbstractR2dbcQuery implements RepositoryQuery { fetchSpec = (FetchSpec) boundQuery.map(row -> true); } else if (requiresMapping()) { - Class resultType = resolveResultType(processor); - EntityRowMapper rowMapper = new EntityRowMapper<>(resultType, converter); + Class typeToRead = resolveResultType(processor); + EntityRowMapper rowMapper = new EntityRowMapper<>(typeToRead, converter); - if (converter.isSimpleType(resultType)) { + if (converter.isSimpleType(typeToRead)) { fetchSpec = new UnwrapOptionalFetchSpecAdapter<>( boundQuery.map((row, rowMetadata) -> Optional.ofNullable(rowMapper.apply(row, rowMetadata)))); @@ -125,17 +125,17 @@ public abstract class AbstractR2dbcQuery implements RepositoryQuery { return execution.execute(fetchSpec, processor.getReturnedType().getDomainType(), tableName); } - private boolean requiresMapping() { - return !isModifyingQuery(); - } - - private Class resolveResultType(ResultProcessor resultProcessor) { + Class resolveResultType(ResultProcessor resultProcessor) { ReturnedType returnedType = resultProcessor.getReturnedType(); return returnedType.isProjecting() ? returnedType.getDomainType() : returnedType.getReturnedType(); } + private boolean requiresMapping() { + return !isModifyingQuery(); + } + private R2dbcQueryExecution getExecutionToWrap(ReturnedType returnedType) { if (isModifyingQuery()) { diff --git a/src/main/java/org/springframework/data/r2dbc/repository/query/StringBasedR2dbcQuery.java b/src/main/java/org/springframework/data/r2dbc/repository/query/StringBasedR2dbcQuery.java index 81ae7ed..5b1ef36 100644 --- a/src/main/java/org/springframework/data/r2dbc/repository/query/StringBasedR2dbcQuery.java +++ b/src/main/java/org/springframework/data/r2dbc/repository/query/StringBasedR2dbcQuery.java @@ -26,6 +26,7 @@ import org.springframework.data.r2dbc.repository.Query; import org.springframework.data.relational.repository.query.RelationalParameterAccessor; import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; import org.springframework.data.repository.query.ReactiveQueryMethodEvaluationContextProvider; +import org.springframework.data.repository.query.ResultProcessor; import org.springframework.data.spel.ExpressionDependencies; import org.springframework.expression.ExpressionParser; import org.springframework.expression.spel.standard.SpelExpressionParser; @@ -156,6 +157,12 @@ public class StringBasedR2dbcQuery extends AbstractR2dbcQuery { }); } + @Override + Class resolveResultType(ResultProcessor resultProcessor) { + + Class returnedType = resultProcessor.getReturnedType().getReturnedType(); + return !returnedType.isInterface() ? returnedType : super.resolveResultType(resultProcessor); + } private Mono getSpelEvaluator(RelationalParameterAccessor accessor) { diff --git a/src/test/java/org/springframework/data/r2dbc/repository/AbstractR2dbcRepositoryIntegrationTests.java b/src/test/java/org/springframework/data/r2dbc/repository/AbstractR2dbcRepositoryIntegrationTests.java index cf9eec8..a3eaa60 100644 --- a/src/test/java/org/springframework/data/r2dbc/repository/AbstractR2dbcRepositoryIntegrationTests.java +++ b/src/test/java/org/springframework/data/r2dbc/repository/AbstractR2dbcRepositoryIntegrationTests.java @@ -22,6 +22,7 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import lombok.Value; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -142,8 +143,8 @@ public abstract class AbstractR2dbcRepositoryIntegrationTests extends R2dbcInteg }).verifyComplete(); } - @Test - void shouldFindApplyingProjection() { + @Test // gh-475 + void shouldFindApplyingInterfaceProjection() { shouldInsertNewItems(); @@ -156,6 +157,20 @@ public abstract class AbstractR2dbcRepositoryIntegrationTests extends R2dbcInteg }).verifyComplete(); } + @Test // gh-475 + void shouldByStringQueryApplyingDtoProjection() { + + shouldInsertNewItems(); + + repository.findAsDtoProjection() // + .map(LegoDto::getName) // + .collectList() // + .as(StepVerifier::create) // + .consumeNextWith(actual -> { + assertThat(actual).contains("SCHAUFELRADBAGGER", "FORSCHUNGSSCHIFF"); + }).verifyComplete(); + } + @Test // gh-344 void shouldFindApplyingDistinctProjection() { @@ -355,6 +370,9 @@ public abstract class AbstractR2dbcRepositoryIntegrationTests extends R2dbcInteg Flux findAsProjection(); + @Query("SELECT name from legoset") + Flux findAsDtoProjection(); + Flux findDistinctBy(); Mono findByManual(int manual); @@ -400,6 +418,17 @@ public abstract class AbstractR2dbcRepositoryIntegrationTests extends R2dbcInteg @Id Integer id; } + @Value + static class LegoDto { + String name; + String unknown; + + public LegoDto(String name, String unknown) { + this.name = name; + this.unknown = unknown; + } + } + interface Named { String getName(); } diff --git a/src/test/java/org/springframework/data/r2dbc/repository/query/PartTreeR2dbcQueryUnitTests.java b/src/test/java/org/springframework/data/r2dbc/repository/query/PartTreeR2dbcQueryUnitTests.java index 04ce8fe..8213fdd 100644 --- a/src/test/java/org/springframework/data/r2dbc/repository/query/PartTreeR2dbcQueryUnitTests.java +++ b/src/test/java/org/springframework/data/r2dbc/repository/query/PartTreeR2dbcQueryUnitTests.java @@ -37,6 +37,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.annotation.Id; import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.data.r2dbc.convert.R2dbcConverter; @@ -621,6 +622,32 @@ class PartTreeR2dbcQueryUnitTests { + ".foo FROM " + TABLE + " WHERE " + TABLE + ".first_name = $1"); } + @Test // gh-475 + void createsQueryToFindByOpenProjection() throws Exception { + + R2dbcQueryMethod queryMethod = getQueryMethod("findOpenProjectionBy"); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + BindableQuery bindableQuery = createQuery(queryMethod, r2dbcQuery); + + assertThat(bindableQuery.get()).isEqualTo( + "SELECT users.id, users.first_name, users.last_name, users.date_of_birth, users.age, users.active FROM " + + TABLE); + } + + @Test // gh-475 + void createsDtoProjectionQuery() throws Exception { + + R2dbcQueryMethod queryMethod = getQueryMethod("findAsDtoProjectionBy"); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + BindableQuery bindableQuery = createQuery(queryMethod, r2dbcQuery); + + assertThat(bindableQuery.get()).isEqualTo( + "SELECT users.id, users.first_name, users.last_name, users.date_of_birth, users.age, users.active FROM " + + TABLE); + } + @Test // gh-363 void createsQueryForCountProjection() throws Exception { @@ -721,6 +748,10 @@ class PartTreeR2dbcQueryUnitTests { Mono findDistinctByFirstName(String firstName); + Mono findOpenProjectionBy(); + + Mono findAsDtoProjectionBy(); + Mono deleteByFirstName(String firstName); Mono countByFirstName(String firstName); @@ -744,4 +775,18 @@ class PartTreeR2dbcQueryUnitTests { String getFoo(); } + + interface OpenUserProjection { + + String getFirstName(); + + @Value("#firstName") + String getFoo(); + } + + static class UserDtoProjection { + + String firstName; + String unknown; + } } diff --git a/src/test/java/org/springframework/data/r2dbc/repository/query/StringBasedR2dbcQueryUnitTests.java b/src/test/java/org/springframework/data/r2dbc/repository/query/StringBasedR2dbcQueryUnitTests.java index a8f43c2..0a4ac6b 100644 --- a/src/test/java/org/springframework/data/r2dbc/repository/query/StringBasedR2dbcQueryUnitTests.java +++ b/src/test/java/org/springframework/data/r2dbc/repository/query/StringBasedR2dbcQueryUnitTests.java @@ -258,6 +258,22 @@ public class StringBasedR2dbcQueryUnitTests { verifyNoMoreInteractions(bindSpec); } + @Test // gh-475 + void usesDomainTypeForInterfaceProjectionResultMapping() { + + StringBasedR2dbcQuery query = getQueryMethod("findAsInterfaceProjection"); + + assertThat(query.resolveResultType(query.getQueryMethod().getResultProcessor())).isEqualTo(Person.class); + } + + @Test // gh-475 + void usesDtoTypeForDtoResultMapping() { + + StringBasedR2dbcQuery query = getQueryMethod("findAsDtoProjection"); + + assertThat(query.resolveResultType(query.getQueryMethod().getResultProcessor())).isEqualTo(PersonDto.class); + } + private StringBasedR2dbcQuery getQueryMethod(String name, Class... args) { Method method = ReflectionUtils.findMethod(SampleRepository.class, name, args); @@ -306,8 +322,20 @@ public class StringBasedR2dbcQueryUnitTests { @Query("SELECT * FROM person WHERE lastname = :name") Person queryWithEnum(MyEnum myEnum); + + @Query("SELECT * FROM person") + PersonDto findAsDtoProjection(); + + @Query("SELECT * FROM person") + PersonProjection findAsInterfaceProjection(); } + static class PersonDto { + + } + + interface PersonProjection {} + static class Person { String name;