diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ExpressionFactorySupport.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ExpressionFactorySupport.java index aa167eca54..bc02029b11 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ExpressionFactorySupport.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ExpressionFactorySupport.java @@ -79,6 +79,12 @@ public boolean requiresOuterJoin(ModelPathResolver resolver, PropertyPath proper Bindable propertyPathModel = resolver.resolve(property); + // If propertyPathModel is null, it might be a @Any association + if (propertyPathModel == null) { + // For @Any associations or other non-metamodel properties, default to outer join + return true; + } + if (!(propertyPathModel instanceof Attribute attribute)) { return false; } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java index 0ef35d2b9d..c12218e4e6 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java @@ -32,6 +32,9 @@ import jakarta.persistence.metamodel.Bindable; import jakarta.persistence.metamodel.ManagedType; +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Member; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -88,6 +91,7 @@ * @author Yanming Zhou * @author Alim Naizabek * @author Jakub Soltys + * @author Hyunjoon Park */ public abstract class QueryUtils { @@ -789,6 +793,13 @@ Expression toExpressionRecursively(From from, PropertyPath property boolean isLeafProperty = !property.hasNext(); + // Check if this is a Hibernate @Any annotated property + if (isAnyAnnotatedProperty(from, property)) { + // For @Any associations, we need to handle them specially since they're not in the metamodel + // Simply return the path expression without further processing + return from.get(segment); + } + FromPathResolver resolver = new FromPathResolver(from); boolean isRelationshipId = isRelationshipId(resolver, property); boolean requiresOuterJoin = requiresOuterJoin(resolver, property, isForSelection, hasRequiredOuterJoin, @@ -947,12 +958,58 @@ private static Bindable getModelForPath(PropertyPath path, @Nullable ManagedT return (Bindable) managedType.getAttribute(segment); } catch (IllegalArgumentException ex) { // ManagedType may be erased for some vendor if the attribute is declared as generic + // or the attribute is not part of the metamodel (e.g., @Any annotation) } } - return (Bindable) fallback.get().get(segment); + try { + return (Bindable) fallback.get().get(segment); + } catch (IllegalArgumentException ex) { + // This can happen with @Any annotated properties as they're not in the metamodel + // Return null to indicate the property cannot be resolved through the metamodel + return null; + } } } + + /** + * Checks if the given property path represents a property annotated with Hibernate's @Any annotation. + * This is necessary because @Any associations are not present in the JPA metamodel. + * + * @param from the root from which to resolve the property + * @param property the property path to check + * @return true if the property is annotated with @Any, false otherwise + */ + private static boolean isAnyAnnotatedProperty(From from, PropertyPath property) { + + try { + Class javaType = from.getJavaType(); + String propertyName = property.getSegment(); + + Member member = null; + try { + member = javaType.getDeclaredField(propertyName); + } catch (NoSuchFieldException ex) { + String capitalizedProperty = propertyName.substring(0, 1).toUpperCase() + propertyName.substring(1); + try { + member = javaType.getDeclaredMethod("get" + capitalizedProperty); + } catch (NoSuchMethodException ex2) { + return false; + } + } + + if (member instanceof AnnotatedElement annotatedElement) { + for (Annotation annotation : annotatedElement.getAnnotations()) { + if (annotation.annotationType().getName().equals("org.hibernate.annotations.Any")) { + return true; + } + } + } + } catch (Exception ex) { + // If anything goes wrong, assume it's not an @Any property + } + return false; + } } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsIntegrationTests.java index f85973efbf..034951e74e 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsIntegrationTests.java @@ -45,6 +45,10 @@ import java.util.function.Consumer; import java.util.stream.Collectors; +import org.hibernate.annotations.Any; +import org.hibernate.annotations.AnyDiscriminator; +import org.hibernate.annotations.AnyDiscriminatorValue; +import org.hibernate.annotations.AnyKeyJavaClass; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mockito; @@ -73,6 +77,7 @@ * @author Diego Krupitza * @author Krzysztof Krason * @author Jakub Soltys + * @author Hyunjoon Park */ @ExtendWith(SpringExtension.class) @ContextConfiguration("classpath:infrastructure.xml") @@ -372,6 +377,36 @@ void queryUtilsConsidersNullPrecedence() { } } + @Test // GH-2318 + void handlesHibernateAnyAnnotationWithoutThrowingException() { + + doInMerchantContext((emf) -> { + + CriteriaBuilder builder = emf.createEntityManager().getCriteriaBuilder(); + CriteriaQuery query = builder.createQuery(EntityWithAny.class); + Root root = query.from(EntityWithAny.class); + + // This would throw IllegalArgumentException without the fix + PropertyPath monitorObjectPath = PropertyPath.from("monitorObject", EntityWithAny.class); + assertThatNoException().isThrownBy(() -> QueryUtils.toExpressionRecursively(root, monitorObjectPath)); + }); + } + + @Test // GH-2318 + void doesNotCreateJoinForAnyAnnotatedProperty() { + + doInMerchantContext((emf) -> { + + CriteriaBuilder builder = emf.createEntityManager().getCriteriaBuilder(); + CriteriaQuery query = builder.createQuery(EntityWithAny.class); + Root root = query.from(EntityWithAny.class); + + QueryUtils.toExpressionRecursively(root, PropertyPath.from("monitorObject", EntityWithAny.class)); + + assertThat(root.getJoins()).isEmpty(); + }); + } + /** * This test documents an ambiguity in the JPA spec (or it's implementation in Hibernate vs EclipseLink) that we have * to work around in the test {@link #doesNotCreateJoinForOptionalAssociationWithoutFurtherNavigation()}. See also: @@ -475,6 +510,38 @@ static class Credential { String uid; } + @Entity + @SuppressWarnings("unused") + static class EntityWithAny { + + @Id String id; + + @Any + @AnyDiscriminator // Default is STRING type + @AnyDiscriminatorValue(discriminator = "monitorable", entity = MonitorableEntity.class) + @AnyDiscriminatorValue(discriminator = "another", entity = AnotherMonitorableEntity.class) + @AnyKeyJavaClass(String.class) + @jakarta.persistence.JoinColumn(name = "monitor_object_id") + @jakarta.persistence.Column(name = "monitor_object_type") + Object monitorObject; + } + + @Entity + @SuppressWarnings("unused") + static class MonitorableEntity { + + @Id String id; + String name; + } + + @Entity + @SuppressWarnings("unused") + static class AnotherMonitorableEntity { + + @Id String id; + String code; + } + /** * A {@link PersistenceProviderResolver} that returns only a Hibernate {@link PersistenceProvider} and ignores others. * diff --git a/spring-data-jpa/src/test/resources/META-INF/persistence.xml b/spring-data-jpa/src/test/resources/META-INF/persistence.xml index 44bbc1a702..baf2708869 100644 --- a/spring-data-jpa/src/test/resources/META-INF/persistence.xml +++ b/spring-data-jpa/src/test/resources/META-INF/persistence.xml @@ -106,6 +106,9 @@ org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$Address org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$Employee org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$Credential + org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$EntityWithAny + org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$MonitorableEntity + org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$AnotherMonitorableEntity true diff --git a/src/main/antora/resources/antora-resources/antora.yml b/src/main/antora/resources/antora-resources/antora.yml index a037a43a83..079699f99a 100644 --- a/src/main/antora/resources/antora-resources/antora.yml +++ b/src/main/antora/resources/antora-resources/antora.yml @@ -11,11 +11,11 @@ asciidoc: springversion: ${spring} commons: ${springdata.commons.docs} include-xml-namespaces: false - spring-data-commons-docs-url: https://docs.spring.io/spring-data/commons/reference/{commons} + spring-data-commons-docs-url: '${documentation.baseurl}/commons/reference/${springdata.commons.short}' spring-data-commons-javadoc-base: '{spring-data-commons-docs-url}/api/java' - springdocsurl: https://docs.spring.io/spring-framework/reference/{springversionshort} + springdocsurl: '${documentation.baseurl}/spring-framework/reference/{springversionshort}' spring-framework-docs: '{springdocsurl}' - springjavadocurl: https://docs.spring.io/spring-framework/docs/${spring}/javadoc-api + springjavadocurl: '${documentation.spring-javadoc-url}' spring-framework-javadoc: '{springjavadocurl}' springhateoasversion: ${spring-hateoas} hibernatejavadocurl: https://docs.jboss.org/hibernate/orm/6.6/javadocs/