Skip to content

Commit b8f4e8b

Browse files
committed
#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.
1 parent c00184b commit b8f4e8b

File tree

6 files changed

+134
-10
lines changed

6 files changed

+134
-10
lines changed

src/main/asciidoc/reference/r2dbc-repositories.adoc

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,21 @@ template.update(other).subscribe(); // emits OptimisticLockingFailureException
354354
:projection-collection: Flux
355355
include::../{spring-data-commons-docs}/repository-projections.adoc[leveloffset=+2]
356356

357+
[[projections.resultmapping]]
358+
==== Result Mapping
359+
360+
A query method returning an Interface- or DTO projection is backed by results produced by the actual query.
361+
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.
362+
363+
Result mapping for DTO projections depends on the actual query type.
364+
Derived queries use the domain type to map results, and Spring Data creates DTO instances solely from properties available on the domain type.
365+
Declaring properties in your DTO that are not available on the domain type is not supported.
366+
367+
String-based queries use a different approach since the actual query, specifically the field projection, and result type declaration are close together.
368+
DTO projections used with query methods annotated with `@Query` map query results directly into the DTO type.
369+
Field mappings on the domain type are not considered.
370+
Using the DTO type directly, your query method can benefit from a more dynamic projection that isn't restricted to the domain model.
371+
357372
include::../{spring-data-commons-docs}/entity-callbacks.adoc[leveloffset=+1]
358373
include::./r2dbc-entity-callbacks.adoc[leveloffset=+2]
359374

src/main/java/org/springframework/data/r2dbc/repository/query/AbstractR2dbcQuery.java

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,10 @@ private Publisher<?> executeQuery(RelationalParameterAccessor parameterAccessor,
103103
fetchSpec = (FetchSpec) boundQuery.map(row -> true);
104104
} else if (requiresMapping()) {
105105

106-
Class<?> resultType = resolveResultType(processor);
107-
EntityRowMapper rowMapper = new EntityRowMapper<>(resultType, converter);
106+
Class<?> typeToRead = resolveResultType(processor);
107+
EntityRowMapper rowMapper = new EntityRowMapper<>(typeToRead, converter);
108108

109-
if (converter.isSimpleType(resultType)) {
109+
if (converter.isSimpleType(typeToRead)) {
110110
fetchSpec = new UnwrapOptionalFetchSpecAdapter<>(
111111
boundQuery.map((row, rowMetadata) -> Optional.ofNullable(rowMapper.apply(row, rowMetadata))));
112112

@@ -125,17 +125,17 @@ private Publisher<?> executeQuery(RelationalParameterAccessor parameterAccessor,
125125
return execution.execute(fetchSpec, processor.getReturnedType().getDomainType(), tableName);
126126
}
127127

128-
private boolean requiresMapping() {
129-
return !isModifyingQuery();
130-
}
131-
132-
private Class<?> resolveResultType(ResultProcessor resultProcessor) {
128+
Class<?> resolveResultType(ResultProcessor resultProcessor) {
133129

134130
ReturnedType returnedType = resultProcessor.getReturnedType();
135131

136132
return returnedType.isProjecting() ? returnedType.getDomainType() : returnedType.getReturnedType();
137133
}
138134

135+
private boolean requiresMapping() {
136+
return !isModifyingQuery();
137+
}
138+
139139
private R2dbcQueryExecution getExecutionToWrap(ReturnedType returnedType) {
140140

141141
if (isModifyingQuery()) {

src/main/java/org/springframework/data/r2dbc/repository/query/StringBasedR2dbcQuery.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import org.springframework.data.relational.repository.query.RelationalParameterAccessor;
2727
import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
2828
import org.springframework.data.repository.query.ReactiveQueryMethodEvaluationContextProvider;
29+
import org.springframework.data.repository.query.ResultProcessor;
2930
import org.springframework.data.spel.ExpressionDependencies;
3031
import org.springframework.expression.ExpressionParser;
3132
import org.springframework.expression.spel.standard.SpelExpressionParser;
@@ -156,6 +157,12 @@ public String get() {
156157
});
157158
}
158159

160+
@Override
161+
Class<?> resolveResultType(ResultProcessor resultProcessor) {
162+
163+
Class<?> returnedType = resultProcessor.getReturnedType().getReturnedType();
164+
return !returnedType.isInterface() ? returnedType : super.resolveResultType(resultProcessor);
165+
}
159166

160167
private Mono<R2dbcSpELExpressionEvaluator> getSpelEvaluator(RelationalParameterAccessor accessor) {
161168

src/test/java/org/springframework/data/r2dbc/repository/AbstractR2dbcRepositoryIntegrationTests.java

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import lombok.Getter;
2323
import lombok.NoArgsConstructor;
2424
import lombok.Setter;
25+
import lombok.Value;
2526
import reactor.core.publisher.Flux;
2627
import reactor.core.publisher.Mono;
2728
import reactor.test.StepVerifier;
@@ -142,8 +143,8 @@ void shouldFindItemsByNameLike() {
142143
}).verifyComplete();
143144
}
144145

145-
@Test
146-
void shouldFindApplyingProjection() {
146+
@Test // gh-475
147+
void shouldFindApplyingInterfaceProjection() {
147148

148149
shouldInsertNewItems();
149150

@@ -156,6 +157,20 @@ void shouldFindApplyingProjection() {
156157
}).verifyComplete();
157158
}
158159

160+
@Test // gh-475
161+
void shouldByStringQueryApplyingDtoProjection() {
162+
163+
shouldInsertNewItems();
164+
165+
repository.findAsDtoProjection() //
166+
.map(LegoDto::getName) //
167+
.collectList() //
168+
.as(StepVerifier::create) //
169+
.consumeNextWith(actual -> {
170+
assertThat(actual).contains("SCHAUFELRADBAGGER", "FORSCHUNGSSCHIFF");
171+
}).verifyComplete();
172+
}
173+
159174
@Test // gh-344
160175
void shouldFindApplyingDistinctProjection() {
161176

@@ -355,6 +370,9 @@ interface LegoSetRepository extends ReactiveCrudRepository<LegoSet, Integer> {
355370

356371
Flux<Named> findAsProjection();
357372

373+
@Query("SELECT name from legoset")
374+
Flux<LegoDto> findAsDtoProjection();
375+
358376
Flux<Named> findDistinctBy();
359377

360378
Mono<LegoSet> findByManual(int manual);
@@ -400,6 +418,17 @@ static class Lego {
400418
@Id Integer id;
401419
}
402420

421+
@Value
422+
static class LegoDto {
423+
String name;
424+
String unknown;
425+
426+
public LegoDto(String name, String unknown) {
427+
this.name = name;
428+
this.unknown = unknown;
429+
}
430+
}
431+
403432
interface Named {
404433
String getName();
405434
}

src/test/java/org/springframework/data/r2dbc/repository/query/PartTreeR2dbcQueryUnitTests.java

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import org.mockito.junit.jupiter.MockitoSettings;
3838
import org.mockito.quality.Strictness;
3939

40+
import org.springframework.beans.factory.annotation.Value;
4041
import org.springframework.data.annotation.Id;
4142
import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
4243
import org.springframework.data.r2dbc.convert.R2dbcConverter;
@@ -621,6 +622,32 @@ void createsQueryToFindAllEntitiesByStringAttributeWithDistinct() throws Excepti
621622
+ ".foo FROM " + TABLE + " WHERE " + TABLE + ".first_name = $1");
622623
}
623624

625+
@Test // gh-475
626+
void createsQueryToFindByOpenProjection() throws Exception {
627+
628+
R2dbcQueryMethod queryMethod = getQueryMethod("findOpenProjectionBy");
629+
PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter,
630+
dataAccessStrategy);
631+
BindableQuery bindableQuery = createQuery(queryMethod, r2dbcQuery);
632+
633+
assertThat(bindableQuery.get()).isEqualTo(
634+
"SELECT users.id, users.first_name, users.last_name, users.date_of_birth, users.age, users.active FROM "
635+
+ TABLE);
636+
}
637+
638+
@Test // gh-475
639+
void createsDtoProjectionQuery() throws Exception {
640+
641+
R2dbcQueryMethod queryMethod = getQueryMethod("findAsDtoProjectionBy");
642+
PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter,
643+
dataAccessStrategy);
644+
BindableQuery bindableQuery = createQuery(queryMethod, r2dbcQuery);
645+
646+
assertThat(bindableQuery.get()).isEqualTo(
647+
"SELECT users.id, users.first_name, users.last_name, users.date_of_birth, users.age, users.active FROM "
648+
+ TABLE);
649+
}
650+
624651
@Test // gh-363
625652
void createsQueryForCountProjection() throws Exception {
626653

@@ -721,6 +748,10 @@ interface UserRepository extends Repository<User, Long> {
721748

722749
Mono<UserProjection> findDistinctByFirstName(String firstName);
723750

751+
Mono<OpenUserProjection> findOpenProjectionBy();
752+
753+
Mono<UserDtoProjection> findAsDtoProjectionBy();
754+
724755
Mono<Integer> deleteByFirstName(String firstName);
725756

726757
Mono<Long> countByFirstName(String firstName);
@@ -744,4 +775,18 @@ interface UserProjection {
744775

745776
String getFoo();
746777
}
778+
779+
interface OpenUserProjection {
780+
781+
String getFirstName();
782+
783+
@Value("#firstName")
784+
String getFoo();
785+
}
786+
787+
static class UserDtoProjection {
788+
789+
String firstName;
790+
String unknown;
791+
}
747792
}

src/test/java/org/springframework/data/r2dbc/repository/query/StringBasedR2dbcQueryUnitTests.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,22 @@ void translatesEnumToDatabaseValue() {
258258
verifyNoMoreInteractions(bindSpec);
259259
}
260260

261+
@Test // gh-475
262+
void usesDomainTypeForInterfaceProjectionResultMapping() {
263+
264+
StringBasedR2dbcQuery query = getQueryMethod("findAsInterfaceProjection");
265+
266+
assertThat(query.resolveResultType(query.getQueryMethod().getResultProcessor())).isEqualTo(Person.class);
267+
}
268+
269+
@Test // gh-475
270+
void usesDtoTypeForDtoResultMapping() {
271+
272+
StringBasedR2dbcQuery query = getQueryMethod("findAsDtoProjection");
273+
274+
assertThat(query.resolveResultType(query.getQueryMethod().getResultProcessor())).isEqualTo(PersonDto.class);
275+
}
276+
261277
private StringBasedR2dbcQuery getQueryMethod(String name, Class<?>... args) {
262278

263279
Method method = ReflectionUtils.findMethod(SampleRepository.class, name, args);
@@ -306,8 +322,20 @@ private interface SampleRepository extends Repository<Person, String> {
306322

307323
@Query("SELECT * FROM person WHERE lastname = :name")
308324
Person queryWithEnum(MyEnum myEnum);
325+
326+
@Query("SELECT * FROM person")
327+
PersonDto findAsDtoProjection();
328+
329+
@Query("SELECT * FROM person")
330+
PersonProjection findAsInterfaceProjection();
309331
}
310332

333+
static class PersonDto {
334+
335+
}
336+
337+
interface PersonProjection {}
338+
311339
static class Person {
312340

313341
String name;

0 commit comments

Comments
 (0)