Skip to content

Optimize derived queries to avoid unnecessary JOINs for association ID access #3970

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* 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.query;

import java.util.function.Supplier;

import jakarta.persistence.metamodel.Attribute;
import jakarta.persistence.metamodel.Attribute.PersistentAttributeType;
import jakarta.persistence.metamodel.Bindable;
import jakarta.persistence.metamodel.ManagedType;
import jakarta.persistence.metamodel.SingularAttribute;

import org.springframework.data.mapping.PropertyPath;
import org.springframework.util.StringUtils;

/**
* Default implementation of {@link PathOptimizationStrategy} that optimizes
* foreign key access for @ManyToOne and owning side of @OneToOne relationships.
*
* @author Hyunjoon Kim
* @since 3.5
*/
public class DefaultPathOptimizationStrategy implements PathOptimizationStrategy {

@Override
public boolean canOptimizeForeignKeyAccess(PropertyPath path, Bindable<?> bindable, MetamodelContext context) {
return isRelationshipId(path, bindable);
}

@Override
public boolean isAssociationId(PropertyPath path, MetamodelContext context) {
// For consistency with canOptimizeForeignKeyAccess, delegate to the same logic
return isRelationshipId(path, null);
}

/**
* Checks if this property path is referencing to relationship id.
* This implementation follows the approach from PR #3922, using
* SingularAttribute.isId() for reliable ID detection.
*
* @param path the property path
* @param bindable the {@link Bindable} to check for attribute model (can be null)
* @return whether the path references a relationship id
*/
private boolean isRelationshipId(PropertyPath path, Bindable<?> bindable) {
if (!path.hasNext()) {
return false;
}

// This logic is adapted from PR #3922's QueryUtils.isRelationshipId method
if (bindable != null) {
ManagedType<?> managedType = QueryUtils.getManagedTypeForModel(bindable);
Bindable<?> propertyPathModel = getModelForPath(path, managedType, () -> null);
if (propertyPathModel != null) {
ManagedType<?> propertyPathManagedType = QueryUtils.getManagedTypeForModel(propertyPathModel);
PropertyPath nextPath = path.next();
if (nextPath != null && propertyPathManagedType != null) {
Bindable<?> nextPropertyPathModel = getModelForPath(nextPath, propertyPathManagedType, () -> null);
if (nextPropertyPathModel instanceof SingularAttribute<?, ?> singularAttribute) {
return singularAttribute.isId();
}
}
}
}

return false;
}

/**
* Gets the model for a path segment. Adapted from QueryUtils.getModelForPath.
*/
private Bindable<?> getModelForPath(PropertyPath path, ManagedType<?> managedType, Supplier<Object> fallback) {
String segment = path.getSegment();
if (managedType != null) {
try {
Attribute<?, ?> attribute = managedType.getAttribute(segment);
return (Bindable<?>) attribute;
} catch (IllegalArgumentException e) {
// Attribute not found in managed type
}
}
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* 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.query;

import jakarta.persistence.metamodel.EntityType;
import jakarta.persistence.metamodel.ManagedType;
import jakarta.persistence.metamodel.Metamodel;
import jakarta.persistence.metamodel.SingularAttribute;

import org.jspecify.annotations.Nullable;

/**
* JPA Metamodel-based implementation of {@link PathOptimizationStrategy.MetamodelContext}.
* This implementation uses the JPA metamodel at runtime but can be replaced with
* an AOT-friendly implementation during native image compilation.
*
* @author Hyunjoon Kim
* @since 3.5
*/
public class JpaMetamodelContext implements PathOptimizationStrategy.MetamodelContext {

private final @Nullable Metamodel metamodel;

public JpaMetamodelContext(@Nullable Metamodel metamodel) {
this.metamodel = metamodel;
}

@Override
public boolean isEntityType(Class<?> type) {
if (metamodel == null) {
return false;
}

try {
metamodel.entity(type);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}

@Override
public boolean isIdProperty(Class<?> entityType, String propertyName) {
if (metamodel == null) {
return false;
}

try {
ManagedType<?> managedType = metamodel.managedType(entityType);

if (managedType instanceof EntityType<?> entity) {
// Check for single ID attribute
if (entity.hasSingleIdAttribute()) {
SingularAttribute<?, ?> idAttribute = entity.getId(entity.getIdType().getJavaType());
return idAttribute.getName().equals(propertyName);
}

// Check for composite ID
return entity.getIdClassAttributes().stream()
.anyMatch(attr -> attr.getName().equals(propertyName));
}

} catch (IllegalArgumentException e) {
// Type not found in metamodel
}

return false;
}

@Override
@Nullable
public Class<?> getPropertyType(Class<?> entityType, String propertyName) {
if (metamodel == null) {
return null;
}

try {
ManagedType<?> managedType = metamodel.managedType(entityType);
return managedType.getAttribute(propertyName).getJavaType();
} catch (IllegalArgumentException e) {
return null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,28 @@ static JpqlQueryBuilder.PathExpression toExpressionRecursively(@Nullable Metamod
String segment = property.getSegment();

boolean isLeafProperty = !property.hasNext();
boolean requiresOuterJoin = requiresOuterJoin(metamodel, from, property, isForSelection, hasRequiredOuterJoin);

// if it does not require an outer join and is a leaf, simply get the segment
if (!requiresOuterJoin && isLeafProperty) {
return new JpqlQueryBuilder.PathAndOrigin(property, source, false);

// Check for relationship ID optimization using unified abstraction
PathOptimizationStrategy strategy = new DefaultPathOptimizationStrategy();
JpaMetamodelContext context = new JpaMetamodelContext(metamodel);
boolean isRelationshipId = strategy.canOptimizeForeignKeyAccess(property, from, context);

boolean requiresOuterJoin = requiresOuterJoin(metamodel, from, property, isForSelection, hasRequiredOuterJoin, isLeafProperty, isRelationshipId);

// if it does not require an outer join and is a leaf or relationship id, simply get rest of the segment path
if (!requiresOuterJoin && (isLeafProperty || isRelationshipId)) {
if (isRelationshipId) {
// For relationship ID case, create implicit path without joins
PropertyPath implicitPath = PropertyPath.from(segment, from.getBindableJavaType());
PropertyPath remainingPath = property.next();
while (remainingPath != null) {
implicitPath = implicitPath.nested(remainingPath.getSegment());
remainingPath = remainingPath.next();
}
return new JpqlQueryBuilder.PathAndOrigin(implicitPath, source, false);
} else {
return new JpqlQueryBuilder.PathAndOrigin(property, source, false);
}
}

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

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

boolean isLeafProperty = !propertyPath.hasNext();
if (isLeafProperty && !isForSelection && !isCollection && !isInverseOptionalOneToOne && !hasRequiredOuterJoin) {
if ((isLeafProperty || isRelationshipId) && !isForSelection && !isCollection && !isInverseOptionalOneToOne && !hasRequiredOuterJoin) {
return false;
}

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

return null;
}

/**
* Checks if the given property path can be optimized by directly accessing the foreign key column
* instead of creating a JOIN.
*
* @param metamodel the JPA metamodel
* @param from the bindable to check
* @param property the property path
* @return true if this can be optimized to use foreign key directly
*/
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* 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.query;

import jakarta.persistence.metamodel.Bindable;

import org.springframework.data.mapping.PropertyPath;

/**
* Strategy interface for optimizing property path traversal in JPA queries.
* Implementations determine when foreign key columns can be accessed directly
* without creating unnecessary JOINs.
*
* @author Hyunjoon Kim
* @since 3.5
*/
public interface PathOptimizationStrategy {

/**
* Determines if a property path can be optimized by accessing the foreign key
* column directly instead of creating a JOIN.
*
* @param path the property path to check
* @param bindable the JPA bindable containing the property
* @param context metadata context for type information
* @return true if the path can be optimized
*/
boolean canOptimizeForeignKeyAccess(PropertyPath path, Bindable<?> bindable, MetamodelContext context);

/**
* Checks if the given property path represents an association's identifier.
* For example, in "author.id", this would return true when "id" is the
* identifier of the Author entity.
*
* @param path the property path to check
* @param context metadata context for type information
* @return true if the path ends with an association's identifier
*/
boolean isAssociationId(PropertyPath path, MetamodelContext context);

/**
* Context interface providing minimal metamodel information needed for
* optimization decisions. This abstraction allows the strategy to work
* in both runtime and AOT compilation scenarios.
*/
interface MetamodelContext {

/**
* Checks if a type is a managed entity type.
*
* @param type the class to check
* @return true if the type is a managed entity
*/
boolean isEntityType(Class<?> type);

/**
* Checks if a property is an identifier property of an entity.
*
* @param entityType the entity class
* @param propertyName the property name
* @return true if the property is an identifier
*/
boolean isIdProperty(Class<?> entityType, String propertyName);

/**
* Gets the type of a property.
*
* @param entityType the entity class
* @param propertyName the property name
* @return the property type, or null if not found
*/
Class<?> getPropertyType(Class<?> entityType, String propertyName);
}
}
Loading