Avoid DTO Constructor Expression rewriting for selection of nested properties.
We back off from rewriting String-based queries to use DTO Constructor expressions if the query selects a property that is assignable to the return type. Closes #3862
This commit is contained in:
@@ -18,12 +18,17 @@ package org.springframework.data.jpa.repository.query;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.Query;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.expression.ValueEvaluationContextProvider;
|
||||
import org.springframework.data.jpa.repository.QueryRewriter;
|
||||
import org.springframework.data.mapping.PropertyPath;
|
||||
import org.springframework.data.mapping.PropertyReferenceException;
|
||||
import org.springframework.data.repository.query.ResultProcessor;
|
||||
import org.springframework.data.repository.query.ReturnedType;
|
||||
import org.springframework.data.repository.query.ValueExpressionDelegate;
|
||||
@@ -48,7 +53,8 @@ import org.springframework.util.StringUtils;
|
||||
*/
|
||||
abstract class AbstractStringBasedJpaQuery extends AbstractJpaQuery {
|
||||
|
||||
private final DeclaredQuery query;
|
||||
private final StringQuery query;
|
||||
private final Map<Class<?>, Boolean> knownProjections = new ConcurrentHashMap<>();
|
||||
private final Lazy<DeclaredQuery> countQuery;
|
||||
private final ValueExpressionDelegate valueExpressionDelegate;
|
||||
private final QueryParameterSetter.QueryMetadataCache metadataCache = new QueryParameterSetter.QueryMetadataCache();
|
||||
@@ -120,7 +126,7 @@ abstract class AbstractStringBasedJpaQuery extends AbstractJpaQuery {
|
||||
|
||||
Sort sort = accessor.getSort();
|
||||
ResultProcessor processor = getQueryMethod().getResultProcessor().withDynamicProjection(accessor);
|
||||
ReturnedType returnedType = processor.getReturnedType();
|
||||
ReturnedType returnedType = getReturnedType(processor);
|
||||
String sortedQueryString = getSortedQueryString(sort, returnedType);
|
||||
Query query = createJpaQuery(sortedQueryString, sort, accessor.getPageable(), returnedType);
|
||||
|
||||
@@ -131,6 +137,81 @@ abstract class AbstractStringBasedJpaQuery extends AbstractJpaQuery {
|
||||
return parameterBinder.get().bindAndPrepare(query, metadata, accessor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-process {@link ReturnedType} to determine if the query is projecting by checking the projection and property
|
||||
* assignability.
|
||||
*
|
||||
* @param processor
|
||||
* @return
|
||||
*/
|
||||
private ReturnedType getReturnedType(ResultProcessor processor) {
|
||||
|
||||
ReturnedType returnedType = processor.getReturnedType();
|
||||
Class<?> returnedJavaType = processor.getReturnedType().getReturnedType();
|
||||
|
||||
if (query.isDefaultProjection() || !returnedType.isProjecting() || returnedJavaType.isInterface()
|
||||
|| query.isNativeQuery()) {
|
||||
return returnedType;
|
||||
}
|
||||
|
||||
Boolean known = knownProjections.get(returnedJavaType);
|
||||
|
||||
if (known != null && known) {
|
||||
return returnedType;
|
||||
}
|
||||
|
||||
if ((known != null && !known) || returnedJavaType.isArray()) {
|
||||
if (known == null) {
|
||||
knownProjections.put(returnedJavaType, false);
|
||||
}
|
||||
return new NonProjectingReturnedType(returnedType);
|
||||
}
|
||||
|
||||
String alias = query.getAlias();
|
||||
String projection = query.getProjection();
|
||||
|
||||
// we can handle single-column and no function projections here only
|
||||
if (StringUtils.hasText(projection) && (projection.indexOf(',') != -1 || projection.indexOf('(') != -1)) {
|
||||
return returnedType;
|
||||
}
|
||||
|
||||
if (StringUtils.hasText(alias) && StringUtils.hasText(projection)) {
|
||||
alias = alias.trim();
|
||||
projection = projection.trim();
|
||||
if (projection.startsWith(alias + ".")) {
|
||||
projection = projection.substring(alias.length() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (StringUtils.hasText(projection)) {
|
||||
|
||||
int space = projection.indexOf(' ');
|
||||
|
||||
if (space != -1) {
|
||||
projection = projection.substring(0, space);
|
||||
}
|
||||
|
||||
Class<?> propertyType;
|
||||
|
||||
try {
|
||||
PropertyPath from = PropertyPath.from(projection, getQueryMethod().getEntityInformation().getJavaType());
|
||||
propertyType = from.getLeafType();
|
||||
} catch (PropertyReferenceException ignored) {
|
||||
propertyType = null;
|
||||
}
|
||||
|
||||
if (propertyType == null
|
||||
|| (returnedJavaType.isAssignableFrom(propertyType) || propertyType.isAssignableFrom(returnedJavaType))) {
|
||||
knownProjections.put(returnedJavaType, false);
|
||||
return new NonProjectingReturnedType(returnedType);
|
||||
} else {
|
||||
knownProjections.put(returnedJavaType, true);
|
||||
}
|
||||
}
|
||||
|
||||
return returnedType;
|
||||
}
|
||||
|
||||
String getSortedQueryString(Sort sort, ReturnedType returnedType) {
|
||||
return querySortRewriter.getSorted(query, sort, returnedType);
|
||||
}
|
||||
@@ -348,4 +429,46 @@ abstract class AbstractStringBasedJpaQuery extends AbstractJpaQuery {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-projecting {@link ReturnedType} wrapper that delegates to the original {@link ReturnedType} but always returns
|
||||
* {@code false} for {@link #isProjecting()}. This type is to indicate that this query is not projecting, even if the
|
||||
* original {@link ReturnedType} was because we e.g. select a nested property and do not want DTO constructor
|
||||
* expression rewriting to kick in.
|
||||
*/
|
||||
private static class NonProjectingReturnedType extends ReturnedType {
|
||||
|
||||
private final ReturnedType delegate;
|
||||
|
||||
NonProjectingReturnedType(ReturnedType delegate) {
|
||||
super(delegate.getDomainType());
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isProjecting() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<?> getReturnedType() {
|
||||
return delegate.getReturnedType();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean needsCustomConstruction() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public Class<?> getTypeToRead() {
|
||||
return delegate.getTypeToRead();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getInputProperties() {
|
||||
return delegate.getInputProperties();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ package org.springframework.data.jpa.domain.sample;
|
||||
|
||||
import jakarta.persistence.Embeddable;
|
||||
|
||||
import org.springframework.util.ObjectUtils;
|
||||
|
||||
/**
|
||||
* @author Thomas Darimont
|
||||
*/
|
||||
@@ -52,4 +54,26 @@ public class Address {
|
||||
public String getStreetNo() {
|
||||
return streetNo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (!(o instanceof Address address)) {
|
||||
return false;
|
||||
}
|
||||
if (!ObjectUtils.nullSafeEquals(country, address.country)) {
|
||||
return false;
|
||||
}
|
||||
if (!ObjectUtils.nullSafeEquals(city, address.city)) {
|
||||
return false;
|
||||
}
|
||||
if (!ObjectUtils.nullSafeEquals(streetName, address.streetName)) {
|
||||
return false;
|
||||
}
|
||||
return ObjectUtils.nullSafeEquals(streetNo, address.streetNo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return ObjectUtils.nullSafeHash(country, city, streetName, streetNo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.Id;
|
||||
|
||||
import org.springframework.util.ObjectUtils;
|
||||
|
||||
/**
|
||||
* Sample domain class representing roles. Mapped with XML.
|
||||
*
|
||||
@@ -55,4 +57,17 @@ public class Role {
|
||||
public boolean isNew() {
|
||||
return id == null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (!(o instanceof Role role)) {
|
||||
return false;
|
||||
}
|
||||
return ObjectUtils.nullSafeEquals(id, role.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return ObjectUtils.nullSafeHash(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,8 @@
|
||||
*/
|
||||
package org.springframework.data.jpa.repository;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
import static org.springframework.data.domain.Sort.Direction.ASC;
|
||||
import static org.springframework.data.domain.Sort.Direction.DESC;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.springframework.data.domain.Sort.Direction.*;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
|
||||
@@ -33,6 +31,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.dao.InvalidDataAccessApiUsageException;
|
||||
import org.springframework.data.domain.Limit;
|
||||
@@ -43,6 +42,7 @@ import org.springframework.data.domain.ScrollPosition;
|
||||
import org.springframework.data.domain.Slice;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.domain.Window;
|
||||
import org.springframework.data.jpa.domain.sample.Address;
|
||||
import org.springframework.data.jpa.domain.sample.Role;
|
||||
import org.springframework.data.jpa.domain.sample.User;
|
||||
import org.springframework.data.jpa.provider.PersistenceProvider;
|
||||
@@ -422,6 +422,11 @@ class UserRepositoryFinderTests {
|
||||
|
||||
assertThat(dtos).flatExtracting(UserRepository.UserExcerpt::firstname) //
|
||||
.contains("Dave", "Carter", "Oliver August");
|
||||
|
||||
dtos = userRepository.findRecordProjectionWithFunctions();
|
||||
|
||||
assertThat(dtos).flatExtracting(UserRepository.UserExcerpt::lastname) //
|
||||
.contains("matthews", "beauford");
|
||||
}
|
||||
|
||||
@Test // GH-3076
|
||||
@@ -442,6 +447,29 @@ class UserRepositoryFinderTests {
|
||||
.contains("Dave", "Carter", "Oliver August");
|
||||
}
|
||||
|
||||
@Test // GH-3862
|
||||
void shouldNotRewritePrimitiveSelectionToDtoProjection() {
|
||||
|
||||
oliver.setAge(28);
|
||||
em.persist(oliver);
|
||||
|
||||
assertThat(userRepository.findAgeByAnnotatedQuery(oliver.getEmailAddress())).contains(28);
|
||||
}
|
||||
|
||||
@Test // GH-3862
|
||||
void shouldNotRewritePropertySelectionToDtoProjection() {
|
||||
|
||||
Address address = new Address("DE", "Dresden", "some street", "12345");
|
||||
dave.setAddress(address);
|
||||
userRepository.save(dave);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(userRepository.findAddressByAnnotatedQuery(dave.getEmailAddress())).contains(address);
|
||||
assertThat(userRepository.findCityByAnnotatedQuery(dave.getEmailAddress())).contains("Dresden");
|
||||
assertThat(userRepository.findRolesByAnnotatedQuery(dave.getEmailAddress())).contains(singer);
|
||||
}
|
||||
|
||||
@Test // GH-3076
|
||||
void dtoProjectionWithEntityAndAggregatedValue() {
|
||||
|
||||
|
||||
@@ -455,7 +455,6 @@ class UserRepositoryTests {
|
||||
|
||||
@Test
|
||||
void testUsesQueryAnnotation() {
|
||||
|
||||
assertThat(repository.findByAnnotatedQuery("gierke@synyx.de")).isNull();
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import org.springframework.data.domain.ScrollPosition;
|
||||
import org.springframework.data.domain.Slice;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.domain.Window;
|
||||
import org.springframework.data.jpa.domain.sample.Address;
|
||||
import org.springframework.data.jpa.domain.sample.Role;
|
||||
import org.springframework.data.jpa.domain.sample.SpecialUser;
|
||||
import org.springframework.data.jpa.domain.sample.User;
|
||||
@@ -723,12 +724,39 @@ public interface UserRepository extends JpaRepository<User, Integer>, JpaSpecifi
|
||||
@Query("select u from User u")
|
||||
List<UserExcerpt> findRecordProjection();
|
||||
|
||||
@Query("select u.firstname, LOWER(u.lastname) from User u")
|
||||
List<UserExcerpt> findRecordProjectionWithFunctions();
|
||||
|
||||
@Query("select u from User u")
|
||||
<T> List<T> findRecordProjection(Class<T> projectionType);
|
||||
|
||||
@Query("select u.firstname, u.lastname from User u")
|
||||
List<UserExcerpt> findMultiselectRecordProjection();
|
||||
|
||||
/**
|
||||
* Retrieves a user age by email.
|
||||
*/
|
||||
@Query("select u.age from User u where u.emailAddress = ?1")
|
||||
Optional<Integer> findAgeByAnnotatedQuery(String emailAddress);
|
||||
|
||||
/**
|
||||
* Retrieves a user address by email.
|
||||
*/
|
||||
@Query("select u.address from User u where u.emailAddress = ?1")
|
||||
Optional<Address> findAddressByAnnotatedQuery(String emailAddress);
|
||||
|
||||
/**
|
||||
* Retrieves a user roles by email.
|
||||
*/
|
||||
@Query("select u.roles from User u where u.emailAddress = ?1")
|
||||
Set<Role> findRolesByAnnotatedQuery(String emailAddress);
|
||||
|
||||
/**
|
||||
* Retrieves a user address city by email.
|
||||
*/
|
||||
@Query("select u.address.city from User u where u.emailAddress = ?1")
|
||||
String findCityByAnnotatedQuery(String emailAddress);
|
||||
|
||||
@UserRoleCountProjectingQuery
|
||||
List<UserRoleCountDtoProjection> dtoProjectionEntityAndAggregatedValue();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user