From eb1266888fcf74cdcd9005bedf4c5de31cf6e867 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Mon, 7 Apr 2025 12:21:14 +0200 Subject: [PATCH] Add support for JSON repository metadata. See #3830 --- spring-data-jpa/pom.xml | 25 ++- .../data/jpa/repository/aot/AotQueries.java | 51 +++++ .../aot/JpaRepositoryContributor.java | 64 ++++++- .../jpa/repository/aot/NamedAotQuery.java | 14 +- .../jpa/repository/aot/QueriesFactory.java | 12 +- .../support/JpaRepositoryFactory.java | 4 +- .../support/JpaRepositoryFactoryBean.java | 6 +- ...RepositoryContributorIntegrationTests.java | 118 ++++++------ ...JpaRepositoryMetadataIntegrationTests.java | 178 ++++++++++++++++++ .../aot/StubRepositoryInformation.java | 13 +- .../jpa/repository/aot/UserRepository.java | 11 ++ 11 files changed, 410 insertions(+), 86 deletions(-) create mode 100644 spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryMetadataIntegrationTests.java diff --git a/spring-data-jpa/pom.xml b/spring-data-jpa/pom.xml index 12a089e3e..1cc667406 100644 --- a/spring-data-jpa/pom.xml +++ b/spring-data-jpa/pom.xml @@ -88,16 +88,31 @@ true - - org.junit.platform - junit-platform-launcher - test - + + org.junit.platform + junit-platform-launcher + test + + org.springframework spring-core-test test + + + net.javacrumbs.json-unit + json-unit-assertj + 4.1.0 + test + + + + com.fasterxml.jackson.core + jackson-databind + test + + org.hsqldb hsqldb diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotQueries.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotQueries.java index 0b900c72a..51d639ea7 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotQueries.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotQueries.java @@ -16,6 +16,8 @@ package org.springframework.data.jpa.repository.aot; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.function.Function; import org.jspecify.annotations.Nullable; @@ -23,6 +25,7 @@ import org.jspecify.annotations.Nullable; import org.springframework.data.jpa.repository.query.DeclaredQuery; import org.springframework.data.jpa.repository.query.QueryEnhancer; import org.springframework.data.jpa.repository.query.QueryEnhancerSelector; +import org.springframework.data.repository.aot.generate.QueryMetadata; import org.springframework.util.StringUtils; /** @@ -68,4 +71,52 @@ record AotQueries(AotQuery result, AotQuery count) { return result().isNative(); } + public QueryMetadata toMetadata(boolean paging) { + return new AotQueryMetadata(paging); + } + + /** + * String and Named Query-based {@link QueryMetadata}. + */ + private class AotQueryMetadata implements QueryMetadata { + + private final boolean paging; + + AotQueryMetadata(boolean paging) { + this.paging = paging; + } + + @Override + public Map serialize() { + + Map serialized = new LinkedHashMap<>(); + + if (result() instanceof NamedAotQuery nq) { + + serialized.put("name", nq.getName()); + serialized.put("query", nq.getQueryString()); + } + + if (result() instanceof StringAotQuery sq) { + serialized.put("query", sq.getQueryString()); + } + + if (paging) { + + if (count() instanceof NamedAotQuery nq) { + + serialized.put("count-name", nq.getName()); + serialized.put("count-query", nq.getQueryString()); + } + + if (count() instanceof StringAotQuery sq) { + serialized.put("count-query", sq.getQueryString()); + } + } + + return serialized; + } + + } + } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributor.java index b5ab48031..785c9133c 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributor.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributor.java @@ -19,10 +19,13 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManagerFactory; import java.lang.reflect.Method; +import java.util.Map; import org.jspecify.annotations.Nullable; +import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.annotation.MergedAnnotations; import org.springframework.data.jpa.provider.PersistenceProvider; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.Modifying; @@ -31,10 +34,12 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.QueryHints; import org.springframework.data.jpa.repository.query.JpaParameters; import org.springframework.data.jpa.repository.query.JpaQueryMethod; +import org.springframework.data.jpa.repository.query.Procedure; import org.springframework.data.jpa.repository.query.QueryEnhancerSelector; import org.springframework.data.repository.aot.generate.AotRepositoryConstructorBuilder; import org.springframework.data.repository.aot.generate.AotRepositoryFragmentMetadata; import org.springframework.data.repository.aot.generate.MethodContributor; +import org.springframework.data.repository.aot.generate.QueryMetadata; import org.springframework.data.repository.aot.generate.RepositoryContributor; import org.springframework.data.repository.config.AotRepositoryContext; import org.springframework.data.repository.core.RepositoryInformation; @@ -46,6 +51,7 @@ import org.springframework.javapoet.CodeBlock; import org.springframework.javapoet.TypeName; import org.springframework.javapoet.TypeSpec; import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; /** * JPA-specific {@link RepositoryContributor} contributing an AOT repository fragment using the {@link EntityManager} @@ -113,20 +119,50 @@ public class JpaRepositoryContributor extends RepositoryContributor { // no stored procedures for now. if (queryMethod.isProcedureQuery()) { + + Procedure procedure = AnnotatedElementUtils.findMergedAnnotation(method, Procedure.class); + + MethodContributor.QueryMethodMetadataContributorBuilder builder = MethodContributor + .forQueryMethod(queryMethod); + + + if (procedure != null) { + + if (StringUtils.hasText(procedure.name())) { + return builder.metadataOnly(new NamedStoredProcedureMetadata(procedure.name())); + } + + if (StringUtils.hasText(procedure.procedureName())) { + return builder.metadataOnly(new StoredProcedureMetadata(procedure.procedureName())); + } + + if (StringUtils.hasText(procedure.value())) { + return builder.metadataOnly(new StoredProcedureMetadata(procedure.value())); + } + } + + // TODO: Better fallback. return null; } ReturnedType returnedType = queryMethod.getResultProcessor().getReturnedType(); JpaParameters parameters = queryMethod.getParameters(); + MergedAnnotation query = MergedAnnotations.from(method).get(Query.class); + + AotQueries aotQueries = queriesFactory.createQueries(repositoryInformation, query, selector, queryMethod, + returnedType); + // no KeysetScrolling for now. if (parameters.hasScrollPositionParameter()) { - return null; + return MethodContributor.forQueryMethod(queryMethod) + .metadataOnly(aotQueries.toMetadata(queryMethod.isPageQuery())); } // no dynamic projections. if (parameters.hasDynamicProjection()) { - return null; + return MethodContributor.forQueryMethod(queryMethod) + .metadataOnly(aotQueries.toMetadata(queryMethod.isPageQuery())); } if (queryMethod.isModifyingQuery()) { @@ -138,15 +174,16 @@ public class JpaRepositoryContributor extends RepositoryContributor { boolean isVoid = ClassUtils.isVoidType(returnType.getType()); if (!returnsCount && !isVoid) { - return null; + return MethodContributor.forQueryMethod(queryMethod) + .metadataOnly(aotQueries.toMetadata(queryMethod.isPageQuery())); } } - return MethodContributor.forQueryMethod(queryMethod).contribute(context -> { + return MethodContributor.forQueryMethod(queryMethod).withMetadata(aotQueries.toMetadata(queryMethod.isPageQuery())) + .contribute(context -> { CodeBlock.Builder body = CodeBlock.builder(); - MergedAnnotation query = context.getAnnotation(Query.class); MergedAnnotation nativeQuery = context.getAnnotation(NativeQuery.class); MergedAnnotation queryHints = context.getAnnotation(QueryHints.class); MergedAnnotation entityGraph = context.getAnnotation(EntityGraph.class); @@ -154,7 +191,6 @@ public class JpaRepositoryContributor extends RepositoryContributor { body.add(context.codeBlocks().logDebug("invoking [%s]".formatted(context.getMethod().getName()))); - AotQueries aotQueries = queriesFactory.createQueries(context, query, selector, queryMethod, returnedType); AotEntityGraph aotEntityGraph = entityGraphLookup.findEntityGraph(entityGraph, repositoryInformation, returnedType, queryMethod); @@ -170,4 +206,20 @@ public class JpaRepositoryContributor extends RepositoryContributor { }); } + record StoredProcedureMetadata(String procedure) implements QueryMetadata { + + @Override + public Map serialize() { + return Map.of("procedure", procedure()); + } + } + + record NamedStoredProcedureMetadata(String procedureName) implements QueryMetadata { + + @Override + public Map serialize() { + return Map.of("procedure-name", procedureName()); + } + } + } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/NamedAotQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/NamedAotQuery.java index 3f7b9293b..e3813ce13 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/NamedAotQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/NamedAotQuery.java @@ -30,12 +30,12 @@ import org.springframework.data.jpa.repository.query.PreprocessedQuery; class NamedAotQuery extends AotQuery { private final String name; - private final DeclaredQuery queryString; + private final DeclaredQuery query; private NamedAotQuery(String name, DeclaredQuery queryString, List parameterBindings) { super(parameterBindings); this.name = name; - this.queryString = queryString; + this.query = queryString; } /** @@ -51,13 +51,17 @@ class NamedAotQuery extends AotQuery { return name; } - public DeclaredQuery getQueryString() { - return queryString; + public DeclaredQuery getQuery() { + return query; + } + + public String getQueryString() { + return getQuery().getQueryString(); } @Override public boolean isNative() { - return queryString.isNative(); + return query.isNative(); } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/QueriesFactory.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/QueriesFactory.java index f31d437fc..05c49f114 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/QueriesFactory.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/QueriesFactory.java @@ -73,11 +73,11 @@ class QueriesFactory { * @param returnedType * @return */ - public AotQueries createQueries(AotQueryMethodGenerationContext context, MergedAnnotation query, + public AotQueries createQueries(RepositoryInformation repositoryInformation, MergedAnnotation query, QueryEnhancerSelector selector, JpaQueryMethod queryMethod, ReturnedType returnedType) { if (query.isPresent() && StringUtils.hasText(query.getString("value"))) { - return buildStringQuery(context.getRepositoryInformation().getDomainType(), returnedType, selector, query, + return buildStringQuery(repositoryInformation.getDomainType(), returnedType, selector, query, queryMethod); } @@ -86,7 +86,7 @@ class QueriesFactory { return buildNamedQuery(returnedType, selector, namedQuery, query, queryMethod); } - return buildPartTreeQuery(returnedType, context, query, queryMethod); + return buildPartTreeQuery(returnedType, repositoryInformation, query, queryMethod); } private AotQueries buildStringQuery(Class domainType, ReturnedType returnedType, QueryEnhancerSelector selector, @@ -159,7 +159,7 @@ class QueriesFactory { String countProjection = query.isPresent() ? query.getString("countProjection") : null; return AotQueries.from(aotQuery, it -> { - return StringAotQuery.of(aotQuery.getQueryString()).getQuery(); + return StringAotQuery.of(aotQuery.getQuery()).getQuery(); }, countProjection, selector); } @@ -197,10 +197,10 @@ class QueriesFactory { return null; } - private AotQueries buildPartTreeQuery(ReturnedType returnedType, AotQueryMethodGenerationContext context, + private AotQueries buildPartTreeQuery(ReturnedType returnedType, RepositoryInformation repositoryInformation, MergedAnnotation query, JpaQueryMethod queryMethod) { - PartTree partTree = new PartTree(context.getMethod().getName(), context.getRepositoryInformation().getDomainType()); + PartTree partTree = new PartTree(queryMethod.getName(), repositoryInformation.getDomainType()); // TODO make configurable JpqlQueryTemplates templates = JpqlQueryTemplates.UPPER; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactory.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactory.java index 2e24577f8..91314ed11 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactory.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactory.java @@ -47,6 +47,7 @@ import org.springframework.data.repository.core.RepositoryMetadata; import org.springframework.data.repository.core.support.QueryCreationListener; import org.springframework.data.repository.core.support.RepositoryComposition.RepositoryFragments; import org.springframework.data.repository.core.support.RepositoryFactorySupport; +import org.springframework.data.repository.core.support.RepositoryFragment; import org.springframework.data.repository.core.support.SurroundingTransactionDetectorMethodInterceptor; import org.springframework.data.repository.query.CachingValueExpressionDelegate; import org.springframework.data.repository.query.QueryLookupStrategy; @@ -298,7 +299,8 @@ public class JpaRepositoryFactory extends RepositoryFactorySupport { getEntityInformation(metadata.getDomainType()), entityManager, resolver, crudMethodMetadata); invokeAwareMethods(querydslJpaPredicateExecutor); - return RepositoryFragments.just(querydslJpaPredicateExecutor); + return RepositoryFragments + .of(RepositoryFragment.implemented(QuerydslPredicateExecutor.class, querydslJpaPredicateExecutor)); } return RepositoryFragments.empty(); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryBean.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryBean.java index 8e8200a37..a9d8622a4 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryBean.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryBean.java @@ -20,11 +20,11 @@ import jakarta.persistence.PersistenceContext; import java.util.function.Function; +import org.jspecify.annotations.Nullable; + import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.ObjectProvider; - -import org.jspecify.annotations.Nullable; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.config.AutowireCapableBeanFactory; import org.springframework.data.jpa.repository.query.EscapeCharacter; @@ -54,7 +54,7 @@ public class JpaRepositoryFactoryBean, S, ID> private @Nullable BeanFactory beanFactory; private @Nullable EntityManager entityManager; - private EntityPathResolver entityPathResolver; + private EntityPathResolver entityPathResolver = SimpleEntityPathResolver.INSTANCE; private EscapeCharacter escapeCharacter = EscapeCharacter.DEFAULT; private @Nullable JpaQueryMethodFactory queryMethodFactory; private @Nullable Function queryEnhancerSelectorSource; diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributorIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributorIntegrationTests.java index 9cbd86109..566a08cb7 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributorIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributorIntegrationTests.java @@ -103,21 +103,21 @@ class JpaRepositoryContributorIntegrationTests { em.clear(); } - @Test + @Test // GH-3830 void testDerivedFinderWithoutArguments() { List users = fragment.findUserNoArgumentsBy(); assertThat(users).hasSize(7).hasOnlyElementsOfType(User.class); } - @Test + @Test // GH-3830 void testFindDerivedQuerySingleEntity() { User user = fragment.findOneByEmailAddress("luke@jedi.org"); assertThat(user.getLastname()).isEqualTo("Skywalker"); } - @Test + @Test // GH-3830 void testFindDerivedFinderOptionalEntity() { Optional user = fragment.findOptionalOneByEmailAddress("yoda@jedi.org"); @@ -125,21 +125,21 @@ class JpaRepositoryContributorIntegrationTests { .hasValueSatisfying(it -> assertThat(it).extracting(User::getFirstname).isEqualTo("Yoda")); } - @Test + @Test // GH-3830 void testDerivedCount() { Long value = fragment.countUsersByLastname("Skywalker"); assertThat(value).isEqualTo(2L); } - @Test + @Test // GH-3830 void testDerivedExists() { Boolean exists = fragment.existsUserByLastname("Skywalker"); assertThat(exists).isTrue(); } - @Test + @Test // GH-3830 void testDerivedFinderReturningList() { List users = fragment.findByLastnameStartingWith("S"); @@ -147,7 +147,7 @@ class JpaRepositoryContributorIntegrationTests { "kylo@new-empire.com", "han@smuggler.net"); } - @Test + @Test // GH-3830 void shouldReturnStream() { Stream users = fragment.streamByLastnameLike("S%"); @@ -155,14 +155,14 @@ class JpaRepositoryContributorIntegrationTests { "kylo@new-empire.com", "han@smuggler.net"); } - @Test + @Test // GH-3830 void testLimitedDerivedFinder() { List users = fragment.findTop2ByLastnameStartingWith("S"); assertThat(users).hasSize(2); } - @Test + @Test // GH-3830 void testSortedDerivedFinder() { List users = fragment.findByLastnameStartingWithOrderByEmailAddress("S"); @@ -170,14 +170,14 @@ class JpaRepositoryContributorIntegrationTests { "luke@jedi.org", "vader@empire.com"); } - @Test + @Test // GH-3830 void testDerivedFinderWithLimitArgument() { List users = fragment.findByLastnameStartingWith("S", Limit.of(2)); assertThat(users).hasSize(2); } - @Test + @Test // GH-3830 void testDerivedFinderWithSort() { List users = fragment.findByLastnameStartingWith("S", Sort.by("emailAddress")); @@ -185,21 +185,21 @@ class JpaRepositoryContributorIntegrationTests { "luke@jedi.org", "vader@empire.com"); } - @Test + @Test // GH-3830 void testDerivedFinderWithSortAndLimit() { List users = fragment.findByLastnameStartingWith("S", Sort.by("emailAddress"), Limit.of(2)); assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com"); } - @Test + @Test // GH-3830 void testDerivedFinderReturningListWithPageable() { List users = fragment.findByLastnameStartingWith("S", PageRequest.of(0, 2, Sort.by("emailAddress"))); assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com"); } - @Test + @Test // GH-3830 void testDerivedFinderReturningPage() { Page page = fragment.findPageOfUsersByLastnameStartingWith("S", @@ -211,7 +211,7 @@ class JpaRepositoryContributorIntegrationTests { "kylo@new-empire.com"); } - @Test + @Test // GH-3830 void testDerivedFinderReturningSlice() { Slice slice = fragment.findSliceOfUserByLastnameStartingWith("S", @@ -223,14 +223,14 @@ class JpaRepositoryContributorIntegrationTests { "kylo@new-empire.com"); } - @Test + @Test // GH-3830 void testAnnotatedFinderReturningSingleValueWithQuery() { User user = fragment.findAnnotatedQueryByEmailAddress("yoda@jedi.org"); assertThat(user).isNotNull().extracting(User::getFirstname).isEqualTo("Yoda"); } - @Test + @Test // GH-3830 void testAnnotatedFinderReturningListWithQuery() { List users = fragment.findAnnotatedQueryByLastname("S"); @@ -238,7 +238,7 @@ class JpaRepositoryContributorIntegrationTests { "kylo@new-empire.com", "luke@jedi.org", "vader@empire.com"); } - @Test + @Test // GH-3830 void testAnnotatedFinderUsingNamedParameterPlaceholderReturningListWithQuery() { List users = fragment.findAnnotatedQueryByLastnameParameter("S"); @@ -246,7 +246,7 @@ class JpaRepositoryContributorIntegrationTests { "kylo@new-empire.com", "luke@jedi.org", "vader@empire.com"); } - @Test + @Test // GH-3830 void shouldApplyAnnotatedLikeStartsEnds() { // start with case @@ -260,7 +260,7 @@ class JpaRepositoryContributorIntegrationTests { "chewie@smuggler.net", "yoda@jedi.org"); } - @Test + @Test // GH-3830 void testAnnotatedMultilineFinderWithQuery() { List users = fragment.findAnnotatedMultilineQueryByLastname("S"); @@ -268,14 +268,14 @@ class JpaRepositoryContributorIntegrationTests { "kylo@new-empire.com", "luke@jedi.org", "vader@empire.com"); } - @Test + @Test // GH-3830 void testAnnotatedFinderWithQueryAndLimit() { List users = fragment.findAnnotatedQueryByLastname("S", Limit.of(2)); assertThat(users).hasSize(2); } - @Test + @Test // GH-3830 void testAnnotatedFinderWithQueryAndSort() { List users = fragment.findAnnotatedQueryByLastname("S", Sort.by("emailAddress")); @@ -283,21 +283,21 @@ class JpaRepositoryContributorIntegrationTests { "luke@jedi.org", "vader@empire.com"); } - @Test + @Test // GH-3830 void testAnnotatedFinderWithQueryLimitAndSort() { List users = fragment.findAnnotatedQueryByLastname("S", Limit.of(2), Sort.by("emailAddress")); assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com"); } - @Test + @Test // GH-3830 void testAnnotatedFinderReturningListWithPageable() { List users = fragment.findAnnotatedQueryByLastname("S", PageRequest.of(0, 2, Sort.by("emailAddress"))); assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com"); } - @Test + @Test // GH-3830 void testAnnotatedFinderReturningPage() { Page page = fragment.findAnnotatedQueryPageOfUsersByLastname("S", @@ -309,7 +309,7 @@ class JpaRepositoryContributorIntegrationTests { "kylo@new-empire.com"); } - @Test + @Test // GH-3830 void testPagingAnnotatedQueryWithSort() { Page page = fragment.findAnnotatedQueryPageWithStaticSort("S", PageRequest.of(0, 2, Sort.unsorted())); @@ -320,7 +320,7 @@ class JpaRepositoryContributorIntegrationTests { "vader@empire.com"); } - @Test + @Test // GH-3830 void testAnnotatedFinderReturningSlice() { Slice slice = fragment.findAnnotatedQuerySliceOfUsersByLastname("S", @@ -331,7 +331,7 @@ class JpaRepositoryContributorIntegrationTests { "kylo@new-empire.com"); } - @Test + @Test // GH-3830 void shouldResolveTemplatedQuery() { User user = fragment.findTemplatedByEmailAddress("han@smuggler.net"); @@ -340,7 +340,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(user.getFirstname()).isEqualTo("Han"); } - @Test + @Test // GH-3830 void shouldEvaluateExpressionByName() { User user = fragment.findValueExpressionNamedByEmailAddress("han@smuggler.net"); @@ -349,7 +349,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(user.getFirstname()).isEqualTo("Han"); } - @Test + @Test // GH-3830 void shouldEvaluateExpressionByPosition() { User user = fragment.findValueExpressionPositionalByEmailAddress("han@smuggler.net"); @@ -358,7 +358,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(user.getFirstname()).isEqualTo("Han"); } - @Test + @Test // GH-3830 void testDerivedFinderReturningListOfProjections() { List users = fragment.findUserProjectionByLastnameStartingWith("S"); @@ -366,7 +366,7 @@ class JpaRepositoryContributorIntegrationTests { "kylo@new-empire.com", "luke@jedi.org", "vader@empire.com"); } - @Test + @Test // GH-3830 void testDerivedFinderReturningPageOfProjections() { Page page = fragment.findUserProjectionByLastnameStartingWith("S", @@ -383,7 +383,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(noResults).isEmpty(); } - @Test + @Test // GH-3830 void shouldApplySqlResultSetMapping() { User.EmailDto result = fragment.findEmailDtoByNativeQuery(kylo.getId()); @@ -391,7 +391,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(result.getOne()).isEqualTo(kylo.getEmailAddress()); } - @Test + @Test // GH-3830 void shouldApplyNamedDto() { // named queries cannot be rewritten @@ -399,7 +399,7 @@ class JpaRepositoryContributorIntegrationTests { .isThrownBy(() -> fragment.findNamedDtoEmailAddress(kylo.getEmailAddress())); } - @Test + @Test // GH-3830 void shouldApplyDerivedDto() { UserRepository.Names names = fragment.findDtoByEmailAddress(kylo.getEmailAddress()); @@ -408,7 +408,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(names.firstname()).isEqualTo(kylo.getFirstname()); } - @Test + @Test // GH-3830 void shouldApplyDerivedDtoPage() { Page names = fragment.findDtoPageByEmailAddress(kylo.getEmailAddress(), PageRequest.of(0, 1)); @@ -417,7 +417,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(names.getContent().get(0).lastname()).isEqualTo(kylo.getLastname()); } - @Test + @Test // GH-3830 void shouldApplyAnnotatedDto() { UserRepository.Names names = fragment.findAnnotatedDtoEmailAddress(kylo.getEmailAddress()); @@ -426,7 +426,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(names.firstname()).isEqualTo(kylo.getFirstname()); } - @Test + @Test // GH-3830 void shouldApplyAnnotatedDtoPage() { Page names = fragment.findAnnotatedDtoPageByEmailAddress(kylo.getEmailAddress(), @@ -436,7 +436,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(names.getContent().get(0).lastname()).isEqualTo(kylo.getLastname()); } - @Test + @Test // GH-3830 void shouldApplyDerivedQueryInterfaceProjection() { UserRepository.EmailOnly result = fragment.findEmailProjectionById(kylo.getId()); @@ -444,7 +444,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(result.getEmailAddress()).isEqualTo(kylo.getEmailAddress()); } - @Test + @Test // GH-3830 void shouldApplyInterfaceProjectionPage() { Page result = fragment.findProjectedPageByEmailAddress(kylo.getEmailAddress(), @@ -454,7 +454,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(result.getContent().get(0).getEmailAddress()).isEqualTo(kylo.getEmailAddress()); } - @Test + @Test // GH-3830 void shouldApplyInterfaceProjectionSlice() { Slice result = fragment.findProjectedSliceByEmailAddress(kylo.getEmailAddress(), @@ -464,7 +464,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(result.getContent().get(0).getEmailAddress()).isEqualTo(kylo.getEmailAddress()); } - @Test + @Test // GH-3830 void shouldApplyInterfaceProjectionToDerivedQueryStream() { Stream result = fragment.streamProjectedByEmailAddress(kylo.getEmailAddress()); @@ -472,7 +472,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(result).hasSize(1).map(UserRepository.EmailOnly::getEmailAddress).contains(kylo.getEmailAddress()); } - @Test + @Test // GH-3830 void shouldApplyAnnotatedQueryInterfaceProjection() { UserRepository.EmailOnly result = fragment.findAnnotatedEmailProjectionByEmailAddress(kylo.getEmailAddress()); @@ -480,7 +480,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(result.getEmailAddress()).isEqualTo(kylo.getEmailAddress()); } - @Test + @Test // GH-3830 void shouldApplyAnnotatedInterfaceProjectionQueryPage() { Page result = fragment.findAnnotatedProjectedPageByEmailAddress(kylo.getEmailAddress(), @@ -490,7 +490,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(result.getContent().get(0).getEmailAddress()).isEqualTo(kylo.getEmailAddress()); } - @Test + @Test // GH-3830 void shouldApplyNativeInterfaceProjection() { UserRepository.EmailOnly result = fragment.findEmailProjectionByNativeQuery(kylo.getId()); @@ -498,7 +498,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(result.getEmailAddress()).isEqualTo(kylo.getEmailAddress()); } - @Test + @Test // GH-3830 void shouldApplyNamedQueryInterfaceProjection() { UserRepository.EmailOnly result = fragment.findNamedProjectionEmailAddress(kylo.getEmailAddress()); @@ -506,7 +506,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(result.getEmailAddress()).isEqualTo(kylo.getEmailAddress()); } - @Test + @Test // GH-3830 void testDerivedDeleteSingle() { User result = fragment.deleteByEmailAddress("yoda@jedi.org"); @@ -519,14 +519,14 @@ class JpaRepositoryContributorIntegrationTests { assertThat(yodaShouldBeGone).isNull(); } - @Test + @Test // GH-3830 void shouldOmitAnnotatedDeleteReturningDomainType() { assertThatException().isThrownBy(() -> fragment.deleteAnnotatedQueryByEmailAddress("foo")) .withRootCauseInstanceOf(NoSuchMethodException.class); } - @Test + @Test // GH-3830 void shouldApplyModifying() { int affected = fragment.renameAllUsersTo("Jones"); @@ -539,7 +539,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(yodaShouldBeGone).isNull(); } - @Test + @Test // GH-3830 void nativeQuery() { Page page = fragment.findByNativeQueryWithPageable(PageRequest.of(0, 2)); @@ -549,14 +549,14 @@ class JpaRepositoryContributorIntegrationTests { assertThat(page.getContent()).containsExactly("Anakin", "Ben"); } - @Test + @Test // GH-3830 void shouldUseNamedQuery() { User user = fragment.findByEmailAddress("luke@jedi.org"); assertThat(user.getLastname()).isEqualTo("Skywalker"); } - @Test + @Test // GH-3830 void shouldUseNamedQueryAndDeriveCountQuery() { Page user = fragment.findPagedByEmailAddress(PageRequest.of(0, 1), "luke@jedi.org"); @@ -565,7 +565,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(user.getTotalElements()).isEqualTo(1); } - @Test + @Test // GH-3830 void shouldUseNamedQueryAndProvidedCountQuery() { Page user = fragment.findPagedWithCountByEmailAddress(PageRequest.of(0, 1), "luke@jedi.org"); @@ -574,7 +574,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(user.getTotalElements()).isEqualTo(1); } - @Test + @Test // GH-3830 void shouldUseNamedQueryAndNamedCountQuery() { Page user = fragment.findPagedWithNamedCountByEmailAddress(PageRequest.of(0, 1), "luke@jedi.org"); @@ -583,13 +583,13 @@ class JpaRepositoryContributorIntegrationTests { assertThat(user.getTotalElements()).isEqualTo(1); } - @Test + @Test // GH-3830 void shouldApplyQueryHints() { assertThatIllegalArgumentException().isThrownBy(() -> fragment.findHintedByLastname("Skywalker")) .withMessageContaining("No enum constant jakarta.persistence.CacheStoreMode.foo"); } - @Test + @Test // GH-3830 void shouldApplyNamedEntityGraph() { User chewie = fragment.findWithNamedEntityGraphByFirstname("Chewbacca"); @@ -598,7 +598,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(chewie.getRoles()).isNotInstanceOf(HibernateProxy.class); } - @Test + @Test // GH-3830 void shouldApplyDeclaredEntityGraph() { User chewie = fragment.findWithDeclaredEntityGraphByFirstname("Chewbacca"); @@ -610,7 +610,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(han.getManager()).isInstanceOf(HibernateProxy.class); } - @Test + @Test // GH-3830 void shouldQuerySubtype() { SpecialUser snoopy = new SpecialUser(); @@ -625,7 +625,7 @@ class JpaRepositoryContributorIntegrationTests { assertThat(result).isInstanceOf(SpecialUser.class); } - @Test + @Test // GH-3830 void shouldApplyQueryRewriter() { User result = fragment.findAndApplyQueryRewriter(kylo.getEmailAddress()); diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryMetadataIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryMetadataIntegrationTests.java new file mode 100644 index 000000000..0cdde1ef0 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryMetadataIntegrationTests.java @@ -0,0 +1,178 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.aot; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.*; +import static org.assertj.core.api.Assertions.*; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.AbstractApplicationContext; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.annotation.Transactional; + +/** + * Integration tests for the {@link UserRepository} JSON metadata. + * + * @author Mark Paluch + */ +@SpringJUnitConfig(classes = JpaRepositoryMetadataIntegrationTests.JpaRepositoryContributorConfiguration.class) +@Transactional +class JpaRepositoryMetadataIntegrationTests { + + @Autowired AbstractApplicationContext context; + + @Configuration + static class JpaRepositoryContributorConfiguration extends AotFragmentTestConfigurationSupport { + public JpaRepositoryContributorConfiguration() { + super(UserRepository.class); + } + } + + @Test // GH-3830 + void shouldDocumentBase() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).isObject() // + .containsEntry("name", UserRepository.class.getName()) // + .containsEntry("module", "") // TODO: JPA should be here + .containsEntry("type", "IMPERATIVE"); + } + + @Test // GH-3830 + void shouldDocumentDerivedQuery() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[0]").isObject().containsEntry("name", "countUsersByLastname"); + assertThatJson(json).inPath("$.methods[0].query").isObject().containsEntry("query", + "SELECT COUNT(u) FROM org.springframework.data.jpa.domain.sample.User u WHERE u.lastname = ?1"); + } + + @Test // GH-3830 + void shouldDocumentPagedQuery() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'findAndApplyQueryRewriter')].query").isArray().element(1) + .isObject().containsEntry("query", "select u from OTHER u where u.emailAddress = ?1") + .containsEntry("count-query", "select count(u) from OTHER u where u.emailAddress = ?1"); + } + + @Test // GH-3830 + void shouldDocumentQueryWithExpression() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'findValueExpressionNamedByEmailAddress')].query").isArray() + .first().isObject().containsEntry("query", "select u from User u where u.emailAddress = :__$synthetic$__1"); + } + + @Test // GH-3830 + void shouldDocumentNamedQuery() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'findPagedWithNamedCountByEmailAddress')].query").isArray() + .first().isObject().containsEntry("name", "User.findByEmailAddress") + .containsEntry("query", "SELECT u FROM User u WHERE u.emailAddress = ?1") + .containsEntry("count-name", "User.findByEmailAddress.count-provided") + .containsEntry("count-query", "SELECT count(u) FROM User u WHERE u.emailAddress = ?1"); + } + + @Test // GH-3830 + void shouldDocumentNamedProcedure() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'namedProcedure')].query").isArray().first().isObject() + .containsEntry("procedure-name", "User.plus1IO"); + } + + @Test // GH-3830 + void shouldDocumentProvidedProcedure() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'providedProcedure')].query").isArray().first().isObject() + .containsEntry("procedure", "sp_add"); + } + + @Test // GH-3830 + void shouldDocumentBaseFragment() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'existsById')].fragment").isArray().first().isObject() + .containsEntry("fragment", "org.springframework.data.jpa.repository.support.SimpleJpaRepository"); + } + + private Resource getResource() { + + String location = UserRepository.class.getPackageName().replace('.', '/') + "/" + + UserRepository.class.getSimpleName() + ".json"; + return new UrlResource(context.getBeanFactory().getBeanClassLoader().getResource(location)); + } + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/StubRepositoryInformation.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/StubRepositoryInformation.java index f9c4042d3..589b95a5f 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/StubRepositoryInformation.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/StubRepositoryInformation.java @@ -107,7 +107,12 @@ class StubRepositoryInformation implements RepositoryInformation { @Override public boolean isQueryMethod(Method method) { - return false; + + if (isBaseClassMethod(method)) { + return false; + } + + return true; } @Override @@ -124,4 +129,10 @@ class StubRepositoryInformation implements RepositoryInformation { public Method getTargetClassMethod(Method method) { return null; } + + @Override + public RepositoryComposition getRepositoryComposition() { + return baseComposition; + } + } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/UserRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/UserRepository.java index 3d5eeb930..b95cd8837 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/UserRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/UserRepository.java @@ -33,7 +33,9 @@ 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.QueryRewriter; +import org.springframework.data.jpa.repository.query.Procedure; import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; /** * @author Christoph Strobl @@ -236,6 +238,15 @@ interface UserRepository extends CrudRepository { @Query(value = "select u from OTHER u where u.emailAddress = ?1", queryRewriter = MyQueryRewriter.class) Page findAndApplyQueryRewriter(String emailAddress, Pageable pageable); + // ------------------------------------------------------------------------- + // Unsupported: Procedures + // ------------------------------------------------------------------------- + @Procedure(name = "User.plus1IO") // Named + Integer namedProcedure(@Param("arg") Integer arg); + + @Procedure(value = "sp_add") // Stored procedure + Integer providedProcedure(@Param("arg") Integer arg); + interface EmailOnly { String getEmailAddress(); }