Skip to content

Commit 376410c

Browse files
committed
Support Hibernate @Any annotation in query derivation.
This commit adds support for Hibernate's @Any annotation in Spring Data JPA query derivation. The @Any annotation enables polymorphic associations in Hibernate but is not represented in the JPA metamodel, causing IllegalArgumentException during query generation. The fix uses reflection to detect @Any annotated properties and handles them specially in the query derivation process, bypassing the metamodel lookup that would otherwise fail. Closes #2318 Signed-off-by: academey <[email protected]>
1 parent d5d9332 commit 376410c

File tree

2 files changed

+281
-1
lines changed

2 files changed

+281
-1
lines changed

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

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,50 @@ private static Nulls toNulls(Sort.NullHandling nullHandling) {
746746
};
747747
}
748748

749+
/**
750+
* Checks if the given property path represents a property annotated with Hibernate's @Any annotation.
751+
* This is necessary because @Any associations are not present in the JPA metamodel.
752+
*
753+
* @param from the root from which to resolve the property
754+
* @param property the property path to check
755+
* @return true if the property is annotated with @Any, false otherwise
756+
* @since 4.0
757+
*/
758+
private static boolean isAnyAnnotatedProperty(From<?, ?> from, PropertyPath property) {
759+
try {
760+
// Get the Java type of the from clause
761+
Class<?> javaType = from.getJavaType();
762+
String propertyName = property.getSegment();
763+
764+
// Try to find the field
765+
Member member = null;
766+
try {
767+
member = javaType.getDeclaredField(propertyName);
768+
} catch (NoSuchFieldException ex) {
769+
// Try to find a getter method
770+
String capitalizedProperty = propertyName.substring(0, 1).toUpperCase() + propertyName.substring(1);
771+
try {
772+
member = javaType.getDeclaredMethod("get" + capitalizedProperty);
773+
} catch (NoSuchMethodException ex2) {
774+
// Property not found
775+
return false;
776+
}
777+
}
778+
779+
if (member instanceof AnnotatedElement annotatedElement) {
780+
// Check for Hibernate @Any annotation using reflection to avoid compile-time dependency
781+
for (Annotation annotation : annotatedElement.getAnnotations()) {
782+
if (annotation.annotationType().getName().equals("org.hibernate.annotations.Any")) {
783+
return true;
784+
}
785+
}
786+
}
787+
} catch (Exception ex) {
788+
// If anything goes wrong, assume it's not an @Any property
789+
}
790+
return false;
791+
}
792+
749793
static <T> Expression<T> toExpressionRecursively(From<?, ?> from, PropertyPath property) {
750794
return toExpressionRecursively(from, property, false);
751795
}
@@ -773,6 +817,13 @@ static <T> Expression<T> toExpressionRecursively(From<?, ?> from, PropertyPath p
773817

774818
boolean isLeafProperty = !property.hasNext();
775819

820+
// Check if this is a Hibernate @Any annotated property
821+
if (isAnyAnnotatedProperty(from, property)) {
822+
// For @Any associations, we need to handle them specially since they're not in the metamodel
823+
// Simply return the path expression without further processing
824+
return from.get(segment);
825+
}
826+
776827
boolean requiresOuterJoin = requiresOuterJoin(from, property, isForSelection, hasRequiredOuterJoin);
777828

778829
// if it does not require an outer join and is a leaf, simply get the segment
@@ -816,6 +867,12 @@ static boolean requiresOuterJoin(From<?, ?> from, PropertyPath property, boolean
816867
return false;
817868
}
818869

870+
// Check if this is a @Any annotated property
871+
if (isAnyAnnotatedProperty(from, property)) {
872+
// @Any associations should be treated as optional associations
873+
return true;
874+
}
875+
819876
Bindable<?> model = from.getModel();
820877
ManagedType<?> managedType = getManagedTypeForModel(model);
821878
Bindable<?> propertyPathModel = getModelForPath(property, managedType, from);
@@ -827,6 +884,12 @@ static boolean requiresOuterJoin(From<?, ?> from, PropertyPath property, boolean
827884
return true;
828885
}
829886

887+
// If propertyPathModel is null, it might be a @Any association
888+
if (propertyPathModel == null) {
889+
// For @Any associations or other non-metamodel properties, default to outer join
890+
return true;
891+
}
892+
830893
if (!(propertyPathModel instanceof Attribute<?, ?> attribute)) {
831894
return false;
832895
}
@@ -969,10 +1032,17 @@ static void checkSortExpression(Order order) {
9691032
return (Bindable<?>) managedType.getAttribute(segment);
9701033
} catch (IllegalArgumentException ex) {
9711034
// ManagedType may be erased for some vendor if the attribute is declared as generic
1035+
// or the attribute is not part of the metamodel (e.g., @Any annotation)
9721036
}
9731037
}
9741038

975-
return fallback.get(segment).getModel();
1039+
try {
1040+
return fallback.get(segment).getModel();
1041+
} catch (IllegalArgumentException ex) {
1042+
// This can happen with @Any annotated properties as they're not in the metamodel
1043+
// Return null to indicate the property cannot be resolved through the metamodel
1044+
return null;
1045+
}
9761046
}
9771047

9781048
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
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 static org.assertj.core.api.Assertions.*;
19+
20+
import jakarta.persistence.Column;
21+
import jakarta.persistence.Entity;
22+
import jakarta.persistence.EntityManager;
23+
import jakarta.persistence.GeneratedValue;
24+
import jakarta.persistence.Id;
25+
import jakarta.persistence.JoinColumn;
26+
import jakarta.persistence.PersistenceContext;
27+
import jakarta.persistence.Table;
28+
29+
import org.hibernate.annotations.Any;
30+
import org.hibernate.annotations.AnyDiscriminator;
31+
import org.hibernate.annotations.AnyDiscriminatorValue;
32+
import org.hibernate.annotations.AnyKeyJavaClass;
33+
import org.junit.jupiter.api.Test;
34+
import org.junit.jupiter.api.extension.ExtendWith;
35+
import org.springframework.beans.factory.annotation.Autowired;
36+
import org.springframework.context.annotation.Configuration;
37+
import org.springframework.context.annotation.ImportResource;
38+
import org.springframework.data.jpa.repository.JpaRepository;
39+
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
40+
import org.springframework.test.context.ContextConfiguration;
41+
import org.springframework.test.context.junit.jupiter.SpringExtension;
42+
import org.springframework.transaction.annotation.Transactional;
43+
44+
/**
45+
* Integration tests for Hibernate @Any annotation support in query derivation.
46+
*
47+
* @author Hyunjoon Park
48+
*/
49+
@ExtendWith(SpringExtension.class)
50+
@ContextConfiguration
51+
@Transactional
52+
class HibernateAnyAnnotationIntegrationTests {
53+
54+
@PersistenceContext EntityManager em;
55+
@Autowired TestEntityRepository repository;
56+
57+
@Test
58+
void derivedQueryMethodShouldWorkWithAnyAnnotation() {
59+
60+
MonitorableEntity monitorable = new MonitorableEntity();
61+
monitorable.name = "Test Entity";
62+
em.persist(monitorable);
63+
64+
TestEntity entity = new TestEntity();
65+
entity.monitorObject = monitorable;
66+
entity = repository.save(entity);
67+
68+
em.flush();
69+
em.clear();
70+
71+
// This would throw IllegalArgumentException without the fix
72+
var result = repository.findByMonitorObjectId(monitorable.id);
73+
74+
assertThat(result).hasSize(1);
75+
assertThat(result.get(0).id).isEqualTo(entity.id);
76+
}
77+
78+
@Test
79+
void shouldHandleNullAnyAssociations() {
80+
81+
TestEntity entity = new TestEntity();
82+
entity.monitorObject = null;
83+
repository.save(entity);
84+
85+
var result = repository.findByMonitorObjectId(999L);
86+
87+
assertThat(result).isEmpty();
88+
}
89+
90+
@Test
91+
void shouldSupportMultipleAnyAssociationTypes() {
92+
93+
MonitorableEntity monitorable = new MonitorableEntity();
94+
monitorable.name = "First Type";
95+
em.persist(monitorable);
96+
97+
AnotherMonitorableEntity another = new AnotherMonitorableEntity();
98+
another.code = "ABC123";
99+
em.persist(another);
100+
101+
TestEntity entity1 = new TestEntity();
102+
entity1.monitorObject = monitorable;
103+
repository.save(entity1);
104+
105+
TestEntity entity2 = new TestEntity();
106+
entity2.monitorObject = another;
107+
repository.save(entity2);
108+
109+
em.flush();
110+
em.clear();
111+
112+
var result1 = repository.findByMonitorObjectId(monitorable.id);
113+
var result2 = repository.findByMonitorObjectId(another.id);
114+
115+
assertThat(result1).hasSize(1);
116+
assertThat(result2).hasSize(1);
117+
}
118+
119+
@Test
120+
void countByAnyAssociationShouldWork() {
121+
122+
MonitorableEntity monitorable = new MonitorableEntity();
123+
monitorable.name = "Test";
124+
em.persist(monitorable);
125+
126+
TestEntity entity1 = new TestEntity();
127+
entity1.monitorObject = monitorable;
128+
repository.save(entity1);
129+
130+
TestEntity entity2 = new TestEntity();
131+
entity2.monitorObject = monitorable;
132+
repository.save(entity2);
133+
134+
long count = repository.countByMonitorObjectId(monitorable.id);
135+
136+
assertThat(count).isEqualTo(2);
137+
}
138+
139+
@Test
140+
void existsByAnyAssociationShouldWork() {
141+
142+
MonitorableEntity monitorable = new MonitorableEntity();
143+
monitorable.name = "Test";
144+
em.persist(monitorable);
145+
146+
TestEntity entity = new TestEntity();
147+
entity.monitorObject = monitorable;
148+
repository.save(entity);
149+
150+
boolean exists = repository.existsByMonitorObjectId(monitorable.id);
151+
152+
assertThat(exists).isTrue();
153+
}
154+
155+
156+
@Entity
157+
@Table(name = "test_entity")
158+
static class TestEntity {
159+
160+
@Id
161+
@GeneratedValue
162+
Long id;
163+
164+
@Any
165+
@AnyDiscriminator // Default is STRING type
166+
@AnyDiscriminatorValue(discriminator = "monitorable", entity = MonitorableEntity.class)
167+
@AnyDiscriminatorValue(discriminator = "another", entity = AnotherMonitorableEntity.class)
168+
@AnyKeyJavaClass(Long.class)
169+
@JoinColumn(name = "monitor_object_id")
170+
@Column(name = "monitor_object_type")
171+
Object monitorObject;
172+
}
173+
174+
@Entity
175+
@Table(name = "monitorable_entity")
176+
static class MonitorableEntity {
177+
178+
@Id
179+
@GeneratedValue
180+
Long id;
181+
182+
String name;
183+
}
184+
185+
@Entity
186+
@Table(name = "another_monitorable_entity")
187+
static class AnotherMonitorableEntity {
188+
189+
@Id
190+
@GeneratedValue
191+
Long id;
192+
193+
String code;
194+
}
195+
196+
public interface TestEntityRepository extends JpaRepository<TestEntity, Long> {
197+
198+
java.util.List<TestEntity> findByMonitorObjectId(Long id);
199+
200+
long countByMonitorObjectId(Long id);
201+
202+
boolean existsByMonitorObjectId(Long id);
203+
}
204+
205+
@Configuration
206+
@EnableJpaRepositories(considerNestedRepositories = true)
207+
@ImportResource("classpath:infrastructure.xml")
208+
static class Config {
209+
}
210+
}

0 commit comments

Comments
 (0)