diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java index 91624e263..851a3918e 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java @@ -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, Boolean> knownProjections = new ConcurrentHashMap<>(); private final Lazy 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 getInputProperties() { + return delegate.getInputProperties(); + } + } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Address.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Address.java index e5db7bfdd..ccd97f7b7 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Address.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Address.java @@ -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); + } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Role.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Role.java index 101a784ee..bdde7ce8f 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Role.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Role.java @@ -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); + } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java index 4e2a54565..7eb4e4cc6 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java @@ -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() { diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java index ab620ee48..9ebddf394 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java @@ -455,7 +455,6 @@ class UserRepositoryTests { @Test void testUsesQueryAnnotation() { - assertThat(repository.findByAnnotatedQuery("gierke@synyx.de")).isNull(); } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java index c4ebaf9f4..88efe091e 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java @@ -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, JpaSpecifi @Query("select u from User u") List findRecordProjection(); + @Query("select u.firstname, LOWER(u.lastname) from User u") + List findRecordProjectionWithFunctions(); + @Query("select u from User u") List findRecordProjection(Class projectionType); @Query("select u.firstname, u.lastname from User u") List findMultiselectRecordProjection(); + /** + * Retrieves a user age by email. + */ + @Query("select u.age from User u where u.emailAddress = ?1") + Optional findAgeByAnnotatedQuery(String emailAddress); + + /** + * Retrieves a user address by email. + */ + @Query("select u.address from User u where u.emailAddress = ?1") + Optional
findAddressByAnnotatedQuery(String emailAddress); + + /** + * Retrieves a user roles by email. + */ + @Query("select u.roles from User u where u.emailAddress = ?1") + Set 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 dtoProjectionEntityAndAggregatedValue();