diff --git a/src/main/asciidoc/reference/r2dbc-repositories.adoc b/src/main/asciidoc/reference/r2dbc-repositories.adoc index a5d8ae104..d9a902b83 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 019490cbb..27d80adaf 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 81ae7ed9a..5b1ef36af 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 cf9eec871..a3eaa6090 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 04ce8fe20..8213fddc3 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 a8f43c2f8..0a4ac6b04 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;