diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java index f797284c5a1..7b3d225e9f2 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java @@ -23,6 +23,8 @@ import jakarta.persistence.TupleElement; import jakarta.persistence.TypedQuery; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; @@ -51,6 +53,7 @@ import org.springframework.jdbc.support.JdbcUtils; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; /** * Abstract base class to implement {@link RepositoryQuery}s. @@ -353,6 +356,24 @@ public Object convert(Object source) { } } + if(type.isProjecting() && !type.getReturnedType().isInterface() && !type.getInputProperties().isEmpty()) { + List ctorArgs = new ArrayList<>(type.getInputProperties().size()); + type.getInputProperties().forEach(it -> { + ctorArgs.add(tuple.get(it)); + }); + try { + return type.getReturnedType().getConstructor(ctorArgs.stream().map(Object::getClass).toArray(Class[]::new)).newInstance(ctorArgs.toArray()); + } catch (InstantiationException e) { + throw new RuntimeException(e); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } catch (InvocationTargetException e) { + throw new RuntimeException(e); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + return new TupleBackedMap(tupleWrapper.apply(tuple)); } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java index 16ab1d21a58..7b89245661d 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java @@ -119,7 +119,13 @@ public Query doCreateQuery(JpaParametersParameterAccessor accessor) { Sort sort = accessor.getSort(); ResultProcessor processor = getQueryMethod().getResultProcessor().withDynamicProjection(accessor); - String sortedQueryString = getSortedQueryString(sort, processor.getReturnedType()); + + String sortedQueryString = null; + if(querySortRewriter.equals(NoOpQuerySortRewriter.INSTANCE) && accessor.findDynamicProjection() != null && !accessor.findDynamicProjection().isInterface()) { + sortedQueryString = getSortedQueryString(new ProjectingSortRewriter(), query, sort, processor.getReturnedType()); + } else { + sortedQueryString = getSortedQueryString(sort, processor.getReturnedType()); + } Query query = createJpaQuery(sortedQueryString, sort, accessor.getPageable(), processor.getReturnedType()); @@ -134,6 +140,10 @@ String getSortedQueryString(Sort sort, ReturnedType returnedType) { return querySortRewriter.getSorted(query, sort, returnedType); } + private static String getSortedQueryString(QuerySortRewriter rewriter, DeclaredQuery query, Sort sort, ReturnedType returnedType) { + return rewriter.getSorted(query, sort, returnedType); + } + @Override protected ParameterBinder createBinder() { return createBinder(query); diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkUserRepositoryFinderTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkUserRepositoryFinderTests.java index 99343adb990..18e0570de89 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkUserRepositoryFinderTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkUserRepositoryFinderTests.java @@ -36,4 +36,8 @@ void executesNotInQueryCorrectly() {} @Override void executesInKeywordForPageCorrectly() {} + @Disabled + @Override + void rawMapProjectionWithEntityAndAggregatedValue() {} + } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java index d887cc51bd8..09281ed623b 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java @@ -15,18 +15,24 @@ */ package org.springframework.data.jpa.repository; -import static org.assertj.core.api.Assertions.*; -import static org.springframework.data.domain.Sort.Direction.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.springframework.data.domain.Sort.Direction.ASC; +import static org.springframework.data.domain.Sort.Direction.DESC; import jakarta.persistence.EntityManager; import java.util.Arrays; import java.util.List; +import java.util.Map; +import org.assertj.core.data.Offset; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.domain.Limit; @@ -45,6 +51,9 @@ import org.springframework.data.jpa.repository.sample.UserRepository.IdOnly; import org.springframework.data.jpa.repository.sample.UserRepository.NameOnly; import org.springframework.data.jpa.repository.sample.UserRepository.RolesAndFirstname; +import org.springframework.data.jpa.repository.sample.UserRepository.UserExcerpt; +import org.springframework.data.jpa.repository.sample.UserRepository.UserRoleCountDtoProjection; +import org.springframework.data.jpa.repository.sample.UserRepository.UserRoleCountInterfaceProjection; import org.springframework.data.repository.query.QueryLookupStrategy; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; @@ -247,9 +256,9 @@ void executesQueryWithLimitAndScrollPosition() { @Test // GH-3409 void executesWindowQueryWithPageable() { - Window first = userRepository.findByLastnameOrderByFirstname("Matthews", PageRequest.of(0,1)); + Window first = userRepository.findByLastnameOrderByFirstname("Matthews", PageRequest.of(0, 1)); - Window next = userRepository.findByLastnameOrderByFirstname("Matthews", PageRequest.of(1,1)); + Window next = userRepository.findByLastnameOrderByFirstname("Matthews", PageRequest.of(1, 1)); assertThat(first).containsExactly(dave); assertThat(next).containsExactly(oliver); @@ -406,21 +415,92 @@ void findByNegatingSimplePropertyUsingMixedNullNonNullArgument() { assertThat(result).containsExactly(carter); } - @Test // GH-3076 - void dtoProjectionShouldApplyConstructorExpressionRewriting() { + @Test // GH-3076 + void dtoProjectionShouldApplyConstructorExpressionRewriting() { - List dtos = userRepository.findRecordProjection(); + List dtos = userRepository.findRecordProjection(); - assertThat(dtos).flatExtracting(UserRepository.UserExcerpt::firstname) // - .contains("Dave", "Carter", "Oliver August"); - } + assertThat(dtos).flatExtracting(UserRepository.UserExcerpt::firstname) // + .contains("Dave", "Carter", "Oliver August"); + } + + @Test // GH-3076 + void dtoMultiselectProjectionShouldApplyConstructorExpressionRewriting() { + + List dtos = userRepository.findMultiselectRecordProjection(); + + assertThat(dtos).flatExtracting(UserRepository.UserExcerpt::firstname) // + .contains("Dave", "Carter", "Oliver August"); + } + + @Test // GH-3076 + void dynamicDtoProjection() { + + List dtos = userRepository.findRecordProjection(UserExcerpt.class); + + assertThat(dtos).flatExtracting(UserRepository.UserExcerpt::firstname) // + .contains("Dave", "Carter", "Oliver August"); + } + + @Test // GH-3076 + void dtoProjectionWithEntityAndAggregatedValue() { + + Map musicians = Map.of(carter.getFirstname(), carter, dave.getFirstname(), dave, + oliver.getFirstname(), oliver); + + assertThat(userRepository.dtoProjectionEntityAndAggregatedValue()).allSatisfy(projection -> { + assertThat(projection.user()).isIn(musicians.values()); + assertThat(projection.roleCount()).isCloseTo(musicians.get(projection.user().getFirstname()).getRoles().size(), + Offset.offset(0L)); + }); + } - @Test // GH-3076 - void dtoMultiselectProjectionShouldApplyConstructorExpressionRewriting() { + @Test // GH-3076 + void interfaceProjectionWithEntityAndAggregatedValue() { - List dtos = userRepository.findMultiselectRecordProjection(); + Map musicians = Map.of(carter.getFirstname(), carter, dave.getFirstname(), dave, + oliver.getFirstname(), oliver); + + assertThat(userRepository.interfaceProjectionEntityAndAggregatedValue()).allSatisfy(projection -> { + assertThat(projection.getUser()).isIn(musicians.values()); + assertThat(projection.getRoleCount()) + .isCloseTo(musicians.get(projection.getUser().getFirstname()).getRoles().size(), Offset.offset(0L)); + }); + } + + @Test // GH-3076 + void rawMapProjectionWithEntityAndAggregatedValue() { + + Map musicians = Map.of(carter.getFirstname(), carter, dave.getFirstname(), dave, + oliver.getFirstname(), oliver); + + assertThat(userRepository.rawMapProjectionEntityAndAggregatedValue()).allSatisfy(projection -> { + assertThat(projection.get("user")).isIn(musicians.values()); + assertThat(projection).containsKey("roleCount"); + }); + } + + @Test // GH-3076 + void dtoProjectionWithEntityAndAggregatedValueWithPageable() { + + Map musicians = Map.of(carter.getFirstname(), carter, dave.getFirstname(), dave, + oliver.getFirstname(), oliver); + + assertThat( + userRepository.dtoProjectionEntityAndAggregatedValue(PageRequest.of(0, 10).withSort(Sort.by("firstname")))) + .allSatisfy(projection -> { + assertThat(projection.user()).isIn(musicians.values()); + assertThat(projection.roleCount()) + .isCloseTo(musicians.get(projection.user().getFirstname()).getRoles().size(), Offset.offset(0L)); + }); + } + + @ParameterizedTest // GH-3076 + @ValueSource(classes = { UserRoleCountDtoProjection.class, UserRoleCountInterfaceProjection.class }) + void dynamicProjectionWithEntityAndAggregated(Class resultType) { + + assertThat(userRepository.findMultiselectRecordDynamicProjection(resultType)).hasSize(3) + .hasOnlyElementsOfType(resultType); + } - assertThat(dtos).flatExtracting(UserRepository.UserExcerpt::firstname) // - .contains("Dave", "Carter", "Oliver August"); - } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlDtoQueryTransformerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlDtoQueryTransformerUnitTests.java index de6b22c90eb..ab13c719a18 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlDtoQueryTransformerUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlDtoQueryTransformerUnitTests.java @@ -49,6 +49,21 @@ void shouldTranslateSingleProjectionToDto() { "SELECT new org.springframework.data.jpa.repository.query.JpqlDtoQueryTransformerUnitTests$MyRecord(p.foo, p.bar) from Person p"); } +// @Test // GH-3076 +// void xxx() { +// +// JpaQueryMethod method = getMethod("dtoProjection2"); +// JpqlSortedQueryTransformer transformer = new JpqlSortedQueryTransformer(Sort.unsorted(), null, +// method.getResultProcessor().getReturnedType()); +// +// 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"); +// +// QueryTokenStream visit = transformer.visit(parser.getContext()); +// +// assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo( +// "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"); +// } + @Test // GH-3076 void shouldRewriteQueriesWithSubselect() { @@ -100,6 +115,7 @@ private JpaQueryMethod getMethod(String name, Class... parameterTypes) { interface MyRepo extends Repository { MyRecord dtoProjection(); + MyRecord2 dtoProjection2(); } record Person(String id) { @@ -109,4 +125,8 @@ record Person(String id) { record MyRecord(String foo, String bar) { } + + record MyRecord2(String foo, String bar, Integer count) { + + } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java index 062bea4d59e..e6d80af08cc 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java @@ -18,6 +18,8 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.QueryHint; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.util.Collection; import java.util.Date; import java.util.List; @@ -721,11 +723,33 @@ List findAllAndSortByFunctionResultNamedParameter(@Param("namedParameter @Query("select u from User u") List findRecordProjection(); + @Query("select u from User u") + List findRecordProjection(Class projectionType); + @Query("select u.firstname, u.lastname from User u") List findMultiselectRecordProjection(); + @UserRoleCountProjectingQuery + List dtoProjectionEntityAndAggregatedValue(); + + @UserRoleCountProjectingQuery + Page dtoProjectionEntityAndAggregatedValue(PageRequest page); + + @Query("select u as user, count(r) as roleCount from User u left outer join u.roles r group by u") + List interfaceProjectionEntityAndAggregatedValue(); + + @Query("select u as user, count(r) as roleCount from User u left outer join u.roles r group by u") + List> rawMapProjectionEntityAndAggregatedValue(); + + @UserRoleCountProjectingQuery + List findMultiselectRecordDynamicProjection(Class projectionType); + Window findBy(OffsetScrollPosition position); + @Retention(RetentionPolicy.RUNTIME) + @Query("select u, count(r) from User u left outer join u.roles r group by u") + @interface UserRoleCountProjectingQuery {} + interface RolesAndFirstname { String getFirstname(); @@ -754,4 +778,11 @@ record UserExcerpt(String firstname, String lastname) { } + record UserRoleCountDtoProjection(User user, Long roleCount) {} + + interface UserRoleCountInterfaceProjection { + User getUser(); + Long getRoleCount(); + } + } diff --git a/src/main/antora/modules/ROOT/pages/repositories/projections.adoc b/src/main/antora/modules/ROOT/pages/repositories/projections.adoc index 5999171b1ef..9aa37457492 100644 --- a/src/main/antora/modules/ROOT/pages/repositories/projections.adoc +++ b/src/main/antora/modules/ROOT/pages/repositories/projections.adoc @@ -40,6 +40,13 @@ This query gets rewritten to `SELECT new UserDto(u.firstname, u.lastname) FROM U This query gets rewritten to `SELECT new UserDto(u.firstname, u.lastname) FROM USER u`. ==== +[WARNING] +==== +JPQL constructor expressions must not contain aliases for selected columns. +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 ...`. + +Some persistence providers may be lenient about this, others not. +==== + Repository query methods that return a DTO projection type (a Java type outside the domain type hierarchy) are subject for query rewriting. If an `@Query`-annotated query already uses constructor expressions, then Spring Data backs off and doesn't apply DTO constructor expression rewriting.