Skip to content

Commit 487b80c

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 487b80c

File tree

4 files changed

+129
-1
lines changed

4 files changed

+129
-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: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,13 @@ <T> Expression<T> toExpressionRecursively(From<?, ?> from, PropertyPath property
789789

790790
boolean isLeafProperty = !property.hasNext();
791791

792+
// Check if this is a Hibernate @Any annotated property
793+
if (isAnyAnnotatedProperty(from, property)) {
794+
// For @Any associations, we need to handle them specially since they're not in the metamodel
795+
// Simply return the path expression without further processing
796+
return from.get(segment);
797+
}
798+
792799
FromPathResolver resolver = new FromPathResolver(from);
793800
boolean isRelationshipId = isRelationshipId(resolver, property);
794801
boolean requiresOuterJoin = requiresOuterJoin(resolver, property, isForSelection, hasRequiredOuterJoin,
@@ -947,12 +954,58 @@ private static Bindable<?> getModelForPath(PropertyPath path, @Nullable ManagedT
947954
return (Bindable<?>) managedType.getAttribute(segment);
948955
} catch (IllegalArgumentException ex) {
949956
// ManagedType may be erased for some vendor if the attribute is declared as generic
957+
// or the attribute is not part of the metamodel (e.g., @Any annotation)
950958
}
951959
}
952960

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

9581011
}

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

Lines changed: 66 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;
@@ -372,6 +376,36 @@ void queryUtilsConsidersNullPrecedence() {
372376
}
373377
}
374378

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

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

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)