Skip to content

Commit 894a06c

Browse files
committed
Optimize derived queries to avoid unnecessary JOINs for association ID access
This commit introduces an optimization that eliminates unnecessary JOINs when accessing association IDs in derived query methods. Changes: - Add PathOptimizationStrategy interface for query path optimization - Implement DefaultPathOptimizationStrategy using SingularAttribute.isId() - Create JpaMetamodelContext for AOT-compatible metamodel access - Update QueryUtils and JpqlUtils to use the unified optimization - Add comprehensive test coverage for the optimization The optimization detects patterns like findByAuthorId() and generates SQL that directly uses the foreign key column instead of creating a JOIN. This improves query performance with Hibernate 6.4+ where entity path traversal generates JOINs by default. Fixes #3349 Signed-off-by: academey <[email protected]>
1 parent d5d9332 commit 894a06c

File tree

8 files changed

+854
-17
lines changed

8 files changed

+854
-17
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* Copyright 2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.jpa.repository.query;
17+
18+
import java.util.function.Supplier;
19+
20+
import jakarta.persistence.metamodel.Attribute;
21+
import jakarta.persistence.metamodel.Attribute.PersistentAttributeType;
22+
import jakarta.persistence.metamodel.Bindable;
23+
import jakarta.persistence.metamodel.ManagedType;
24+
import jakarta.persistence.metamodel.SingularAttribute;
25+
26+
import org.springframework.data.mapping.PropertyPath;
27+
import org.springframework.util.StringUtils;
28+
29+
/**
30+
* Default implementation of {@link PathOptimizationStrategy} that optimizes
31+
* foreign key access for @ManyToOne and owning side of @OneToOne relationships.
32+
*
33+
* @author Hyunjoon Kim
34+
* @since 3.5
35+
*/
36+
public class DefaultPathOptimizationStrategy implements PathOptimizationStrategy {
37+
38+
@Override
39+
public boolean canOptimizeForeignKeyAccess(PropertyPath path, Bindable<?> bindable, MetamodelContext context) {
40+
return isRelationshipId(path, bindable);
41+
}
42+
43+
@Override
44+
public boolean isAssociationId(PropertyPath path, MetamodelContext context) {
45+
// For consistency with canOptimizeForeignKeyAccess, delegate to the same logic
46+
return isRelationshipId(path, null);
47+
}
48+
49+
/**
50+
* Checks if this property path is referencing to relationship id.
51+
* This implementation follows the approach from PR #3922, using
52+
* SingularAttribute.isId() for reliable ID detection.
53+
*
54+
* @param path the property path
55+
* @param bindable the {@link Bindable} to check for attribute model (can be null)
56+
* @return whether the path references a relationship id
57+
*/
58+
private boolean isRelationshipId(PropertyPath path, Bindable<?> bindable) {
59+
if (!path.hasNext()) {
60+
return false;
61+
}
62+
63+
// This logic is adapted from PR #3922's QueryUtils.isRelationshipId method
64+
if (bindable != null) {
65+
ManagedType<?> managedType = QueryUtils.getManagedTypeForModel(bindable);
66+
Bindable<?> propertyPathModel = getModelForPath(path, managedType, () -> null);
67+
if (propertyPathModel != null) {
68+
ManagedType<?> propertyPathManagedType = QueryUtils.getManagedTypeForModel(propertyPathModel);
69+
PropertyPath nextPath = path.next();
70+
if (nextPath != null && propertyPathManagedType != null) {
71+
Bindable<?> nextPropertyPathModel = getModelForPath(nextPath, propertyPathManagedType, () -> null);
72+
if (nextPropertyPathModel instanceof SingularAttribute<?, ?> singularAttribute) {
73+
return singularAttribute.isId();
74+
}
75+
}
76+
}
77+
}
78+
79+
return false;
80+
}
81+
82+
/**
83+
* Gets the model for a path segment. Adapted from QueryUtils.getModelForPath.
84+
*/
85+
private Bindable<?> getModelForPath(PropertyPath path, ManagedType<?> managedType, Supplier<Object> fallback) {
86+
String segment = path.getSegment();
87+
if (managedType != null) {
88+
try {
89+
Attribute<?, ?> attribute = managedType.getAttribute(segment);
90+
return (Bindable<?>) attribute;
91+
} catch (IllegalArgumentException e) {
92+
// Attribute not found in managed type
93+
}
94+
}
95+
return null;
96+
}
97+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* Copyright 2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.jpa.repository.query;
17+
18+
import jakarta.persistence.metamodel.EntityType;
19+
import jakarta.persistence.metamodel.ManagedType;
20+
import jakarta.persistence.metamodel.Metamodel;
21+
import jakarta.persistence.metamodel.SingularAttribute;
22+
23+
import org.jspecify.annotations.Nullable;
24+
25+
/**
26+
* JPA Metamodel-based implementation of {@link PathOptimizationStrategy.MetamodelContext}.
27+
* This implementation uses the JPA metamodel at runtime but can be replaced with
28+
* an AOT-friendly implementation during native image compilation.
29+
*
30+
* @author Hyunjoon Kim
31+
* @since 3.5
32+
*/
33+
public class JpaMetamodelContext implements PathOptimizationStrategy.MetamodelContext {
34+
35+
private final @Nullable Metamodel metamodel;
36+
37+
public JpaMetamodelContext(@Nullable Metamodel metamodel) {
38+
this.metamodel = metamodel;
39+
}
40+
41+
@Override
42+
public boolean isEntityType(Class<?> type) {
43+
if (metamodel == null) {
44+
return false;
45+
}
46+
47+
try {
48+
metamodel.entity(type);
49+
return true;
50+
} catch (IllegalArgumentException e) {
51+
return false;
52+
}
53+
}
54+
55+
@Override
56+
public boolean isIdProperty(Class<?> entityType, String propertyName) {
57+
if (metamodel == null) {
58+
return false;
59+
}
60+
61+
try {
62+
ManagedType<?> managedType = metamodel.managedType(entityType);
63+
64+
if (managedType instanceof EntityType<?> entity) {
65+
// Check for single ID attribute
66+
if (entity.hasSingleIdAttribute()) {
67+
SingularAttribute<?, ?> idAttribute = entity.getId(entity.getIdType().getJavaType());
68+
return idAttribute.getName().equals(propertyName);
69+
}
70+
71+
// Check for composite ID
72+
return entity.getIdClassAttributes().stream()
73+
.anyMatch(attr -> attr.getName().equals(propertyName));
74+
}
75+
76+
} catch (IllegalArgumentException e) {
77+
// Type not found in metamodel
78+
}
79+
80+
return false;
81+
}
82+
83+
@Override
84+
@Nullable
85+
public Class<?> getPropertyType(Class<?> entityType, String propertyName) {
86+
if (metamodel == null) {
87+
return null;
88+
}
89+
90+
try {
91+
ManagedType<?> managedType = metamodel.managedType(entityType);
92+
return managedType.getAttribute(propertyName).getJavaType();
93+
} catch (IllegalArgumentException e) {
94+
return null;
95+
}
96+
}
97+
}

spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlUtils.java

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,28 @@ static JpqlQueryBuilder.PathExpression toExpressionRecursively(@Nullable Metamod
6363
String segment = property.getSegment();
6464

6565
boolean isLeafProperty = !property.hasNext();
66-
boolean requiresOuterJoin = requiresOuterJoin(metamodel, from, property, isForSelection, hasRequiredOuterJoin);
67-
68-
// if it does not require an outer join and is a leaf, simply get the segment
69-
if (!requiresOuterJoin && isLeafProperty) {
70-
return new JpqlQueryBuilder.PathAndOrigin(property, source, false);
66+
67+
// Check for relationship ID optimization using unified abstraction
68+
PathOptimizationStrategy strategy = new DefaultPathOptimizationStrategy();
69+
JpaMetamodelContext context = new JpaMetamodelContext(metamodel);
70+
boolean isRelationshipId = strategy.canOptimizeForeignKeyAccess(property, from, context);
71+
72+
boolean requiresOuterJoin = requiresOuterJoin(metamodel, from, property, isForSelection, hasRequiredOuterJoin, isLeafProperty, isRelationshipId);
73+
74+
// if it does not require an outer join and is a leaf or relationship id, simply get rest of the segment path
75+
if (!requiresOuterJoin && (isLeafProperty || isRelationshipId)) {
76+
if (isRelationshipId) {
77+
// For relationship ID case, create implicit path without joins
78+
PropertyPath implicitPath = PropertyPath.from(segment, from.getBindableJavaType());
79+
PropertyPath remainingPath = property.next();
80+
while (remainingPath != null) {
81+
implicitPath = implicitPath.nested(remainingPath.getSegment());
82+
remainingPath = remainingPath.next();
83+
}
84+
return new JpqlQueryBuilder.PathAndOrigin(implicitPath, source, false);
85+
} else {
86+
return new JpqlQueryBuilder.PathAndOrigin(property, source, false);
87+
}
7188
}
7289

7390
// get or create the join
@@ -105,7 +122,7 @@ static JpqlQueryBuilder.PathExpression toExpressionRecursively(@Nullable Metamod
105122
* @return
106123
*/
107124
static boolean requiresOuterJoin(@Nullable Metamodel metamodel, Bindable<?> bindable, PropertyPath propertyPath,
108-
boolean isForSelection, boolean hasRequiredOuterJoin) {
125+
boolean isForSelection, boolean hasRequiredOuterJoin, boolean isLeafProperty, boolean isRelationshipId) {
109126

110127
ManagedType<?> managedType = QueryUtils.getManagedTypeForModel(bindable);
111128
Attribute<?, ?> attribute = getModelForPath(metamodel, propertyPath, managedType, bindable);
@@ -127,8 +144,7 @@ static boolean requiresOuterJoin(@Nullable Metamodel metamodel, Bindable<?> bind
127144
boolean isInverseOptionalOneToOne = PersistentAttributeType.ONE_TO_ONE == attribute.getPersistentAttributeType()
128145
&& StringUtils.hasText(QueryUtils.getAnnotationProperty(attribute, "mappedBy", ""));
129146

130-
boolean isLeafProperty = !propertyPath.hasNext();
131-
if (isLeafProperty && !isForSelection && !isCollection && !isInverseOptionalOneToOne && !hasRequiredOuterJoin) {
147+
if ((isLeafProperty || isRelationshipId) && !isForSelection && !isCollection && !isInverseOptionalOneToOne && !hasRequiredOuterJoin) {
132148
return false;
133149
}
134150

@@ -159,4 +175,14 @@ static boolean requiresOuterJoin(@Nullable Metamodel metamodel, Bindable<?> bind
159175

160176
return null;
161177
}
178+
179+
/**
180+
* Checks if the given property path can be optimized by directly accessing the foreign key column
181+
* instead of creating a JOIN.
182+
*
183+
* @param metamodel the JPA metamodel
184+
* @param from the bindable to check
185+
* @param property the property path
186+
* @return true if this can be optimized to use foreign key directly
187+
*/
162188
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Copyright 2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.jpa.repository.query;
17+
18+
import jakarta.persistence.metamodel.Bindable;
19+
20+
import org.springframework.data.mapping.PropertyPath;
21+
22+
/**
23+
* Strategy interface for optimizing property path traversal in JPA queries.
24+
* Implementations determine when foreign key columns can be accessed directly
25+
* without creating unnecessary JOINs.
26+
*
27+
* @author Hyunjoon Kim
28+
* @since 3.5
29+
*/
30+
public interface PathOptimizationStrategy {
31+
32+
/**
33+
* Determines if a property path can be optimized by accessing the foreign key
34+
* column directly instead of creating a JOIN.
35+
*
36+
* @param path the property path to check
37+
* @param bindable the JPA bindable containing the property
38+
* @param context metadata context for type information
39+
* @return true if the path can be optimized
40+
*/
41+
boolean canOptimizeForeignKeyAccess(PropertyPath path, Bindable<?> bindable, MetamodelContext context);
42+
43+
/**
44+
* Checks if the given property path represents an association's identifier.
45+
* For example, in "author.id", this would return true when "id" is the
46+
* identifier of the Author entity.
47+
*
48+
* @param path the property path to check
49+
* @param context metadata context for type information
50+
* @return true if the path ends with an association's identifier
51+
*/
52+
boolean isAssociationId(PropertyPath path, MetamodelContext context);
53+
54+
/**
55+
* Context interface providing minimal metamodel information needed for
56+
* optimization decisions. This abstraction allows the strategy to work
57+
* in both runtime and AOT compilation scenarios.
58+
*/
59+
interface MetamodelContext {
60+
61+
/**
62+
* Checks if a type is a managed entity type.
63+
*
64+
* @param type the class to check
65+
* @return true if the type is a managed entity
66+
*/
67+
boolean isEntityType(Class<?> type);
68+
69+
/**
70+
* Checks if a property is an identifier property of an entity.
71+
*
72+
* @param entityType the entity class
73+
* @param propertyName the property name
74+
* @return true if the property is an identifier
75+
*/
76+
boolean isIdProperty(Class<?> entityType, String propertyName);
77+
78+
/**
79+
* Gets the type of a property.
80+
*
81+
* @param entityType the entity class
82+
* @param propertyName the property name
83+
* @return the property type, or null if not found
84+
*/
85+
Class<?> getPropertyType(Class<?> entityType, String propertyName);
86+
}
87+
}

0 commit comments

Comments
 (0)