Skip to content

Commit cece7cd

Browse files
committed
Support Hibernate @Any annotation in query derivation
Fixes #2318 Added support for Hibernate's @Any annotation in query derivation. The issue was that @Any properties are not part of JPA metamodel, causing IllegalArgumentException. This fix uses reflection to detect @Any annotations and handles them appropriately during query creation. Signed-off-by: academey <[email protected]>
1 parent b93c238 commit cece7cd

File tree

4 files changed

+134
-1
lines changed

4 files changed

+134
-1
lines changed

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ public boolean requiresOuterJoin(ModelPathResolver resolver, PropertyPath proper
7979

8080
Bindable<?> propertyPathModel = resolver.resolve(property);
8181

82+
// If propertyPathModel is null, it might be a @Any association
83+
if (propertyPathModel == null) {
84+
// For @Any associations or other non-metamodel properties, default to outer join
85+
return true;
86+
}
87+
8288
if (!(propertyPathModel instanceof Attribute<?, ?> attribute)) {
8389
return false;
8490
}

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

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@
3232
import jakarta.persistence.metamodel.Bindable;
3333
import jakarta.persistence.metamodel.ManagedType;
3434

35+
import java.lang.annotation.Annotation;
36+
import java.lang.reflect.AnnotatedElement;
37+
import java.lang.reflect.Member;
3538
import java.util.ArrayList;
3639
import java.util.Collection;
3740
import java.util.Collections;
@@ -88,6 +91,7 @@
8891
* @author Yanming Zhou
8992
* @author Alim Naizabek
9093
* @author Jakub Soltys
94+
* @author Hyunjoon Park
9195
*/
9296
public abstract class QueryUtils {
9397

@@ -789,6 +793,13 @@ <T> Expression<T> toExpressionRecursively(From<?, ?> from, PropertyPath property
789793

790794
boolean isLeafProperty = !property.hasNext();
791795

796+
// Check if this is a Hibernate @Any annotated property
797+
if (isAnyAnnotatedProperty(from, property)) {
798+
// For @Any associations, we need to handle them specially since they're not in the metamodel
799+
// Simply return the path expression without further processing
800+
return from.get(segment);
801+
}
802+
792803
FromPathResolver resolver = new FromPathResolver(from);
793804
boolean isRelationshipId = isRelationshipId(resolver, property);
794805
boolean requiresOuterJoin = requiresOuterJoin(resolver, property, isForSelection, hasRequiredOuterJoin,
@@ -947,12 +958,58 @@ private static Bindable<?> getModelForPath(PropertyPath path, @Nullable ManagedT
947958
return (Bindable<?>) managedType.getAttribute(segment);
948959
} catch (IllegalArgumentException ex) {
949960
// ManagedType may be erased for some vendor if the attribute is declared as generic
961+
// or the attribute is not part of the metamodel (e.g., @Any annotation)
950962
}
951963
}
952964

953-
return (Bindable<?>) fallback.get().get(segment);
965+
try {
966+
return (Bindable<?>) fallback.get().get(segment);
967+
} catch (IllegalArgumentException ex) {
968+
// This can happen with @Any annotated properties as they're not in the metamodel
969+
// Return null to indicate the property cannot be resolved through the metamodel
970+
return null;
971+
}
954972
}
955973
}
974+
975+
/**
976+
* Checks if the given property path represents a property annotated with Hibernate's @Any annotation.
977+
* This is necessary because @Any associations are not present in the JPA metamodel.
978+
*
979+
* @param from the root from which to resolve the property
980+
* @param property the property path to check
981+
* @return true if the property is annotated with @Any, false otherwise
982+
*/
983+
private static boolean isAnyAnnotatedProperty(From<?, ?> from, PropertyPath property) {
984+
985+
try {
986+
Class<?> javaType = from.getJavaType();
987+
String propertyName = property.getSegment();
988+
989+
Member member = null;
990+
try {
991+
member = javaType.getDeclaredField(propertyName);
992+
} catch (NoSuchFieldException ex) {
993+
String capitalizedProperty = propertyName.substring(0, 1).toUpperCase() + propertyName.substring(1);
994+
try {
995+
member = javaType.getDeclaredMethod("get" + capitalizedProperty);
996+
} catch (NoSuchMethodException ex2) {
997+
return false;
998+
}
999+
}
1000+
1001+
if (member instanceof AnnotatedElement annotatedElement) {
1002+
for (Annotation annotation : annotatedElement.getAnnotations()) {
1003+
if (annotation.annotationType().getName().equals("org.hibernate.annotations.Any")) {
1004+
return true;
1005+
}
1006+
}
1007+
}
1008+
} catch (Exception ex) {
1009+
// If anything goes wrong, assume it's not an @Any property
1010+
}
1011+
return false;
1012+
}
9561013
}
9571014

9581015
}

spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsIntegrationTests.java

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@
4545
import java.util.function.Consumer;
4646
import java.util.stream.Collectors;
4747

48+
import org.hibernate.annotations.Any;
49+
import org.hibernate.annotations.AnyDiscriminator;
50+
import org.hibernate.annotations.AnyDiscriminatorValue;
51+
import org.hibernate.annotations.AnyKeyJavaClass;
4852
import org.junit.jupiter.api.Test;
4953
import org.junit.jupiter.api.extension.ExtendWith;
5054
import org.mockito.Mockito;
@@ -73,6 +77,7 @@
7377
* @author Diego Krupitza
7478
* @author Krzysztof Krason
7579
* @author Jakub Soltys
80+
* @author Hyunjoon Park
7681
*/
7782
@ExtendWith(SpringExtension.class)
7883
@ContextConfiguration("classpath:infrastructure.xml")
@@ -372,6 +377,36 @@ void queryUtilsConsidersNullPrecedence() {
372377
}
373378
}
374379

380+
@Test // GH-2318
381+
void handlesHibernateAnyAnnotationWithoutThrowingException() {
382+
383+
doInMerchantContext((emf) -> {
384+
385+
CriteriaBuilder builder = emf.createEntityManager().getCriteriaBuilder();
386+
CriteriaQuery<EntityWithAny> query = builder.createQuery(EntityWithAny.class);
387+
Root<EntityWithAny> root = query.from(EntityWithAny.class);
388+
389+
// This would throw IllegalArgumentException without the fix
390+
PropertyPath monitorObjectPath = PropertyPath.from("monitorObject", EntityWithAny.class);
391+
assertThatNoException().isThrownBy(() -> QueryUtils.toExpressionRecursively(root, monitorObjectPath));
392+
});
393+
}
394+
395+
@Test // GH-2318
396+
void doesNotCreateJoinForAnyAnnotatedProperty() {
397+
398+
doInMerchantContext((emf) -> {
399+
400+
CriteriaBuilder builder = emf.createEntityManager().getCriteriaBuilder();
401+
CriteriaQuery<EntityWithAny> query = builder.createQuery(EntityWithAny.class);
402+
Root<EntityWithAny> root = query.from(EntityWithAny.class);
403+
404+
QueryUtils.toExpressionRecursively(root, PropertyPath.from("monitorObject", EntityWithAny.class));
405+
406+
assertThat(root.getJoins()).isEmpty();
407+
});
408+
}
409+
375410
/**
376411
* This test documents an ambiguity in the JPA spec (or it's implementation in Hibernate vs EclipseLink) that we have
377412
* to work around in the test {@link #doesNotCreateJoinForOptionalAssociationWithoutFurtherNavigation()}. See also:
@@ -475,6 +510,38 @@ static class Credential {
475510
String uid;
476511
}
477512

513+
@Entity
514+
@SuppressWarnings("unused")
515+
static class EntityWithAny {
516+
517+
@Id String id;
518+
519+
@Any
520+
@AnyDiscriminator // Default is STRING type
521+
@AnyDiscriminatorValue(discriminator = "monitorable", entity = MonitorableEntity.class)
522+
@AnyDiscriminatorValue(discriminator = "another", entity = AnotherMonitorableEntity.class)
523+
@AnyKeyJavaClass(String.class)
524+
@jakarta.persistence.JoinColumn(name = "monitor_object_id")
525+
@jakarta.persistence.Column(name = "monitor_object_type")
526+
Object monitorObject;
527+
}
528+
529+
@Entity
530+
@SuppressWarnings("unused")
531+
static class MonitorableEntity {
532+
533+
@Id String id;
534+
String name;
535+
}
536+
537+
@Entity
538+
@SuppressWarnings("unused")
539+
static class AnotherMonitorableEntity {
540+
541+
@Id String id;
542+
String code;
543+
}
544+
478545
/**
479546
* A {@link PersistenceProviderResolver} that returns only a Hibernate {@link PersistenceProvider} and ignores others.
480547
*

spring-data-jpa/src/test/resources/META-INF/persistence.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@
106106
<class>org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$Address</class>
107107
<class>org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$Employee</class>
108108
<class>org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$Credential</class>
109+
<class>org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$EntityWithAny</class>
110+
<class>org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$MonitorableEntity</class>
111+
<class>org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$AnotherMonitorableEntity</class>
109112
<exclude-unlisted-classes>true</exclude-unlisted-classes>
110113
<properties>
111114
<property name="hibernate.dialect" value="org.hibernate.dialect.HSQLDialect" />

0 commit comments

Comments
 (0)