Skip to content

Commit 0483e44

Browse files
christophstroblmp911de
authored andcommitted
Polishing.
There's a difference in what the query needs to look like using dto vs. interface projections where the former does not allow column aliases and the latter requires them. See #2327
1 parent 1a532b8 commit 0483e44

File tree

7 files changed

+190
-17
lines changed

7 files changed

+190
-17
lines changed

spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java

+21
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
import jakarta.persistence.TupleElement;
2424
import jakarta.persistence.TypedQuery;
2525

26+
import java.lang.reflect.InvocationTargetException;
27+
import java.util.ArrayList;
2628
import java.util.Arrays;
2729
import java.util.Collection;
2830
import java.util.HashMap;
@@ -51,6 +53,7 @@
5153
import org.springframework.jdbc.support.JdbcUtils;
5254
import org.springframework.lang.Nullable;
5355
import org.springframework.util.Assert;
56+
import org.springframework.util.ClassUtils;
5457

5558
/**
5659
* Abstract base class to implement {@link RepositoryQuery}s.
@@ -353,6 +356,24 @@ public Object convert(Object source) {
353356
}
354357
}
355358

359+
if(type.isProjecting() && !type.getReturnedType().isInterface() && !type.getInputProperties().isEmpty()) {
360+
List<Object> ctorArgs = new ArrayList<>(type.getInputProperties().size());
361+
type.getInputProperties().forEach(it -> {
362+
ctorArgs.add(tuple.get(it));
363+
});
364+
try {
365+
return type.getReturnedType().getConstructor(ctorArgs.stream().map(Object::getClass).toArray(Class<?>[]::new)).newInstance(ctorArgs.toArray());
366+
} catch (InstantiationException e) {
367+
throw new RuntimeException(e);
368+
} catch (IllegalAccessException e) {
369+
throw new RuntimeException(e);
370+
} catch (InvocationTargetException e) {
371+
throw new RuntimeException(e);
372+
} catch (NoSuchMethodException e) {
373+
throw new RuntimeException(e);
374+
}
375+
}
376+
356377
return new TupleBackedMap(tupleWrapper.apply(tuple));
357378
}
358379

spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java

+11-1
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,13 @@ public Query doCreateQuery(JpaParametersParameterAccessor accessor) {
119119

120120
Sort sort = accessor.getSort();
121121
ResultProcessor processor = getQueryMethod().getResultProcessor().withDynamicProjection(accessor);
122-
String sortedQueryString = getSortedQueryString(sort, processor.getReturnedType());
122+
123+
String sortedQueryString = null;
124+
if(querySortRewriter.equals(NoOpQuerySortRewriter.INSTANCE) && accessor.findDynamicProjection() != null && !accessor.findDynamicProjection().isInterface()) {
125+
sortedQueryString = getSortedQueryString(new ProjectingSortRewriter(), query, sort, processor.getReturnedType());
126+
} else {
127+
sortedQueryString = getSortedQueryString(sort, processor.getReturnedType());
128+
}
123129

124130
Query query = createJpaQuery(sortedQueryString, sort, accessor.getPageable(), processor.getReturnedType());
125131

@@ -134,6 +140,10 @@ String getSortedQueryString(Sort sort, ReturnedType returnedType) {
134140
return querySortRewriter.getSorted(query, sort, returnedType);
135141
}
136142

143+
private static String getSortedQueryString(QuerySortRewriter rewriter, DeclaredQuery query, Sort sort, ReturnedType returnedType) {
144+
return rewriter.getSorted(query, sort, returnedType);
145+
}
146+
137147
@Override
138148
protected ParameterBinder createBinder() {
139149
return createBinder(query);

spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkUserRepositoryFinderTests.java

+4
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,8 @@ void executesNotInQueryCorrectly() {}
3636
@Override
3737
void executesInKeywordForPageCorrectly() {}
3838

39+
@Disabled
40+
@Override
41+
void rawMapProjectionWithEntityAndAggregatedValue() {}
42+
3943
}

spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java

+96-16
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,24 @@
1515
*/
1616
package org.springframework.data.jpa.repository;
1717

18-
import static org.assertj.core.api.Assertions.*;
19-
import static org.springframework.data.domain.Sort.Direction.*;
18+
import static org.assertj.core.api.Assertions.assertThat;
19+
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
20+
import static org.springframework.data.domain.Sort.Direction.ASC;
21+
import static org.springframework.data.domain.Sort.Direction.DESC;
2022

2123
import jakarta.persistence.EntityManager;
2224

2325
import java.util.Arrays;
2426
import java.util.List;
27+
import java.util.Map;
2528

29+
import org.assertj.core.data.Offset;
2630
import org.junit.jupiter.api.AfterEach;
2731
import org.junit.jupiter.api.BeforeEach;
2832
import org.junit.jupiter.api.Test;
2933
import org.junit.jupiter.api.extension.ExtendWith;
34+
import org.junit.jupiter.params.ParameterizedTest;
35+
import org.junit.jupiter.params.provider.ValueSource;
3036
import org.springframework.beans.factory.annotation.Autowired;
3137
import org.springframework.dao.InvalidDataAccessApiUsageException;
3238
import org.springframework.data.domain.Limit;
@@ -45,6 +51,9 @@
4551
import org.springframework.data.jpa.repository.sample.UserRepository.IdOnly;
4652
import org.springframework.data.jpa.repository.sample.UserRepository.NameOnly;
4753
import org.springframework.data.jpa.repository.sample.UserRepository.RolesAndFirstname;
54+
import org.springframework.data.jpa.repository.sample.UserRepository.UserExcerpt;
55+
import org.springframework.data.jpa.repository.sample.UserRepository.UserRoleCountDtoProjection;
56+
import org.springframework.data.jpa.repository.sample.UserRepository.UserRoleCountInterfaceProjection;
4857
import org.springframework.data.repository.query.QueryLookupStrategy;
4958
import org.springframework.test.context.ContextConfiguration;
5059
import org.springframework.test.context.junit.jupiter.SpringExtension;
@@ -247,9 +256,9 @@ void executesQueryWithLimitAndScrollPosition() {
247256
@Test // GH-3409
248257
void executesWindowQueryWithPageable() {
249258

250-
Window<User> first = userRepository.findByLastnameOrderByFirstname("Matthews", PageRequest.of(0,1));
259+
Window<User> first = userRepository.findByLastnameOrderByFirstname("Matthews", PageRequest.of(0, 1));
251260

252-
Window<User> next = userRepository.findByLastnameOrderByFirstname("Matthews", PageRequest.of(1,1));
261+
Window<User> next = userRepository.findByLastnameOrderByFirstname("Matthews", PageRequest.of(1, 1));
253262

254263
assertThat(first).containsExactly(dave);
255264
assertThat(next).containsExactly(oliver);
@@ -406,21 +415,92 @@ void findByNegatingSimplePropertyUsingMixedNullNonNullArgument() {
406415
assertThat(result).containsExactly(carter);
407416
}
408417

409-
@Test // GH-3076
410-
void dtoProjectionShouldApplyConstructorExpressionRewriting() {
418+
@Test // GH-3076
419+
void dtoProjectionShouldApplyConstructorExpressionRewriting() {
411420

412-
List<UserRepository.UserExcerpt> dtos = userRepository.findRecordProjection();
421+
List<UserExcerpt> dtos = userRepository.findRecordProjection();
413422

414-
assertThat(dtos).flatExtracting(UserRepository.UserExcerpt::firstname) //
415-
.contains("Dave", "Carter", "Oliver August");
416-
}
423+
assertThat(dtos).flatExtracting(UserRepository.UserExcerpt::firstname) //
424+
.contains("Dave", "Carter", "Oliver August");
425+
}
426+
427+
@Test // GH-3076
428+
void dtoMultiselectProjectionShouldApplyConstructorExpressionRewriting() {
429+
430+
List<UserExcerpt> dtos = userRepository.findMultiselectRecordProjection();
431+
432+
assertThat(dtos).flatExtracting(UserRepository.UserExcerpt::firstname) //
433+
.contains("Dave", "Carter", "Oliver August");
434+
}
435+
436+
@Test // GH-3076
437+
void dynamicDtoProjection() {
438+
439+
List<UserExcerpt> dtos = userRepository.findRecordProjection(UserExcerpt.class);
440+
441+
assertThat(dtos).flatExtracting(UserRepository.UserExcerpt::firstname) //
442+
.contains("Dave", "Carter", "Oliver August");
443+
}
444+
445+
@Test // GH-3076
446+
void dtoProjectionWithEntityAndAggregatedValue() {
447+
448+
Map<String, User> musicians = Map.of(carter.getFirstname(), carter, dave.getFirstname(), dave,
449+
oliver.getFirstname(), oliver);
450+
451+
assertThat(userRepository.dtoProjectionEntityAndAggregatedValue()).allSatisfy(projection -> {
452+
assertThat(projection.user()).isIn(musicians.values());
453+
assertThat(projection.roleCount()).isCloseTo(musicians.get(projection.user().getFirstname()).getRoles().size(),
454+
Offset.offset(0L));
455+
});
456+
}
417457

418-
@Test // GH-3076
419-
void dtoMultiselectProjectionShouldApplyConstructorExpressionRewriting() {
458+
@Test // GH-3076
459+
void interfaceProjectionWithEntityAndAggregatedValue() {
420460

421-
List<UserRepository.UserExcerpt> dtos = userRepository.findMultiselectRecordProjection();
461+
Map<String, User> musicians = Map.of(carter.getFirstname(), carter, dave.getFirstname(), dave,
462+
oliver.getFirstname(), oliver);
463+
464+
assertThat(userRepository.interfaceProjectionEntityAndAggregatedValue()).allSatisfy(projection -> {
465+
assertThat(projection.getUser()).isIn(musicians.values());
466+
assertThat(projection.getRoleCount())
467+
.isCloseTo(musicians.get(projection.getUser().getFirstname()).getRoles().size(), Offset.offset(0L));
468+
});
469+
}
470+
471+
@Test // GH-3076
472+
void rawMapProjectionWithEntityAndAggregatedValue() {
473+
474+
Map<String, User> musicians = Map.of(carter.getFirstname(), carter, dave.getFirstname(), dave,
475+
oliver.getFirstname(), oliver);
476+
477+
assertThat(userRepository.rawMapProjectionEntityAndAggregatedValue()).allSatisfy(projection -> {
478+
assertThat(projection.get("user")).isIn(musicians.values());
479+
assertThat(projection).containsKey("roleCount");
480+
});
481+
}
482+
483+
@Test // GH-3076
484+
void dtoProjectionWithEntityAndAggregatedValueWithPageable() {
485+
486+
Map<String, User> musicians = Map.of(carter.getFirstname(), carter, dave.getFirstname(), dave,
487+
oliver.getFirstname(), oliver);
488+
489+
assertThat(
490+
userRepository.dtoProjectionEntityAndAggregatedValue(PageRequest.of(0, 10).withSort(Sort.by("firstname"))))
491+
.allSatisfy(projection -> {
492+
assertThat(projection.user()).isIn(musicians.values());
493+
assertThat(projection.roleCount())
494+
.isCloseTo(musicians.get(projection.user().getFirstname()).getRoles().size(), Offset.offset(0L));
495+
});
496+
}
497+
498+
@ParameterizedTest // GH-3076
499+
@ValueSource(classes = { UserRoleCountDtoProjection.class, UserRoleCountInterfaceProjection.class })
500+
<T> void dynamicProjectionWithEntityAndAggregated(Class<T> resultType) {
501+
502+
assertThat(userRepository.findMultiselectRecordDynamicProjection(resultType)).hasSize(3)
503+
.hasOnlyElementsOfType(resultType);
504+
}
422505

423-
assertThat(dtos).flatExtracting(UserRepository.UserExcerpt::firstname) //
424-
.contains("Dave", "Carter", "Oliver August");
425-
}
426506
}

spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlDtoQueryTransformerUnitTests.java

+20
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,21 @@ void shouldTranslateSingleProjectionToDto() {
4949
"SELECT new org.springframework.data.jpa.repository.query.JpqlDtoQueryTransformerUnitTests$MyRecord(p.foo, p.bar) from Person p");
5050
}
5151

52+
// @Test // GH-3076
53+
// void xxx() {
54+
//
55+
// JpaQueryMethod method = getMethod("dtoProjection2");
56+
// JpqlSortedQueryTransformer transformer = new JpqlSortedQueryTransformer(Sort.unsorted(), null,
57+
// method.getResultProcessor().getReturnedType());
58+
//
59+
// JpaQueryEnhancer.JpqlQueryParser parser = JpaQueryEnhancer.JpqlQueryParser.parseQuery("select u.foo, u.bar, count(r) from User u left outer join u.role r group by u");
60+
//
61+
// QueryTokenStream visit = transformer.visit(parser.getContext());
62+
//
63+
// assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo(
64+
// "select new org.springframework.data.jpa.repository.query.JpqlDtoQueryTransformerUnitTests$MyRecord2(u.foo, u.bar, count(r)) from User u left outer join u.role r group by u");
65+
// }
66+
5267
@Test // GH-3076
5368
void shouldRewriteQueriesWithSubselect() {
5469

@@ -100,6 +115,7 @@ private JpaQueryMethod getMethod(String name, Class<?>... parameterTypes) {
100115
interface MyRepo extends Repository<Person, String> {
101116

102117
MyRecord dtoProjection();
118+
MyRecord2 dtoProjection2();
103119
}
104120

105121
record Person(String id) {
@@ -109,4 +125,8 @@ record Person(String id) {
109125
record MyRecord(String foo, String bar) {
110126

111127
}
128+
129+
record MyRecord2(String foo, String bar, Integer count) {
130+
131+
}
112132
}

spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java

+31
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
import jakarta.persistence.EntityManager;
1919
import jakarta.persistence.QueryHint;
2020

21+
import java.lang.annotation.Retention;
22+
import java.lang.annotation.RetentionPolicy;
2123
import java.util.Collection;
2224
import java.util.Date;
2325
import java.util.List;
@@ -721,11 +723,33 @@ List<String> findAllAndSortByFunctionResultNamedParameter(@Param("namedParameter
721723
@Query("select u from User u")
722724
List<UserExcerpt> findRecordProjection();
723725

726+
@Query("select u from User u")
727+
<T> List<T> findRecordProjection(Class<T> projectionType);
728+
724729
@Query("select u.firstname, u.lastname from User u")
725730
List<UserExcerpt> findMultiselectRecordProjection();
726731

732+
@UserRoleCountProjectingQuery
733+
List<UserRoleCountDtoProjection> dtoProjectionEntityAndAggregatedValue();
734+
735+
@UserRoleCountProjectingQuery
736+
Page<UserRoleCountDtoProjection> dtoProjectionEntityAndAggregatedValue(PageRequest page);
737+
738+
@Query("select u as user, count(r) as roleCount from User u left outer join u.roles r group by u")
739+
List<UserRoleCountInterfaceProjection> interfaceProjectionEntityAndAggregatedValue();
740+
741+
@Query("select u as user, count(r) as roleCount from User u left outer join u.roles r group by u")
742+
List<Map<String, Object>> rawMapProjectionEntityAndAggregatedValue();
743+
744+
@UserRoleCountProjectingQuery
745+
<T> List<T> findMultiselectRecordDynamicProjection(Class<T> projectionType);
746+
727747
Window<User> findBy(OffsetScrollPosition position);
728748

749+
@Retention(RetentionPolicy.RUNTIME)
750+
@Query("select u, count(r) from User u left outer join u.roles r group by u")
751+
@interface UserRoleCountProjectingQuery {}
752+
729753
interface RolesAndFirstname {
730754

731755
String getFirstname();
@@ -754,4 +778,11 @@ record UserExcerpt(String firstname, String lastname) {
754778

755779
}
756780

781+
record UserRoleCountDtoProjection(User user, Long roleCount) {}
782+
783+
interface UserRoleCountInterfaceProjection {
784+
User getUser();
785+
Long getRoleCount();
786+
}
787+
757788
}

src/main/antora/modules/ROOT/pages/repositories/projections.adoc

+7
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ This query gets rewritten to `SELECT new UserDto(u.firstname, u.lastname) FROM U
4040
This query gets rewritten to `SELECT new UserDto(u.firstname, u.lastname) FROM USER u`.
4141
====
4242

43+
[WARNING]
44+
====
45+
JPQL constructor expressions must not contain aliases for selected columns.
46+
While `SELECT u as user, count(u.roles) as roleCount FROM USER u ...` is a valid usecase for interface based projections that rely on column names from the returned `Tuple`, the same construct is invalid when requesting a DTO where it needs to be `SELECT u, count(u.roles) FROM USER u ...`. +
47+
Some persistence providers may be lenient about this, others not.
48+
====
49+
4350
Repository query methods that return a DTO projection type (a Java type outside the domain type hierarchy) are subject for query rewriting.
4451
If an `@Query`-annotated query already uses constructor expressions, then Spring Data backs off and doesn't apply DTO constructor expression rewriting.
4552

0 commit comments

Comments
 (0)