Skip to content

Support Hibernate @Any annotation in query derivation #3978

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -88,6 +91,7 @@
* @author Yanming Zhou
* @author Alim Naizabek
* @author Jakub Soltys
* @author Hyunjoon Park
*/
public abstract class QueryUtils {

Expand Down Expand Up @@ -789,6 +793,13 @@ <T> Expression<T> 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,
Expand Down Expand Up @@ -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;
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -73,6 +77,7 @@
* @author Diego Krupitza
* @author Krzysztof Krason
* @author Jakub Soltys
* @author Hyunjoon Park
*/
@ExtendWith(SpringExtension.class)
@ContextConfiguration("classpath:infrastructure.xml")
Expand Down Expand Up @@ -372,6 +377,36 @@ void queryUtilsConsidersNullPrecedence() {
}
}

@Test // GH-2318
void handlesHibernateAnyAnnotationWithoutThrowingException() {

doInMerchantContext((emf) -> {

CriteriaBuilder builder = emf.createEntityManager().getCriteriaBuilder();
CriteriaQuery<EntityWithAny> query = builder.createQuery(EntityWithAny.class);
Root<EntityWithAny> 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<EntityWithAny> query = builder.createQuery(EntityWithAny.class);
Root<EntityWithAny> 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:
Expand Down Expand Up @@ -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.
*
Expand Down
3 changes: 3 additions & 0 deletions spring-data-jpa/src/test/resources/META-INF/persistence.xml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@
<class>org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$Address</class>
<class>org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$Employee</class>
<class>org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$Credential</class>
<class>org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$EntityWithAny</class>
<class>org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$MonitorableEntity</class>
<class>org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$AnotherMonitorableEntity</class>
<exclude-unlisted-classes>true</exclude-unlisted-classes>
<properties>
<property name="hibernate.dialect" value="org.hibernate.dialect.HSQLDialect" />
Expand Down
6 changes: 3 additions & 3 deletions src/main/antora/resources/antora-resources/antora.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down