Skip to content

Commit fab342e

Browse files
committed
Fix Hibernate 7 eager fetch joins to initialize associations
Under Hibernate 7, `Entity.findById(id, [fetch:[assoc:'join']])` (and dynamic finders generally) did not eagerly initialize the requested association, while Hibernate 5 did. The user-visible symptom is that JSON views silently drop hasMany collections after migration, because the view layer skips uninitialized lazy collections in non-deep rendering. Root cause: GORM's shared DynamicFinder translates an EAGER fetch strategy into `query.join(property)`. In Hibernate 7 that became a plain JPA join that JpaCriteriaQueryCreator only materialized on demand when referenced in a predicate - and a fetch-only association is never referenced, so it was never joined or fetched. JPA requires `root.fetch(...)` for eager initialization, which the rewritten query layer never emitted (Hibernate 5 used `criteria.setFetchMode(assoc, JOIN)`). Fix (Hibernate 7 only): - HibernateQuery records the association paths requested through join(...) and exposes them via getFetchJoinPaths(). createAlias-style filtering joins go through detachedCriteria.join directly and are not recorded. - JpaCriteriaQueryCreator.createQuery() materializes those paths as `root.fetch(path, joinType)` for full-entity selects only; projection and count queries are skipped (a fetch join is invalid there), dotted paths are skipped, and a non-fetchable path falls back to the existing join handling. Adds FetchJoinSpec to both the Hibernate 5 and Hibernate 7 modules as a regression test. The full grails-data-hibernate7-core suite passes (2944 tests, 0 failures); Hibernate 5 is unchanged. Assisted-by: claude-code:claude-4.8-opus
1 parent 81b0c52 commit fab342e

4 files changed

Lines changed: 168 additions & 1 deletion

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* https://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package grails.gorm.tests
20+
21+
import grails.gorm.annotation.Entity
22+
import org.hibernate.Hibernate
23+
24+
class FetchJoinSpec extends HibernateGormDatastoreSpec {
25+
26+
void setupSpec() {
27+
manager.registerDomainClasses(FetchJoinParent, FetchJoinChild)
28+
}
29+
30+
void "findById with fetch join eagerly initializes the hasMany collection"() {
31+
given:
32+
def parent = new FetchJoinParent(name: "p")
33+
parent.addToChildren(new FetchJoinChild(name: "c1"))
34+
parent.addToChildren(new FetchJoinChild(name: "c2"))
35+
parent.save(flush: true)
36+
manager.session.clear()
37+
38+
when: "loading by id with an explicit join fetch of the collection"
39+
def found = FetchJoinParent.findById(parent.id, [fetch: [children: 'join']])
40+
41+
then: "the collection is eagerly initialized"
42+
found != null
43+
Hibernate.isInitialized(found.children)
44+
found.children.size() == 2
45+
}
46+
}
47+
48+
@Entity
49+
class FetchJoinParent {
50+
String name
51+
static hasMany = [children: FetchJoinChild]
52+
}
53+
54+
@Entity
55+
class FetchJoinChild {
56+
String name
57+
static belongsTo = [parent: FetchJoinParent]
58+
}

grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateQuery.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@
2121
import java.util.Collections;
2222
import java.util.Deque;
2323
import java.util.HashMap;
24+
import java.util.LinkedHashSet;
2425
import java.util.LinkedList;
2526
import java.util.List;
2627
import java.util.Map;
28+
import java.util.Set;
2729

2830
import groovy.lang.Closure;
2931

@@ -73,6 +75,7 @@ public class HibernateQuery extends Query {
7375
protected static final String ALIAS = "_alias";
7476
private final Map<String, CriteriaAndAlias> createdAssociationPaths = new HashMap<>();
7577
private final List<HibernateAlias> aliases = new java.util.ArrayList<>();
78+
private final Set<String> fetchJoinPaths = new LinkedHashSet<>();
7679
protected String alias;
7780
protected int aliasCount;
7881
protected Deque<GrailsHibernatePersistentEntity> entityStack = new LinkedList<>();
@@ -403,16 +406,28 @@ public Query clearOrders() {
403406

404407
@Override
405408
public Query join(String property) {
409+
fetchJoinPaths.add(property);
406410
detachedCriteria.join(property);
407411
return this;
408412
}
409413

410414
@Override
411415
public Query join(String property, JoinType joinType) {
416+
fetchJoinPaths.add(property);
412417
detachedCriteria.join(property, joinType);
413418
return this;
414419
}
415420

421+
/**
422+
* The association paths requested as eager join fetches via {@link #join(String)} - for
423+
* example by a dynamic finder invoked with {@code [fetch: [assoc: 'join']]}. These are
424+
* materialized as JPA {@code root.fetch(...)} joins so the associations are eagerly
425+
* initialized, matching the Hibernate 5 behaviour.
426+
*/
427+
public Set<String> getFetchJoinPaths() {
428+
return Collections.unmodifiableSet(fetchJoinPaths);
429+
}
430+
416431
@Override
417432
public Query select(String property) {
418433
detachedCriteria.select(property);

grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/JpaCriteriaQueryCreator.java

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@
1717

1818
import java.util.ArrayList;
1919
import java.util.List;
20+
import java.util.Map;
2021
import java.util.Objects;
2122

2223
import jakarta.persistence.criteria.AbstractQuery;
2324
import jakarta.persistence.criteria.CriteriaBuilder;
2425
import jakarta.persistence.criteria.CriteriaQuery;
2526
import jakarta.persistence.criteria.Expression;
27+
import jakarta.persistence.criteria.JoinType;
2628
import jakarta.persistence.criteria.Root;
2729

2830
import org.hibernate.query.criteria.HibernateCriteriaBuilder;
@@ -101,7 +103,9 @@ public JpaCriteriaQuery<?> createQuery() {
101103
var context = JpaQueryContext.forSubquery(parentContext, aliases, root);
102104
registerDetachedJoins(context);
103105
discoverAliases(detachedCriteria.getCriteria(), context);
104-
106+
107+
applyEagerFetchJoins(root, projectionList);
108+
105109
new JpaProjectionAdapter(criteriaBuilder, context).adapt(projections, (AbstractQuery<?>) cq);
106110
assignGroupBy(cq, context);
107111

@@ -110,6 +114,38 @@ public JpaCriteriaQuery<?> createQuery() {
110114
return cq;
111115
}
112116

117+
/**
118+
* Eagerly fetch-join the associations requested via {@code fetch:[assoc:'join']} (which a
119+
* dynamic finder turns into {@link HibernateQuery#join(String)} calls). Hibernate 5 initializes
120+
* these collections; without an explicit JPA {@code root.fetch(...)} they would only be plain
121+
* joins (or, when not referenced in a predicate, not materialized at all), leaving the
122+
* association uninitialized. Only applied to full-entity selects - a fetch join on a projection
123+
* query (for example {@code count}) is invalid.
124+
*/
125+
private void applyEagerFetchJoins(Root<?> root, List<Query.Projection> projectionList) {
126+
if (hibernateQuery == null) {
127+
return;
128+
}
129+
boolean entitySelect = projectionList.stream().noneMatch(p -> !(p instanceof Query.DistinctProjection));
130+
if (!entitySelect) {
131+
return;
132+
}
133+
Map<String, ?> joinTypes = detachedCriteria.getJoinTypes();
134+
for (String fetchPath : hibernateQuery.getFetchJoinPaths()) {
135+
if (fetchPath == null || fetchPath.indexOf('.') >= 0) {
136+
continue;
137+
}
138+
Object configured = (joinTypes != null) ? joinTypes.get(fetchPath) : null;
139+
JoinType joinType = (configured instanceof JoinType jt) ? jt : JoinType.LEFT;
140+
try {
141+
root.fetch(fetchPath, joinType);
142+
}
143+
catch (IllegalArgumentException ignored) {
144+
// Not a fetchable association on this root; the plain join handling remains in place.
145+
}
146+
}
147+
}
148+
113149
@SuppressWarnings("unchecked")
114150
public <T> void populateSubquery(JpaSubQuery<T> subquery) {
115151
var projectionList = collectProjections();
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* https://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package grails.gorm.tests
20+
21+
import grails.gorm.annotation.Entity
22+
import org.hibernate.Hibernate
23+
24+
class FetchJoinSpec extends HibernateGormDatastoreSpec {
25+
26+
void setupSpec() {
27+
manager.registerDomainClasses(FetchJoinParent, FetchJoinChild)
28+
}
29+
30+
void "findById with fetch join eagerly initializes the hasMany collection"() {
31+
given:
32+
def parent = new FetchJoinParent(name: "p")
33+
parent.addToChildren(new FetchJoinChild(name: "c1"))
34+
parent.addToChildren(new FetchJoinChild(name: "c2"))
35+
parent.save(flush: true)
36+
manager.session.clear()
37+
38+
when: "loading by id with an explicit join fetch of the collection"
39+
def found = FetchJoinParent.findById(parent.id, [fetch: [children: 'join']])
40+
41+
then: "the collection is eagerly initialized"
42+
found != null
43+
Hibernate.isInitialized(found.children)
44+
found.children.size() == 2
45+
}
46+
}
47+
48+
@Entity
49+
class FetchJoinParent {
50+
String name
51+
static hasMany = [children: FetchJoinChild]
52+
}
53+
54+
@Entity
55+
class FetchJoinChild {
56+
String name
57+
static belongsTo = [parent: FetchJoinParent]
58+
}

0 commit comments

Comments
 (0)