Skip to content

Commit 559aa27

Browse files
committed
Support Hibernate @Any annotation in query derivation.
Added special handling for Hibernate's @Any annotation in QueryUtils to prevent IllegalArgumentException when deriving queries for properties annotated with @Any. Since @Any associations are not part of the JPA metamodel, they require special detection and handling using reflection. The fix: - Detects @Any annotated properties using reflection to avoid compile-time Hibernate dependency - Returns the property path directly without metamodel validation for @Any properties - Treats @Any associations as optional (requiring outer joins) - Handles metamodel lookup failures gracefully when encountering @Any properties This change allows Spring Data JPA repositories to use query derivation methods for entities containing @Any associations without throwing exceptions. Fixes #2318 Signed-off-by: academey <[email protected]>
1 parent d5d9332 commit 559aa27

File tree

2 files changed

+289
-1
lines changed

2 files changed

+289
-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
@@ -773,6 +773,13 @@ static <T> Expression<T> toExpressionRecursively(From<?, ?> from, PropertyPath p
773773

774774
boolean isLeafProperty = !property.hasNext();
775775

776+
// Check if this is a Hibernate @Any annotated property
777+
if (isAnyAnnotatedProperty(from, property)) {
778+
// For @Any associations, we need to handle them specially since they're not in the metamodel
779+
// Simply return the path expression without further processing
780+
return from.get(segment);
781+
}
782+
776783
boolean requiresOuterJoin = requiresOuterJoin(from, property, isForSelection, hasRequiredOuterJoin);
777784

778785
// if it does not require an outer join and is a leaf, simply get the segment
@@ -816,6 +823,12 @@ static boolean requiresOuterJoin(From<?, ?> from, PropertyPath property, boolean
816823
return false;
817824
}
818825

826+
// Check if this is a @Any annotated property
827+
if (isAnyAnnotatedProperty(from, property)) {
828+
// @Any associations should be treated as optional associations
829+
return true;
830+
}
831+
819832
Bindable<?> model = from.getModel();
820833
ManagedType<?> managedType = getManagedTypeForModel(model);
821834
Bindable<?> propertyPathModel = getModelForPath(property, managedType, from);
@@ -827,6 +840,12 @@ static boolean requiresOuterJoin(From<?, ?> from, PropertyPath property, boolean
827840
return true;
828841
}
829842

843+
// If propertyPathModel is null, it might be a @Any association
844+
if (propertyPathModel == null) {
845+
// For @Any associations or other non-metamodel properties, default to outer join
846+
return true;
847+
}
848+
830849
if (!(propertyPathModel instanceof Attribute<?, ?> attribute)) {
831850
return false;
832851
}
@@ -969,10 +988,17 @@ static void checkSortExpression(Order order) {
969988
return (Bindable<?>) managedType.getAttribute(segment);
970989
} catch (IllegalArgumentException ex) {
971990
// ManagedType may be erased for some vendor if the attribute is declared as generic
991+
// or the attribute is not part of the metamodel (e.g., @Any annotation)
972992
}
973993
}
974994

975-
return fallback.get(segment).getModel();
995+
try {
996+
return fallback.get(segment).getModel();
997+
} catch (IllegalArgumentException ex) {
998+
// This can happen with @Any annotated properties as they're not in the metamodel
999+
// Return null to indicate the property cannot be resolved through the metamodel
1000+
return null;
1001+
}
9761002
}
9771003

9781004
/**
@@ -996,4 +1022,48 @@ static void checkSortExpression(Order order) {
9961022

9971023
return singularAttribute.getType() instanceof ManagedType<?> managedType ? managedType : null;
9981024
}
1025+
1026+
/**
1027+
* Checks if the given property path represents a property annotated with Hibernate's @Any annotation.
1028+
* This is necessary because @Any associations are not present in the JPA metamodel.
1029+
*
1030+
* @param from the root from which to resolve the property
1031+
* @param property the property path to check
1032+
* @return true if the property is annotated with @Any, false otherwise
1033+
* @since 4.0
1034+
*/
1035+
private static boolean isAnyAnnotatedProperty(From<?, ?> from, PropertyPath property) {
1036+
try {
1037+
// Get the Java type of the from clause
1038+
Class<?> javaType = from.getJavaType();
1039+
String propertyName = property.getSegment();
1040+
1041+
// Try to find the field
1042+
Member member = null;
1043+
try {
1044+
member = javaType.getDeclaredField(propertyName);
1045+
} catch (NoSuchFieldException ex) {
1046+
// Try to find a getter method
1047+
String capitalizedProperty = propertyName.substring(0, 1).toUpperCase() + propertyName.substring(1);
1048+
try {
1049+
member = javaType.getDeclaredMethod("get" + capitalizedProperty);
1050+
} catch (NoSuchMethodException ex2) {
1051+
// Property not found
1052+
return false;
1053+
}
1054+
}
1055+
1056+
if (member instanceof AnnotatedElement annotatedElement) {
1057+
// Check for Hibernate @Any annotation using reflection to avoid compile-time dependency
1058+
for (Annotation annotation : annotatedElement.getAnnotations()) {
1059+
if (annotation.annotationType().getName().equals("org.hibernate.annotations.Any")) {
1060+
return true;
1061+
}
1062+
}
1063+
}
1064+
} catch (Exception ex) {
1065+
// If anything goes wrong, assume it's not an @Any property
1066+
}
1067+
return false;
1068+
}
9991069
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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.Entity;
21+
import jakarta.persistence.EntityManager;
22+
import jakarta.persistence.GeneratedValue;
23+
import jakarta.persistence.Id;
24+
import jakarta.persistence.PersistenceContext;
25+
import jakarta.persistence.Table;
26+
import jakarta.persistence.criteria.CriteriaBuilder;
27+
import jakarta.persistence.criteria.CriteriaQuery;
28+
import jakarta.persistence.criteria.Root;
29+
30+
import java.io.Serializable;
31+
import java.lang.reflect.Method;
32+
33+
import org.hibernate.annotations.Any;
34+
import org.hibernate.annotations.AnyDiscriminator;
35+
import org.hibernate.annotations.AnyDiscriminatorValue;
36+
import org.hibernate.annotations.AnyKeyJavaClass;
37+
import org.hibernate.annotations.DiscriminatorType;
38+
import org.junit.jupiter.api.Test;
39+
import org.junit.jupiter.api.extension.ExtendWith;
40+
import org.springframework.beans.factory.annotation.Autowired;
41+
import org.springframework.context.annotation.Bean;
42+
import org.springframework.context.annotation.Configuration;
43+
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
44+
import org.springframework.data.jpa.repository.JpaRepository;
45+
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
46+
import org.springframework.data.jpa.repository.support.JpaRepositoryFactory;
47+
import org.springframework.data.mapping.PropertyPath;
48+
import org.springframework.data.repository.query.QueryMethod;
49+
import org.springframework.test.context.ContextConfiguration;
50+
import org.springframework.test.context.junit.jupiter.SpringExtension;
51+
import org.springframework.transaction.annotation.Transactional;
52+
53+
import com.zaxxer.hikari.HikariDataSource;
54+
55+
import org.springframework.orm.jpa.JpaTransactionManager;
56+
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
57+
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
58+
59+
/**
60+
* Integration tests for Hibernate @Any annotation support in query derivation.
61+
*
62+
* @author Hyunjoon Choi
63+
*/
64+
@ExtendWith(SpringExtension.class)
65+
@ContextConfiguration(classes = HibernateAnyAnnotationIntegrationTests.TestConfig.class)
66+
@Transactional
67+
class HibernateAnyAnnotationIntegrationTests {
68+
69+
@PersistenceContext EntityManager em;
70+
@Autowired TestEntityRepository repository;
71+
72+
@Test
73+
void shouldHandleAnyAnnotationInQueryDerivation() {
74+
75+
// Given
76+
MonitorableEntity monitorable = new MonitorableEntity();
77+
monitorable.name = "Test Entity";
78+
em.persist(monitorable);
79+
80+
TestEntity entity = new TestEntity();
81+
entity.monitorObject = monitorable;
82+
entity = repository.save(entity);
83+
84+
em.flush();
85+
em.clear();
86+
87+
// When
88+
var result = repository.findByMonitorObjectId(monitorable.id);
89+
90+
// Then
91+
assertThat(result).isNotEmpty();
92+
assertThat(result.get(0).id).isEqualTo(entity.id);
93+
}
94+
95+
@Test
96+
void shouldReturnEmptyWhenMonitorObjectIsNull() {
97+
98+
// Given
99+
TestEntity entity = new TestEntity();
100+
entity.monitorObject = null;
101+
repository.save(entity);
102+
103+
em.flush();
104+
em.clear();
105+
106+
// When
107+
var result = repository.findByMonitorObjectId(999L);
108+
109+
// Then
110+
assertThat(result).isEmpty();
111+
}
112+
113+
@Test
114+
void isAnyAnnotatedPropertyReflectionTest() throws Exception {
115+
116+
// Use reflection to test the private method
117+
Method method = QueryUtils.class.getDeclaredMethod("isAnyAnnotatedProperty",
118+
jakarta.persistence.criteria.From.class, PropertyPath.class);
119+
method.setAccessible(true);
120+
121+
CriteriaBuilder cb = em.getCriteriaBuilder();
122+
CriteriaQuery<TestEntity> query = cb.createQuery(TestEntity.class);
123+
Root<TestEntity> root = query.from(TestEntity.class);
124+
125+
PropertyPath propertyPath = PropertyPath.from("monitorObject", TestEntity.class);
126+
127+
// When
128+
Boolean result = (Boolean) method.invoke(null, root, propertyPath);
129+
130+
// Then
131+
assertThat(result).isTrue();
132+
}
133+
134+
@Test
135+
void regularPropertyShouldNotBeDetectedAsAny() throws Exception {
136+
137+
// Use reflection to test the private method
138+
Method method = QueryUtils.class.getDeclaredMethod("isAnyAnnotatedProperty",
139+
jakarta.persistence.criteria.From.class, PropertyPath.class);
140+
method.setAccessible(true);
141+
142+
CriteriaBuilder cb = em.getCriteriaBuilder();
143+
CriteriaQuery<TestEntity> query = cb.createQuery(TestEntity.class);
144+
Root<TestEntity> root = query.from(TestEntity.class);
145+
146+
PropertyPath propertyPath = PropertyPath.from("id", TestEntity.class);
147+
148+
// When
149+
Boolean result = (Boolean) method.invoke(null, root, propertyPath);
150+
151+
// Then
152+
assertThat(result).isFalse();
153+
}
154+
155+
@Configuration
156+
@EnableJpaRepositories(considerNestedRepositories = true)
157+
static class TestConfig {
158+
159+
@Bean
160+
HikariDataSource dataSource() {
161+
HikariDataSource dataSource = new HikariDataSource();
162+
dataSource.setJdbcUrl("jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1");
163+
dataSource.setUsername("sa");
164+
dataSource.setPassword("");
165+
return dataSource;
166+
}
167+
168+
@Bean
169+
LocalContainerEntityManagerFactoryBean entityManagerFactory(HikariDataSource dataSource) {
170+
HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
171+
vendorAdapter.setGenerateDdl(true);
172+
vendorAdapter.setShowSql(false);
173+
174+
LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
175+
factory.setJpaVendorAdapter(vendorAdapter);
176+
factory.setDataSource(dataSource);
177+
factory.setPackagesToScan(HibernateAnyAnnotationIntegrationTests.class.getPackage().getName());
178+
return factory;
179+
}
180+
181+
@Bean
182+
JpaTransactionManager transactionManager(LocalContainerEntityManagerFactoryBean entityManagerFactory) {
183+
JpaTransactionManager transactionManager = new JpaTransactionManager();
184+
transactionManager.setEntityManagerFactory(entityManagerFactory.getObject());
185+
return transactionManager;
186+
}
187+
}
188+
189+
@Entity
190+
@Table(name = "test_entity")
191+
static class TestEntity {
192+
193+
@Id
194+
@GeneratedValue
195+
Long id;
196+
197+
@Any
198+
@AnyDiscriminator(DiscriminatorType.STRING)
199+
@AnyDiscriminatorValue(discriminator = "monitorable", entity = MonitorableEntity.class)
200+
@AnyKeyJavaClass(Long.class)
201+
Object monitorObject;
202+
}
203+
204+
@Entity
205+
@Table(name = "monitorable_entity")
206+
static class MonitorableEntity {
207+
208+
@Id
209+
@GeneratedValue
210+
Long id;
211+
212+
String name;
213+
}
214+
215+
interface TestEntityRepository extends JpaRepository<TestEntity, Long> {
216+
java.util.List<TestEntity> findByMonitorObjectId(Long id);
217+
}
218+
}

0 commit comments

Comments
 (0)