From 3e9d39445d1f7642b784652c481d669b30e6f445 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Fri, 21 Jun 2024 14:34:47 +0200 Subject: [PATCH] Polishing. Add tests, update documentation, add support for SQL ResultSet mapping. See #3155 Original pull request: #3353 --- .../data/jpa/repository/NativeQuery.java | 99 +++++++++++-------- .../data/jpa/repository/Query.java | 10 +- .../jpa/repository/query/JpaQueryMethod.java | 9 ++ .../jpa/repository/query/NativeJpaQuery.java | 20 +++- .../data/jpa/domain/sample/User.java | 28 +++++- ...lipseLinkNamespaceUserRepositoryTests.java | 5 +- .../jpa/repository/UserRepositoryTests.java | 15 ++- .../jpa/repository/sample/UserRepository.java | 12 ++- .../modules/ROOT/pages/jpa/query-methods.adoc | 17 ++-- 9 files changed, 150 insertions(+), 65 deletions(-) diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/NativeQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/NativeQuery.java index abbf986f2..47ed58c61 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/NativeQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/NativeQuery.java @@ -15,62 +15,83 @@ */ package org.springframework.data.jpa.repository; -import org.springframework.core.annotation.AliasFor; -import org.springframework.data.annotation.QueryAnnotation; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; -import java.lang.annotation.*; +import org.springframework.core.annotation.AliasFor; /** - * Annotation to declare native queries directly on repository methods. + * Annotation to declare native queries directly on repository query methods. *

- * Specifically {@code @NativeQuery} is a composed annotation that - * acts as a shortcut for {@code @Query(nativeQuery = true)}. + * Specifically {@code @NativeQuery} is a composed annotation that acts as a shortcut for + * {@code @Query(nativeQuery = true)} for most attributes. + *

+ * This annotation defines {@code sqlResultSetMapping} to apply JPA SQL ResultSet mapping for native queries. Make sure + * to use the corresponding return type as defined in {@code @SqlResultSetMapping}. When using named native queries, + * define SQL result set mapping through {@code @NamedNativeQuery(resultSetMapping=…)} as named queries do not accept + * {@code sqlResultSetMapping}. * * @author Danny van den Elshout - * @since 3.3 + * @author Mark Paluch + * @since 3.4 * @see Query + * @see Modifying */ - @Retention(RetentionPolicy.RUNTIME) @Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) -@QueryAnnotation @Documented @Query(nativeQuery = true) public @interface NativeQuery { - /** - * Alias for {@link Query#value()} - */ - @AliasFor(annotation = Query.class) - String value() default ""; + /** + * Defines the native query to be executed when the annotated method is called. Alias for {@link Query#value()}. + */ + @AliasFor(annotation = Query.class) + String value() default ""; - /** - * Alias for {@link Query#countQuery()} - */ - @AliasFor(annotation = Query.class) - String countQuery() default ""; + /** + * Defines a special count query that shall be used for pagination queries to look up the total number of elements for + * a page. If none is configured we will derive the count query from the original query or {@link #countProjection()} + * query if any. Alias for {@link Query#countQuery()}. + */ + @AliasFor(annotation = Query.class) + String countQuery() default ""; - /** - * Alias for {@link Query#countProjection()} - */ - @AliasFor(annotation = Query.class) - String countProjection() default ""; + /** + * Defines the projection part of the count query that is generated for pagination. If neither {@link #countQuery()} + * nor {@code countProjection()} is configured we will derive the count query from the original query. Alias for + * {@link Query#countProjection()}. + */ + @AliasFor(annotation = Query.class) + String countProjection() default ""; - /** - * Alias for {@link Query#name()} - */ - @AliasFor(annotation = Query.class) - String name() default ""; + /** + * The named query to be used. If not defined, a {@link jakarta.persistence.NamedQuery} with name of + * {@code ${domainClass}.${queryMethodName}} will be used. Alias for {@link Query#name()}. + */ + @AliasFor(annotation = Query.class) + String name() default ""; - /** - * Alias for {@link Query#countName()} - */ - @AliasFor(annotation = Query.class) - String countName() default ""; + /** + * Returns the name of the {@link jakarta.persistence.NamedQuery} to be used to execute count queries when pagination + * is used. Will default to the named query name configured suffixed by {@code .count}. Alias for + * {@link Query#countName()}. + */ + @AliasFor(annotation = Query.class) + String countName() default ""; - /** - * Alias for {@link Query#queryRewriter()} - */ - @AliasFor(annotation = Query.class) - Class queryRewriter() default QueryRewriter.IdentityQueryRewriter.class; + /** + * Define a {@link QueryRewriter} that should be applied to the query string after the query is fully assembled. Alias + * for {@link Query#queryRewriter()}. + */ + @AliasFor(annotation = Query.class) + Class queryRewriter() default QueryRewriter.IdentityQueryRewriter.class; + + /** + * Name of the {@link jakarta.persistence.SqlResultSetMapping @SqlResultSetMapping(name)} to apply for this query. + */ + String sqlResultSetMapping() default ""; } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Query.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Query.java index e9010594a..e43157b40 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Query.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Query.java @@ -24,9 +24,9 @@ import java.lang.annotation.Target; import org.springframework.data.annotation.QueryAnnotation; /** - * Annotation to declare finder queries directly on repository methods. + * Annotation to declare finder queries directly on repository query methods. *

- * When using a native query {@link NativeQuery @NativeQuery} variant is available. + * When using a native query, a {@link NativeQuery @NativeQuery} variant is available. * * @author Oliver Gierke * @author Thomas Darimont @@ -48,7 +48,7 @@ public @interface Query { String value() default ""; /** - * Defines a special count query that shall be used for pagination queries to lookup the total number of elements for + * Defines a special count query that shall be used for pagination queries to look up the total number of elements for * a page. If none is configured we will derive the count query from the original query or {@link #countProjection()} * query if any. */ @@ -56,7 +56,7 @@ public @interface Query { /** * Defines the projection part of the count query that is generated for pagination. If neither {@link #countQuery()} - * nor {@link #countProjection()} is configured we will derive the count query from the original query. + * nor {@code countProjection()} is configured we will derive the count query from the original query. * * @return * @since 1.6 @@ -70,7 +70,7 @@ public @interface Query { /** * The named query to be used. If not defined, a {@link jakarta.persistence.NamedQuery} with name of - * {@code $ domainClass}.${queryMethodName}} will be used. + * {@code ${domainClass}.${queryMethodName}} will be used. */ String name() default ""; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryMethod.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryMethod.java index b299d87c4..fde3b2cb9 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryMethod.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryMethod.java @@ -233,6 +233,15 @@ public class JpaQueryMethod extends QueryMethod { return extractor; } + /** + * Returns the {@link Method}. + * + * @return + */ + Method getMethod() { + return method; + } + /** * Returns the actual return type of the method. * diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NativeJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NativeJpaQuery.java index a2bb681e1..c0ad69de7 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NativeJpaQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NativeJpaQuery.java @@ -19,8 +19,11 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import jakarta.persistence.Tuple; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.annotation.MergedAnnotations; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.NativeQuery; import org.springframework.data.jpa.repository.QueryRewriter; import org.springframework.data.repository.query.Parameters; import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; @@ -28,6 +31,7 @@ import org.springframework.data.repository.query.RepositoryQuery; import org.springframework.data.repository.query.ReturnedType; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.lang.Nullable; +import org.springframework.util.ObjectUtils; /** * {@link RepositoryQuery} implementation that inspects a {@link org.springframework.data.repository.query.QueryMethod} @@ -42,6 +46,8 @@ import org.springframework.lang.Nullable; */ final class NativeJpaQuery extends AbstractStringBasedJpaQuery { + private final @Nullable String sqlResultSetMapping; + private final boolean queryForEntity; /** @@ -59,6 +65,10 @@ final class NativeJpaQuery extends AbstractStringBasedJpaQuery { super(method, em, queryString, countQueryString, rewriter, evaluationContextProvider, parser); + MergedAnnotations annotations = MergedAnnotations.from(method.getMethod()); + MergedAnnotation annotation = annotations.get(NativeQuery.class); + this.sqlResultSetMapping = annotation.isPresent() ? annotation.getString("sqlResultSetMapping") : null; + this.queryForEntity = getQueryMethod().isQueryForEntity(); Parameters parameters = method.getParameters(); @@ -72,10 +82,14 @@ final class NativeJpaQuery extends AbstractStringBasedJpaQuery { protected Query createJpaQuery(String queryString, Sort sort, Pageable pageable, ReturnedType returnedType) { EntityManager em = getEntityManager(); - Class type = getTypeToQueryFor(returnedType); + String query = potentiallyRewriteQuery(queryString, sort, pageable); - return type == null ? em.createNativeQuery(potentiallyRewriteQuery(queryString, sort, pageable)) - : em.createNativeQuery(potentiallyRewriteQuery(queryString, sort, pageable), type); + if (!ObjectUtils.isEmpty(sqlResultSetMapping)) { + return em.createNativeQuery(query, sqlResultSetMapping); + } + + Class type = getTypeToQueryFor(returnedType); + return type == null ? em.createNativeQuery(query) : em.createNativeQuery(query, type); } @Nullable diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/User.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/User.java index d98ed10d6..fcad1f8e8 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/User.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/User.java @@ -83,8 +83,11 @@ import java.util.Set; @StoredProcedureParameter(mode = ParameterMode.OUT, name = "res", type = Integer.class) }) // Annotations for native Query with pageable -@SqlResultSetMappings({ - @SqlResultSetMapping(name = "SqlResultSetMapping.count", columns = @ColumnResult(name = "cnt")) }) +@SqlResultSetMappings({ @SqlResultSetMapping(name = "SqlResultSetMapping.count", columns = @ColumnResult(name = "cnt")), + @SqlResultSetMapping(name = "emailDto", + classes = { @ConstructorResult(targetClass = User.EmailDto.class, + columns = { @ColumnResult(name = "emailaddress", type = String.class), + @ColumnResult(name = "secondary_email_address", type = String.class) }) }) }) @NamedNativeQueries({ @NamedNativeQuery(name = "User.findByNativeNamedQueryWithPageable", resultClass = User.class, query = "SELECT * FROM SD_USER ORDER BY UCASE(firstname)"), @@ -93,7 +96,26 @@ import java.util.Set; @Table(name = "SD_User") public class User { - @Id @GeneratedValue(strategy = GenerationType.AUTO) private Integer id; + public static class EmailDto { + private final String one; + private final String two; + + public EmailDto(String one, String two) { + this.one = one; + this.two = two; + } + + public String getOne() { + return one; + } + + public String getTwo() { + return two; + } + } + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) private Integer id; private String firstname; private String lastname; private int age; diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkNamespaceUserRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkNamespaceUserRepositoryTests.java index 6eb1d40dd..a93fb5336 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkNamespaceUserRepositoryTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkNamespaceUserRepositoryTests.java @@ -15,12 +15,13 @@ */ package org.springframework.data.jpa.repository; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.*; import jakarta.persistence.Query; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; + import org.springframework.data.jpa.repository.sample.UserRepository; import org.springframework.test.context.ContextConfiguration; @@ -75,7 +76,7 @@ class EclipseLinkNamespaceUserRepositoryTests extends NamespaceUserRepositoryTes @Disabled @Override @Test // DATAJPA-980 - void supportsProjectionsWithNativeQueries() {} + void supportsInterfaceProjectionsWithNativeQueries() {} /** * Ignored until https://bugs.eclipse.org/bugs/show_bug.cgi?id=525319 is fixed. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java index d549077ee..ed3b76365 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java @@ -2943,7 +2943,7 @@ class UserRepositoryTests { } @Test // DATAJPA-980 - void supportsProjectionsWithNativeQueries() { + void supportsInterfaceProjectionsWithNativeQueries() { flushTestUsers(); @@ -2971,6 +2971,19 @@ class UserRepositoryTests { .isNotNull(); } + @Test // GH-3155 + void supportsResultSetMappingWithNativeQueries() { + + flushTestUsers(); + + User user = repository.findAll().get(0); + + User.EmailDto result = repository.findEmailDtoByNativeQuery(user.getId()); + + assertThat(result.getOne()).isEqualTo(user.getEmailAddress()); + assertThat(result.getTwo()).isEqualTo(user.getSecondaryEmailAddress()); + } + @Test // GH-3462 void supportsProjectionsWithNativeQueriesAndUnderscoresColumnNameToCamelCaseProperty() { 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 684b49bce..b2251291a 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 @@ -41,6 +41,7 @@ import org.springframework.data.jpa.domain.sample.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.NativeQuery; import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.QueryHints; import org.springframework.data.jpa.repository.query.Procedure; @@ -405,7 +406,7 @@ public interface UserRepository extends JpaRepository, JpaSpecifi Slice findTop2UsersBy(Pageable page); // DATAJPA-506 - @Query(value = "select u.binaryData from SD_User u where u.id = ?1", nativeQuery = true) + @NativeQuery("select u.binaryData from SD_User u where u.id = ?1") byte[] findBinaryDataByIdNative(Integer id); // DATAJPA-506 @@ -555,8 +556,12 @@ public interface UserRepository extends JpaRepository, JpaSpecifi @Query(value = "SELECT firstname, lastname FROM SD_User WHERE id = ?1", nativeQuery = true) NameOnly findByNativeQuery(Integer id); - // DATAJPA-1248 - @Query(value = "SELECT emailaddress, secondary_email_address FROM SD_User WHERE id = ?1", nativeQuery = true) + // GH-3155 + @NativeQuery(value = "SELECT emailaddress, secondary_email_address FROM SD_User WHERE id = ?1", + sqlResultSetMapping = "emailDto") + User.EmailDto findEmailDtoByNativeQuery(Integer id); + + @NativeQuery(value = "SELECT emailaddress, secondary_email_address FROM SD_User WHERE id = ?1") EmailOnly findEmailOnlyByNativeQuery(Integer id); // DATAJPA-1235 @@ -729,4 +734,5 @@ public interface UserRepository extends JpaRepository, JpaSpecifi interface IdOnly { int getId(); } + } diff --git a/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc b/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc index d6fb186ac..475dc0551 100644 --- a/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc +++ b/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc @@ -185,8 +185,7 @@ you can make any alterations at the last moment. ---- public interface MyRepository extends JpaRepository { - @Query(value = "select original_user_alias.* from SD_USER original_user_alias", - nativeQuery = true, + @NativeQuery(value = "select original_user_alias.* from SD_USER original_user_alias", queryRewriter = MyQueryRewriter.class) List findByNativeQuery(String param); @@ -273,7 +272,7 @@ In the preceding example, the `LIKE` delimiter character (`%`) is recognized, an [[jpa.query-methods.at-query.native]] === Native Queries -The `@Query` annotation allows for running native queries by setting the `nativeQuery` flag to true, as shown in the following example: +Using the `@NativeQuery` annotation allows running native queries, as shown in the following example: .Declare a native query at the query method using @Query ==== @@ -281,24 +280,24 @@ The `@Query` annotation allows for running native queries by setting the `native ---- public interface UserRepository extends JpaRepository { - @Query(value = "SELECT * FROM USERS WHERE EMAIL_ADDRESS = ?1", nativeQuery = true) + @NativeQuery(value = "SELECT * FROM USERS WHERE EMAIL_ADDRESS = ?1") User findByEmailAddress(String emailAddress); } ---- - ==== +NOTE: The `@NativeQuery` annotation is mostly a composed annotation for `@Query(nativeQuery=true)` but it also provides additional attributes such as `sqlResultSetMapping` to leverage JPA's `@SqlResultSetMapping(…)`. + NOTE: Spring Data JPA does not currently support dynamic sorting for native queries, because it would have to manipulate the actual query declared, which it cannot do reliably for native SQL. You can, however, use native queries for pagination by specifying the count query yourself, as shown in the following example: -.Declare native count queries for pagination at the query method by using `@Query` +.Declare native count queries for pagination at the query method by using `@NativeQuery` ==== [source, java] ---- public interface UserRepository extends JpaRepository { - @Query(value = "SELECT * FROM USERS WHERE LASTNAME = ?1", - countQuery = "SELECT count(*) FROM USERS WHERE LASTNAME = ?1", - nativeQuery = true) + @NativeQuery(value = "SELECT * FROM USERS WHERE LASTNAME = ?1", + countQuery = "SELECT count(*) FROM USERS WHERE LASTNAME = ?1") Page findByLastname(String lastname, Pageable pageable); } ----