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:
Mark Paluch
2025-05-05 17:15:27 +02:00
parent 43062c4e95
commit d463f5fd45
6 changed files with 224 additions and 7 deletions

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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() {

View File

@@ -455,7 +455,6 @@ class UserRepositoryTests {
@Test
void testUsesQueryAnnotation() {
assertThat(repository.findByAnnotatedQuery("gierke@synyx.de")).isNull();
}

View File

@@ -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();