Add support for JSON repository metadata.

See #3830
This commit is contained in:
Mark Paluch
2025-04-07 12:21:14 +02:00
parent be4d8528b3
commit eb1266888f
11 changed files with 410 additions and 86 deletions

View File

@@ -88,16 +88,31 @@
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>net.javacrumbs.json-unit</groupId>
<artifactId>json-unit-assertj</artifactId>
<version>4.1.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.hsqldb</groupId>
<artifactId>hsqldb</artifactId>

View File

@@ -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<String, Object> serialize() {
Map<String, Object> 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;
}
}
}

View File

@@ -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<JpaQueryMethod> 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> 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> query = context.getAnnotation(Query.class);
MergedAnnotation<NativeQuery> nativeQuery = context.getAnnotation(NativeQuery.class);
MergedAnnotation<QueryHints> queryHints = context.getAnnotation(QueryHints.class);
MergedAnnotation<EntityGraph> 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<String, Object> serialize() {
return Map.of("procedure", procedure());
}
}
record NamedStoredProcedureMetadata(String procedureName) implements QueryMetadata {
@Override
public Map<String, Object> serialize() {
return Map.of("procedure-name", procedureName());
}
}
}

View File

@@ -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<ParameterBinding> 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();
}
}

View File

@@ -73,11 +73,11 @@ class QueriesFactory {
* @param returnedType
* @return
*/
public AotQueries createQueries(AotQueryMethodGenerationContext context, MergedAnnotation<Query> query,
public AotQueries createQueries(RepositoryInformation repositoryInformation, MergedAnnotation<Query> 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> 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;

View File

@@ -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();

View File

@@ -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<T extends Repository<S, ID>, 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<BeanFactory, QueryEnhancerSelector> queryEnhancerSelectorSource;

View File

@@ -103,21 +103,21 @@ class JpaRepositoryContributorIntegrationTests {
em.clear();
}
@Test
@Test // GH-3830
void testDerivedFinderWithoutArguments() {
List<User> 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> 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<User> users = fragment.findByLastnameStartingWith("S");
@@ -147,7 +147,7 @@ class JpaRepositoryContributorIntegrationTests {
"kylo@new-empire.com", "han@smuggler.net");
}
@Test
@Test // GH-3830
void shouldReturnStream() {
Stream<User> users = fragment.streamByLastnameLike("S%");
@@ -155,14 +155,14 @@ class JpaRepositoryContributorIntegrationTests {
"kylo@new-empire.com", "han@smuggler.net");
}
@Test
@Test // GH-3830
void testLimitedDerivedFinder() {
List<User> users = fragment.findTop2ByLastnameStartingWith("S");
assertThat(users).hasSize(2);
}
@Test
@Test // GH-3830
void testSortedDerivedFinder() {
List<User> users = fragment.findByLastnameStartingWithOrderByEmailAddress("S");
@@ -170,14 +170,14 @@ class JpaRepositoryContributorIntegrationTests {
"luke@jedi.org", "vader@empire.com");
}
@Test
@Test // GH-3830
void testDerivedFinderWithLimitArgument() {
List<User> users = fragment.findByLastnameStartingWith("S", Limit.of(2));
assertThat(users).hasSize(2);
}
@Test
@Test // GH-3830
void testDerivedFinderWithSort() {
List<User> 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<User> 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<User> 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<User> page = fragment.findPageOfUsersByLastnameStartingWith("S",
@@ -211,7 +211,7 @@ class JpaRepositoryContributorIntegrationTests {
"kylo@new-empire.com");
}
@Test
@Test // GH-3830
void testDerivedFinderReturningSlice() {
Slice<User> 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<User> 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<User> 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<User> 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<User> users = fragment.findAnnotatedQueryByLastname("S", Limit.of(2));
assertThat(users).hasSize(2);
}
@Test
@Test // GH-3830
void testAnnotatedFinderWithQueryAndSort() {
List<User> 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<User> 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<User> 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<User> page = fragment.findAnnotatedQueryPageOfUsersByLastname("S",
@@ -309,7 +309,7 @@ class JpaRepositoryContributorIntegrationTests {
"kylo@new-empire.com");
}
@Test
@Test // GH-3830
void testPagingAnnotatedQueryWithSort() {
Page<User> 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<User> 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<UserDtoProjection> 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<UserDtoProjection> 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<UserRepository.Names> 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<UserRepository.Names> 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<UserRepository.EmailOnly> 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<UserRepository.EmailOnly> 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<UserRepository.EmailOnly> 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<UserRepository.EmailOnly> 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<String> 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> 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> 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> 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());

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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<User, Integer> {
@Query(value = "select u from OTHER u where u.emailAddress = ?1", queryRewriter = MyQueryRewriter.class)
Page<User> 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();
}