Add support for Entity Graphs.

See #3830
This commit is contained in:
Mark Paluch
2025-04-02 14:11:38 +02:00
parent 4b0a83a97b
commit e38b219898
5 changed files with 197 additions and 10 deletions

View File

@@ -0,0 +1,31 @@
/*
* 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 java.util.List;
import org.jspecify.annotations.Nullable;
import org.springframework.data.jpa.repository.EntityGraph;
/**
* AOT representation of an resolved entity graph. The graph can be either named or defined by attribute paths in case
* the named entity graph cannot be looked up.
*
* @author Mark Paluch
*/
record AotEntityGraph(@Nullable String name, EntityGraph.EntityGraphType type, List<String> attributePaths) {
}

View File

@@ -79,6 +79,7 @@ class JpaCodeBlocks {
private String queryVariableName = "query";
private @Nullable AotQueries queries;
private MergedAnnotation<QueryHints> queryHints = MergedAnnotation.missing();
private @Nullable AotEntityGraph entityGraph;
private @Nullable String sqlResultSetMapping;
private @Nullable Class<?> queryReturnType;
@@ -112,6 +113,11 @@ class JpaCodeBlocks {
return this;
}
public QueryBlockBuilder entityGraph(@Nullable AotEntityGraph entityGraph) {
this.entityGraph = entityGraph;
return this;
}
public QueryBlockBuilder queryReturnType(@Nullable Class<?> queryReturnType) {
this.queryReturnType = queryReturnType;
return this;
@@ -162,7 +168,7 @@ class JpaCodeBlocks {
}
builder.add(createQuery(queryVariableName, queryStringNameVariableName, queries.result(),
this.sqlResultSetMapping, this.queryHints, this.queryReturnType));
this.sqlResultSetMapping, this.queryHints, this.entityGraph, this.queryReturnType));
builder.add(applyLimits(queries.result().isExists()));
@@ -173,7 +179,7 @@ class JpaCodeBlocks {
boolean queryHints = this.queryHints.isPresent() && this.queryHints.getBoolean("forCounting");
builder.add(createQuery(countQueryVariableName, countQueryStringNameVariableName, queries.count(), null,
queryHints ? this.queryHints : MergedAnnotation.missing(), Long.class));
queryHints ? this.queryHints : MergedAnnotation.missing(), null, Long.class));
builder.addStatement("return ($T) $L.getSingleResult()", Long.class, countQueryVariableName);
// end control flow does not work well with lambdas
@@ -190,8 +196,7 @@ class JpaCodeBlocks {
builder.beginControlFlow("if ($L.isSorted())", sort);
builder.addStatement("$T declaredQuery = $T.$L($L)", DeclaredQuery.class, DeclaredQuery.class,
queries != null && queries.isNative() ? "nativeQuery" : "jpqlQuery",
queryString);
queries != null && queries.isNative() ? "nativeQuery" : "jpqlQuery", queryString);
builder.addStatement("$L = rewriteQuery(declaredQuery, $L, $T.class)", queryString, sort, actualReturnType);
builder.endControlFlow();
@@ -238,13 +243,17 @@ class JpaCodeBlocks {
private CodeBlock createQuery(String queryVariableName, @Nullable String queryStringNameVariableName,
AotQuery query, @Nullable String sqlResultSetMapping, MergedAnnotation<QueryHints> queryHints,
@Nullable Class<?> queryReturnType) {
@Nullable AotEntityGraph entityGraph, @Nullable Class<?> queryReturnType) {
Builder builder = CodeBlock.builder();
builder.add(
doCreateQuery(queryVariableName, queryStringNameVariableName, query, sqlResultSetMapping, queryReturnType));
if (entityGraph != null) {
builder.add(applyEntityGraph(entityGraph, queryVariableName));
}
if (queryHints.isPresent()) {
builder.add(applyHints(queryVariableName, queryHints));
builder.add("\n");
@@ -363,6 +372,43 @@ class JpaCodeBlocks {
throw new UnsupportedOperationException("Not supported yet");
}
private CodeBlock applyEntityGraph(AotEntityGraph entityGraph, String queryVariableName) {
CodeBlock.Builder builder = CodeBlock.builder();
if (StringUtils.hasText(entityGraph.name())) {
builder.addStatement("$T<?> entityGraph = $L.getEntityGraph($S)", jakarta.persistence.EntityGraph.class,
context.fieldNameOf(EntityManager.class), entityGraph.name());
} else {
builder.addStatement("$T<$T> entityGraph = $L.createEntityGraph($T.class)",
jakarta.persistence.EntityGraph.class, context.getActualReturnType().getType(),
context.fieldNameOf(EntityManager.class), context.getActualReturnType().getType());
for (String attributePath : entityGraph.attributePaths()) {
String[] pathComponents = StringUtils.delimitedListToStringArray(attributePath, ".");
StringBuilder chain = new StringBuilder("entityGraph");
for (int i = 0; i < pathComponents.length; i++) {
if (i < pathComponents.length - 1) {
chain.append(".addSubgraph($S)");
} else {
chain.append(".addAttributeNodes($S)");
}
}
builder.addStatement(chain.toString(), (Object[]) pathComponents);
}
builder.addStatement("$L.setHint($S, entityGraph)", queryVariableName, entityGraph.type().getKey());
}
return builder.build();
}
private CodeBlock applyHints(String queryVariableName, MergedAnnotation<QueryHints> queryHints) {
Builder hintsBuilder = CodeBlock.builder();
@@ -505,5 +551,4 @@ class JpaCodeBlocks {
}
}

View File

@@ -15,6 +15,7 @@
*/
package org.springframework.data.jpa.repository.aot;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.Tuple;
@@ -23,16 +24,22 @@ import jakarta.persistence.metamodel.Metamodel;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.function.UnaryOperator;
import org.jspecify.annotations.Nullable;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.core.annotation.MergedAnnotation;
import org.springframework.data.jpa.provider.PersistenceProvider;
import org.springframework.data.jpa.provider.QueryExtractor;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.NativeQuery;
import org.springframework.data.jpa.repository.Query;
@@ -166,15 +173,17 @@ public class JpaRepositoryContributor extends RepositoryContributor {
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);
MergedAnnotation<Modifying> modifying = context.getAnnotation(Modifying.class);
body.add(context.codeBlocks().logDebug("invoking [%s]".formatted(context.getMethod().getName())));
AotQueries aotQueries = getQueries(context, query, selector, queryMethod, returnedType);
AotEntityGraph aotEntityGraph = getAotEntityGraph(entityGraph, repositoryInformation, returnedType, queryMethod);
body.add(JpaCodeBlocks.queryBuilder(context, queryMethod).filter(aotQueries)
.queryReturnType(getQueryReturnType(aotQueries.result(), returnedType, context)).nativeQuery(nativeQuery)
.queryHints(queryHints).build());
.queryHints(queryHints).entityGraph(aotEntityGraph).build());
body.add(
JpaCodeBlocks.executionBuilder(context, queryMethod).modifying(modifying).query(aotQueries.result()).build());
@@ -360,4 +369,65 @@ public class JpaRepositoryContributor extends RepositoryContributor {
return result;
}
@SuppressWarnings("unchecked")
private @Nullable AotEntityGraph getAotEntityGraph(MergedAnnotation<EntityGraph> entityGraph,
RepositoryInformation information, ReturnedType returnedType, JpaQueryMethod queryMethod) {
if (!entityGraph.isPresent()) {
return null;
}
EntityGraph.EntityGraphType type = entityGraph.getEnum("type", EntityGraph.EntityGraphType.class);
String[] attributePaths = entityGraph.getStringArray("attributePaths");
Collection<String> entityGraphNames = getEntityGraphNames(entityGraph, information, queryMethod);
List<Class<?>> candidates = Arrays.asList(returnedType.getDomainType(), returnedType.getReturnedType(),
returnedType.getTypeToRead());
for (Class<?> candidate : candidates) {
Map<String, jakarta.persistence.EntityGraph<?>> namedEntityGraphs = emf
.getNamedEntityGraphs(Class.class.cast(candidate));
if (namedEntityGraphs.isEmpty()) {
continue;
}
for (String entityGraphName : entityGraphNames) {
if (namedEntityGraphs.containsKey(entityGraphName)) {
return new AotEntityGraph(entityGraphName, type, Collections.emptyList());
}
}
}
if (attributePaths.length > 0) {
return new AotEntityGraph(null, type, Arrays.asList(attributePaths));
}
return null;
}
private Set<String> getEntityGraphNames(MergedAnnotation<EntityGraph> entityGraph, RepositoryInformation information,
JpaQueryMethod queryMethod) {
Set<String> entityGraphNames = new LinkedHashSet<>();
String value = entityGraph.getString("value");
if (StringUtils.hasText(value)) {
entityGraphNames.add(value);
}
entityGraphNames.add(queryMethod.getNamedQueryName());
entityGraphNames.add(getFallbackEntityGraphName(information, queryMethod));
return entityGraphNames;
}
private String getFallbackEntityGraphName(RepositoryInformation information, JpaQueryMethod queryMethod) {
Class<?> domainType = information.getDomainType();
Entity entity = AnnotatedElementUtils.findMergedAnnotation(domainType, Entity.class);
String entityName = entity != null && StringUtils.hasText(entity.name()) ? entity.name()
: domainType.getSimpleName();
return entityName + "." + queryMethod.getName();
}
}

View File

@@ -23,6 +23,7 @@ import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
import org.hibernate.proxy.HibernateProxy;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -33,6 +34,7 @@ import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Slice;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.sample.Role;
import org.springframework.data.jpa.domain.sample.User;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.transaction.annotation.Transactional;
@@ -50,6 +52,7 @@ class JpaRepositoryContributorIntegrationTests {
@Autowired UserRepository fragment;
@Autowired EntityManager em;
User luke, leia, han, chewbacca, yoda, vader, kylo;
Role smuggler, jedi, imperium;
@Configuration
static class JpaRepositoryContributorConfiguration extends AotFragmentTestConfigurationSupport {
@@ -62,17 +65,26 @@ class JpaRepositoryContributorIntegrationTests {
void beforeEach() {
em.createQuery("DELETE FROM %s".formatted(User.class.getName())).executeUpdate();
em.createQuery("DELETE FROM %s".formatted(Role.class.getName())).executeUpdate();
smuggler = em.merge(new Role("Smuggler"));
jedi = em.merge(new Role("Jedi"));
imperium = em.merge(new Role("Imperium"));
luke = new User("Luke", "Skywalker", "luke@jedi.org");
luke.addRole(jedi);
em.persist(luke);
leia = new User("Leia", "Organa", "leia@resistance.gov");
em.persist(leia);
han = new User("Han", "Solo", "han@smuggler.net");
han.setManager(luke);
em.persist(han);
chewbacca = new User("Chewbacca", "n/a", "chewie@smuggler.net");
chewbacca.setManager(han);
chewbacca.addRole(smuggler);
em.persist(chewbacca);
yoda = new User("Yoda", "n/a", "yoda@jedi.org");
@@ -83,6 +95,9 @@ class JpaRepositoryContributorIntegrationTests {
kylo = new User("Ben", "Solo", "kylo@new-empire.com");
em.persist(kylo);
em.flush();
em.clear();
}
@Test
@@ -388,6 +403,27 @@ class JpaRepositoryContributorIntegrationTests {
.withMessageContaining("No enum constant jakarta.persistence.CacheStoreMode.foo");
}
@Test
void shouldApplyNamedEntityGraph() {
User chewie = fragment.findWithNamedEntityGraphByFirstname("Chewbacca");
assertThat(chewie.getManager()).isInstanceOf(HibernateProxy.class);
assertThat(chewie.getRoles()).isNotInstanceOf(HibernateProxy.class);
}
@Test
void shouldApplyDeclaredEntityGraph() {
User chewie = fragment.findWithDeclaredEntityGraphByFirstname("Chewbacca");
assertThat(chewie.getRoles()).isNotInstanceOf(HibernateProxy.class);
User han = chewie.getManager();
assertThat(han.getRoles()).isNotInstanceOf(HibernateProxy.class);
assertThat(han.getManager()).isInstanceOf(HibernateProxy.class);
}
@Test
void testDerivedFinderReturningPageOfProjections() {
@@ -464,7 +500,6 @@ class JpaRepositoryContributorIntegrationTests {
void todo() {
// entity graphs
// interface projections
// dynamic projections
// class type parameter

View File

@@ -27,6 +27,7 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.sample.User;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.NativeQuery;
import org.springframework.data.jpa.repository.Query;
@@ -111,7 +112,6 @@ interface UserRepository extends CrudRepository<User, Integer> {
@Query("select u from User u where u.lastname like ?1%")
Slice<User> findAnnotatedQuerySliceOfUsersByLastname(String lastname, Pageable pageable);
// Value Expressions
@Query("select u from #{#entityName} u where u.emailAddress = ?1")
@@ -139,7 +139,7 @@ interface UserRepository extends CrudRepository<User, Integer> {
// native queries
@Query(value = "SELECT firstname FROM SD_User ORDER BY UCASE(firstname)", countQuery = "SELECT count(*) FROM SD_User",
nativeQuery = true)
nativeQuery = true)
Page<String> findByNativeQueryWithPageable(Pageable pageable);
// projections
@@ -158,6 +158,12 @@ interface UserRepository extends CrudRepository<User, Integer> {
@QueryHints(value = { @QueryHint(name = "jakarta.persistence.cache.storeMode", value = "foo") }, forCounting = false)
List<User> findHintedByLastname(String lastname);
@EntityGraph(type = EntityGraph.EntityGraphType.FETCH, value = "User.overview")
User findWithNamedEntityGraphByFirstname(String firstname);
@EntityGraph(type = EntityGraph.EntityGraphType.FETCH, attributePaths = { "roles", "manager.roles" })
User findWithDeclaredEntityGraphByFirstname(String firstname);
List<User> findByLastnameStartingWithOrderByFirstname(String lastname, Limit limit);
List<User> findByLastname(String lastname, Sort sort);