Skip to content

Commit 715021d

Browse files
authored
Add support for lazy loaded attribute fetching with JPA entity graph query hint (#387)
* Add support for lazy loaded attribute fetching with JPA entity graph hint * update hint to use JAKARTA_PERSISTENCE_FETCHGRAPH * Add lazy load bytecode enhancement unit test coverage for books model
1 parent 17120b6 commit 715021d

File tree

11 files changed

+236
-18
lines changed

11 files changed

+236
-18
lines changed

build/pom.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@
1515

1616
<dependencyManagement>
1717
<dependencies>
18+
<dependency>
19+
<groupId>org.hibernate.orm</groupId>
20+
<artifactId>hibernate-platform</artifactId>
21+
<version>${hibernate.version}</version>
22+
<type>pom</type>
23+
<scope>import</scope>
24+
</dependency>
1825
<dependency>
1926
<groupId>org.springframework.boot</groupId>
2027
<artifactId>spring-boot-dependencies</artifactId>
@@ -75,6 +82,11 @@
7582
</execution>
7683
</executions>
7784
</plugin>
85+
<plugin>
86+
<groupId>org.hibernate.orm.tooling</groupId>
87+
<artifactId>hibernate-enhance-maven-plugin</artifactId>
88+
<version>${hibernate.version}</version>
89+
</plugin>
7890
</plugins>
7991
</pluginManagement>
8092
</build>

dependencies/pom.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
<joda-time.version>2.12.5</joda-time.version>
2121
<graphql-java-extended-scalars.version>20.2</graphql-java-extended-scalars.version>
2222
<jakarta.persistence-api.version>3.1.0</jakarta.persistence-api.version>
23+
<hibernate.version>6.2.3.Final</hibernate.version>
2324
</properties>
2425

2526
<dependencyManagement>

scalars/src/main/java/com/introproventures/graphql/jpa/query/schema/JavaScalars.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ public class JavaScalars {
139139
newScalarType("SqlTimestamp", "SQL Timestamp type", new GraphQLSqlTimestampCoercing())
140140
);
141141
scalarsRegistry.put(Byte[].class, newScalarType("ByteArray", "ByteArray type", new GraphQLLOBCoercing()));
142+
scalarsRegistry.put(byte[].class, newScalarType("ByteArray", "ByteArray type", new GraphQLLOBCoercing()));
142143
scalarsRegistry.put(Instant.class, newScalarType("Instant", "Instant type", new GraphQLInstantCoercing()));
143144
scalarsRegistry.put(
144145
ZonedDateTime.class,
@@ -158,6 +159,10 @@ public static Collection<GraphQLScalarType> scalars() {
158159
return Collections.unmodifiableCollection(scalarsRegistry.values());
159160
}
160161

162+
public static boolean contains(Class<?> key) {
163+
return scalarsRegistry.containsKey(key);
164+
}
165+
161166
public static GraphQLScalarType of(Class<?> key) {
162167
return scalarsRegistry.computeIfAbsent(key, JavaScalars::computeGraphQLScalarType);
163168
}
@@ -669,7 +674,7 @@ public static class GraphQLLOBCoercing implements Coercing<Object, Object> {
669674
@Override
670675
public Object serialize(Object input) {
671676
if (input.getClass() == byte[].class) {
672-
return input;
677+
return new String((byte[]) input, StandardCharsets.UTF_8);
673678
}
674679
return null;
675680
}

schema/src/main/java/com/introproventures/graphql/jpa/query/schema/impl/GraphQLJpaQueryFactory.java

Lines changed: 113 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import static com.introproventures.graphql.jpa.query.support.GraphQLSupport.isLogicalArgument;
2323
import static com.introproventures.graphql.jpa.query.support.GraphQLSupport.isPageArgument;
2424
import static com.introproventures.graphql.jpa.query.support.GraphQLSupport.isWhereArgument;
25+
import static com.introproventures.graphql.jpa.query.support.GraphQLSupport.selections;
2526
import static graphql.introspection.Introspection.SchemaMetaFieldDef;
2627
import static graphql.introspection.Introspection.TypeMetaFieldDef;
2728
import static graphql.introspection.Introspection.TypeNameMetaFieldDef;
@@ -60,7 +61,9 @@
6061
import graphql.schema.GraphQLScalarType;
6162
import graphql.schema.GraphQLSchema;
6263
import graphql.schema.GraphQLType;
64+
import jakarta.persistence.EntityGraph;
6365
import jakarta.persistence.EntityManager;
66+
import jakarta.persistence.Subgraph;
6467
import jakarta.persistence.TypedQuery;
6568
import jakarta.persistence.criteria.AbstractQuery;
6669
import jakarta.persistence.criteria.CriteriaBuilder;
@@ -121,6 +124,7 @@ public final class GraphQLJpaQueryFactory {
121124
private static final String DESC = "DESC";
122125

123126
private static final Logger logger = LoggerFactory.getLogger(GraphQLJpaQueryFactory.class);
127+
public static final String JAKARTA_PERSISTENCE_FETCHGRAPH = "jakarta.persistence.fetchgraph";
124128
private static Function<Object, Object> unproxy;
125129

126130
static {
@@ -260,15 +264,26 @@ protected Stream<Object> queryResultStream(DataFetchingEnvironment environment,
260264
keys.toArray()
261265
);
262266

267+
// Let's create entity graph from selection
268+
var entityGraph = createEntityGraph(queryEnvironment);
269+
263270
// Let's execute query and get wrap result into stream
264-
return getResultStream(query, fetchSize, isDistinct);
271+
return getResultStream(query, fetchSize, isDistinct, entityGraph);
265272
}
266273

267-
protected <T> Stream<T> getResultStream(TypedQuery<T> query, int fetchSize, boolean isDistinct) {
274+
protected <T> Stream<T> getResultStream(
275+
TypedQuery<T> query,
276+
int fetchSize,
277+
boolean isDistinct,
278+
EntityGraph<?> entityGraph
279+
) {
268280
// Let' try reduce overhead and disable all caching
269281
query.setHint(ORG_HIBERNATE_READ_ONLY, true);
270282
query.setHint(ORG_HIBERNATE_FETCH_SIZE, fetchSize);
271283
query.setHint(ORG_HIBERNATE_CACHEABLE, false);
284+
if (entityGraph != null) {
285+
query.setHint(JAKARTA_PERSISTENCE_FETCHGRAPH, entityGraph);
286+
}
272287

273288
if (logger.isDebugEnabled()) {
274289
logger.info("\nGraphQL JPQL Fetch Query String:\n {}", getJPQLQueryString(query));
@@ -441,7 +456,9 @@ protected Map<Object, List<Object>> loadOneToMany(DataFetchingEnvironment enviro
441456

442457
TypedQuery<Object[]> query = getBatchQuery(environment, field, isDefaultDistinct(), keys);
443458

444-
List<Object[]> resultList = getResultList(query);
459+
var entityGraph = createEntityGraph(environment);
460+
461+
List<Object[]> resultList = getResultList(query, entityGraph);
445462

446463
if (logger.isTraceEnabled()) {
447464
logger.trace(
@@ -477,7 +494,9 @@ protected Map<Object, Object> loadManyToOne(DataFetchingEnvironment environment,
477494

478495
TypedQuery<Object[]> query = getBatchQuery(environment, field, isDefaultDistinct(), keys);
479496

480-
List<Object[]> resultList = getResultList(query);
497+
var entityGraph = createEntityGraph(environment);
498+
499+
List<Object[]> resultList = getResultList(query, entityGraph);
481500

482501
Map<Object, Object> resultMap = new LinkedHashMap<>(resultList.size());
483502

@@ -486,7 +505,7 @@ protected Map<Object, Object> loadManyToOne(DataFetchingEnvironment environment,
486505
return resultMap;
487506
}
488507

489-
protected <T> List<T> getResultList(TypedQuery<T> query) {
508+
protected <T> List<T> getResultList(TypedQuery<T> query, EntityGraph<?> entityGraph) {
490509
if (logger.isDebugEnabled()) {
491510
logger.info("\nGraphQL JPQL Batch Query String:\n {}", getJPQLQueryString(query));
492511
}
@@ -496,6 +515,10 @@ protected <T> List<T> getResultList(TypedQuery<T> query) {
496515
query.setHint(ORG_HIBERNATE_FETCH_SIZE, defaultFetchSize);
497516
query.setHint(ORG_HIBERNATE_CACHEABLE, false);
498517

518+
if (entityGraph != null) {
519+
query.setHint(JAKARTA_PERSISTENCE_FETCHGRAPH, entityGraph);
520+
}
521+
499522
return query.getResultList();
500523
}
501524

@@ -1693,8 +1716,10 @@ private EmbeddableType<?> computeEmbeddableType(GraphQLObjectType objectType) {
16931716
* @return resolved GraphQL object type or null if no output type is provided
16941717
*/
16951718
private GraphQLObjectType getObjectType(DataFetchingEnvironment environment) {
1696-
GraphQLType outputType = environment.getFieldType();
1719+
return getObjectType(environment.getFieldType());
1720+
}
16971721

1722+
private GraphQLObjectType getObjectType(GraphQLType outputType) {
16981723
if (outputType instanceof GraphQLList) outputType = ((GraphQLList) outputType).getWrappedType();
16991724

17001725
if (outputType instanceof GraphQLObjectType) return (GraphQLObjectType) outputType;
@@ -1976,6 +2001,88 @@ private <T> T detach(T entity) {
19762001
return entity;
19772002
}
19782003

2004+
EntityGraph<?> createEntityGraph(DataFetchingEnvironment environment) {
2005+
Field root = environment.getMergedField().getSingleField();
2006+
GraphQLObjectType fieldType = getObjectType(environment);
2007+
EntityType<?> entityType = getEntityType(fieldType);
2008+
2009+
EntityGraph<?> entityGraph = entityManager.createEntityGraph(entityType.getJavaType());
2010+
2011+
var entityDescriptor = EntityIntrospector.introspect(entityType);
2012+
2013+
selections(root)
2014+
.forEach(selectedField -> {
2015+
var propertyDescriptor = entityDescriptor.getPropertyDescriptor(selectedField.getName());
2016+
2017+
propertyDescriptor
2018+
.flatMap(AttributePropertyDescriptor::getAttribute)
2019+
.ifPresent(attribute -> {
2020+
if (
2021+
isManagedType(attribute) && hasSelectionSet(selectedField) && hasNoArguments(selectedField)
2022+
) {
2023+
var attributeFieldDefinition = fieldType.getFieldDefinition(attribute.getName());
2024+
entityGraph.addAttributeNodes(attribute.getName());
2025+
addSubgraph(
2026+
selectedField,
2027+
attributeFieldDefinition,
2028+
entityGraph.addSubgraph(attribute.getName())
2029+
);
2030+
} else if (isBasic(attribute)) {
2031+
entityGraph.addAttributeNodes(attribute.getName());
2032+
}
2033+
});
2034+
});
2035+
2036+
return entityGraph;
2037+
}
2038+
2039+
void addSubgraph(Field field, GraphQLFieldDefinition fieldDefinition, Subgraph<?> subgraph) {
2040+
var fieldObjectType = getObjectType(fieldDefinition.getType());
2041+
var fieldEntityType = getEntityType(fieldObjectType);
2042+
var fieldEntityDescriptor = EntityIntrospector.introspect(fieldEntityType);
2043+
2044+
selections(field)
2045+
.forEach(selectedField -> {
2046+
var propertyDescriptor = fieldEntityDescriptor.getPropertyDescriptor(selectedField.getName());
2047+
2048+
propertyDescriptor
2049+
.flatMap(AttributePropertyDescriptor::getAttribute)
2050+
.ifPresent(attribute -> {
2051+
var selectedName = selectedField.getName();
2052+
2053+
if (
2054+
hasSelectionSet(selectedField) && isManagedType(attribute) && hasNoArguments(selectedField)
2055+
) {
2056+
var selectedFieldDefinition = fieldObjectType.getFieldDefinition(selectedName);
2057+
subgraph.addAttributeNodes(selectedName);
2058+
addSubgraph(selectedField, selectedFieldDefinition, subgraph.addSubgraph(selectedName));
2059+
} else if (isBasic(attribute)) {
2060+
subgraph.addAttributeNodes(selectedName);
2061+
}
2062+
});
2063+
});
2064+
}
2065+
2066+
static boolean isManagedType(Attribute<?, ?> attribute) {
2067+
return (
2068+
attribute.getPersistentAttributeType() != Attribute.PersistentAttributeType.EMBEDDED &&
2069+
attribute.getPersistentAttributeType() != Attribute.PersistentAttributeType.BASIC &&
2070+
attribute.getPersistentAttributeType() != Attribute.PersistentAttributeType.ELEMENT_COLLECTION
2071+
);
2072+
}
2073+
2074+
static boolean isBasic(Attribute<?, ?> attribute) {
2075+
return !isManagedType(attribute);
2076+
}
2077+
2078+
static boolean hasNoArguments(Field field) {
2079+
return !hasArguments(field);
2080+
}
2081+
2082+
static boolean hasArguments(Field field) {
2083+
return field.getArguments() != null && !field.getArguments().isEmpty();
2084+
}
2085+
19792086
/**
19802087
* Creates builder to build {@link GraphQLJpaQueryFactory}.
19812088
* @return created builder

schema/src/main/java/com/introproventures/graphql/jpa/query/schema/impl/GraphQLJpaSchemaBuilder.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1412,7 +1412,7 @@ private GraphQLOutputType getGraphQLTypeFromJavaType(Class<?> clazz) {
14121412
classCache.putIfAbsent(clazz, enumType);
14131413

14141414
return enumType;
1415-
} else if (clazz.isArray()) {
1415+
} else if (clazz.isArray() && !JavaScalars.contains(clazz)) {
14161416
return GraphQLList.list(JavaScalars.of(clazz.getComponentType()));
14171417
}
14181418

schema/src/test/java/com/introproventures/graphql/jpa/query/schema/GraphQLExecutorTests.java

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,6 +792,59 @@ public void queryBooksAuthorWithExplictOptionalTrue() {
792792
assertThat(result.toString()).isEqualTo(expected);
793793
}
794794

795+
@Test
796+
public void queryAuthorsWithLazyLoadProfilePicture() {
797+
//given
798+
String query =
799+
"""
800+
query {
801+
Authors {
802+
select {
803+
id
804+
name
805+
profilePicture
806+
}
807+
}
808+
}
809+
""";
810+
811+
String expected =
812+
"""
813+
{Authors={select=[{id=1, name=Leo Tolstoy, profilePicture=base64:iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAlElEQVR4nO2UQQrEIAxFe/8jSRUrKtZFEasIQs7yh3RRKEOnixmYjYsQ0OT5E2ImIsIvbRpAjB7iq7GptcIYg947rLUopbwl5JzPGPattXtgSgkhBAghsG3b4fncOQfv/RnDIL7jB+d5/qxQKYV1XS9ArTWWZbkoZHsExhgPICfdlcxKpZTY9/25ZBp/mcb6on9s7Bc+TJAvSO7XjwAAAABJRU5ErkJggg==}, {id=4, name=Anton Chekhov, profilePicture=base64:iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAlElEQVR4nO2UQQrEIAxFe/8jSRUrKtZFEasIQs7yh3RRKEOnixmYjYsQ0OT5E2ImIsIvbRpAjB7iq7GptcIYg947rLUopbwl5JzPGPattXtgSgkhBAghsG3b4fncOQfv/RnDIL7jB+d5/qxQKYV1XS9ArTWWZbkoZHsExhgPICfdlcxKpZTY9/25ZBp/mcb6on9s7Bc+TJAvSO7XjwAAAABJRU5ErkJggg==}, {id=8, name=Igor Dianov, profilePicture=base64:iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAlElEQVR4nO2UQQrEIAxFe/8jSRUrKtZFEasIQs7yh3RRKEOnixmYjYsQ0OT5E2ImIsIvbRpAjB7iq7GptcIYg947rLUopbwl5JzPGPattXtgSgkhBAghsG3b4fncOQfv/RnDIL7jB+d5/qxQKYV1XS9ArTWWZbkoZHsExhgPICfdlcxKpZTY9/25ZBp/mcb6on9s7Bc+TJAvSO7XjwAAAABJRU5ErkJggg==}]}}
814+
""";
815+
816+
//when
817+
Object result = executor.execute(query).getData();
818+
819+
// then
820+
assertThat(result.toString()).isEqualTo(expected.strip());
821+
}
822+
823+
@Test
824+
public void queryAuthorsWithNoProfilePicture() {
825+
//given
826+
String query =
827+
"""
828+
query {
829+
Authors {
830+
select {
831+
id
832+
name
833+
}
834+
}
835+
}
836+
""";
837+
838+
String expected =
839+
"{Authors={select=[{id=1, name=Leo Tolstoy}, {id=4, name=Anton Chekhov}, {id=8, name=Igor Dianov}]}}";
840+
841+
//when
842+
Object result = executor.execute(query).getData();
843+
844+
// then
845+
assertThat(result.toString()).isEqualTo(expected.strip());
846+
}
847+
795848
// https://github.com/introproventures/graphql-jpa-query/issues/30
796849
@Test
797850
public void queryForEntityWithMappedSuperclass() {

schema/src/test/resources/data.sql

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,19 +112,19 @@ insert into thing (id, type) values
112112
('2D1EBC5B7D2741979CF0E84451C5BBB1', 'Thing1');
113113

114114
-- Books
115-
insert into author (id, name, genre) values (1, 'Leo Tolstoy', 'NOVEL');
115+
insert into author (id, name, genre, profile_picture) values (1, 'Leo Tolstoy', 'NOVEL', 'base64:iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAlElEQVR4nO2UQQrEIAxFe/8jSRUrKtZFEasIQs7yh3RRKEOnixmYjYsQ0OT5E2ImIsIvbRpAjB7iq7GptcIYg947rLUopbwl5JzPGPattXtgSgkhBAghsG3b4fncOQfv/RnDIL7jB+d5/qxQKYV1XS9ArTWWZbkoZHsExhgPICfdlcxKpZTY9/25ZBp/mcb6on9s7Bc+TJAvSO7XjwAAAABJRU5ErkJggg==');
116116
insert into book (id, title, author_id, genre, publication_date, description)
117117
values (2, 'War and Peace', 1, 'NOVEL', '1869-01-01', 'The novel chronicles the history of the French invasion of Russia and the impact of the Napoleonic era on Tsarist society through the stories of five Russian aristocratic families.');
118118
insert into book (id, title, author_id, genre, publication_date, description)
119119
values (3, 'Anna Karenina', 1, 'NOVEL', '1877-04-01', 'A complex novel in eight parts, with more than a dozen major characters, it is spread over more than 800 pages (depending on the translation), typically contained in two volumes.');
120-
insert into author (id, name, genre) values (4, 'Anton Chekhov', 'PLAY');
120+
insert into author (id, name, genre, profile_picture) values (4, 'Anton Chekhov', 'PLAY', 'base64:iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAlElEQVR4nO2UQQrEIAxFe/8jSRUrKtZFEasIQs7yh3RRKEOnixmYjYsQ0OT5E2ImIsIvbRpAjB7iq7GptcIYg947rLUopbwl5JzPGPattXtgSgkhBAghsG3b4fncOQfv/RnDIL7jB+d5/qxQKYV1XS9ArTWWZbkoZHsExhgPICfdlcxKpZTY9/25ZBp/mcb6on9s7Bc+TJAvSO7XjwAAAABJRU5ErkJggg==');
121121
insert into book (id, title, author_id, genre, publication_date, description)
122122
values (5, 'The Cherry Orchard', 4, 'PLAY', '1904-01-17', 'The play concerns an aristocratic Russian landowner who returns to her family estate (which includes a large and well-known cherry orchard) just before it is auctioned to pay the mortgage.');
123123
insert into book (id, title, author_id, genre, publication_date, description)
124124
values (6, 'The Seagull', 4, 'PLAY', '1896-10-17', 'It dramatises the romantic and artistic conflicts between four characters');
125125
insert into book (id, title, author_id, genre, publication_date, description)
126126
values (7, 'Three Sisters', 4, 'PLAY', '1900-01-01', 'The play is sometimes included on the short list of Chekhov''s outstanding plays, along with The Cherry Orchard, The Seagull and Uncle Vanya.[1]');
127-
insert into author (id, name, genre) values (8, 'Igor Dianov', 'JAVA');
127+
insert into author (id, name, genre, profile_picture) values (8, 'Igor Dianov', 'JAVA', 'base64:iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAlElEQVR4nO2UQQrEIAxFe/8jSRUrKtZFEasIQs7yh3RRKEOnixmYjYsQ0OT5E2ImIsIvbRpAjB7iq7GptcIYg947rLUopbwl5JzPGPattXtgSgkhBAghsG3b4fncOQfv/RnDIL7jB+d5/qxQKYV1XS9ArTWWZbkoZHsExhgPICfdlcxKpZTY9/25ZBp/mcb6on9s7Bc+TJAvSO7XjwAAAABJRU5ErkJggg==');
128128

129129
insert into book_tags (book_id, tags) values (2, 'war'), (2, 'piece');
130130
insert into book_tags (book_id, tags) values (3, 'anna'), (3, 'karenina');
@@ -163,4 +163,4 @@ insert into calculated_entity (id, title, info) values
163163
-- FloatingThing
164164
insert into floating_thing (id, float_value, double_value, big_decimal_value) values
165165
(1, 4.55, 4.55, 4.55),
166-
(2, -0.44, -0.44, -0.44)
166+
(2, -0.44, -0.44, -0.44)

tests/models/books/pom.xml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,24 @@
2424
<optional>true</optional>
2525
</dependency>
2626
</dependencies>
27+
28+
<build>
29+
<plugins>
30+
<plugin>
31+
<groupId>org.hibernate.orm.tooling</groupId>
32+
<artifactId>hibernate-enhance-maven-plugin</artifactId>
33+
<executions>
34+
<execution>
35+
<configuration>
36+
<failOnError>true</failOnError>
37+
<enableLazyInitialization>true</enableLazyInitialization>
38+
</configuration>
39+
<goals>
40+
<goal>enhance</goal>
41+
</goals>
42+
</execution>
43+
</executions>
44+
</plugin>
45+
</plugins>
46+
</build>
2747
</project>

0 commit comments

Comments
 (0)