From 4d7f75e99ca491b2911e930e2abec48cd0f035f5 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Tue, 27 Jan 2026 16:09:57 +0100 Subject: [PATCH] Refine input property handling for DTO projections. We now retain the input property ordering. Additionally, when providing input properties, we no longer limit properties to top-level properties but allow selection of nested properties. Closes #4174 See: #3908 --- .../support/FluentQuerySupport.java | 3 +- .../support/SimpleJpaRepository.java | 25 ++++++++----- ...lipseLinkNamespaceUserRepositoryTests.java | 5 +++ .../jpa/repository/UserRepositoryTests.java | 35 +++++++++++++------ 4 files changed, 48 insertions(+), 20 deletions(-) diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FluentQuerySupport.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FluentQuerySupport.java index 9d04b43df..ac19cc82f 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FluentQuerySupport.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FluentQuerySupport.java @@ -18,6 +18,7 @@ package org.springframework.data.jpa.repository.support; import java.util.Collection; import java.util.Collections; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.Set; import java.util.function.Function; @@ -60,7 +61,7 @@ abstract class FluentQuerySupport { this.limit = limit; if (properties != null) { - this.properties = new HashSet<>(properties); + this.properties = new LinkedHashSet<>(properties); } else { this.properties = Collections.emptySet(); } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java index 78925bf84..40c9337e9 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java @@ -788,13 +788,14 @@ public class SimpleJpaRepository implements JpaRepositoryImplementation TypedQuery getQuery(ReturnedType returnedType, @Nullable Specification spec, - Class domainClass, Sort sort, Collection inputProperties, @Nullable ScrollPosition scrollPosition) { + private TypedQuery getQuery(ReturnedType returnedType, @Nullable Specification spec, Class domainClass, + Sort sort, Collection inputProperties, @Nullable ScrollPosition scrollPosition) { CriteriaBuilder builder = entityManager.getCriteriaBuilder(); CriteriaQuery query; boolean interfaceProjection = returnedType.isInterfaceProjection(); + boolean inputPropertiesPresent = !inputProperties.isEmpty(); if (returnedType.needsCustomConstruction()) { query = (CriteriaQuery) (interfaceProjection ? builder.createTupleQuery() @@ -822,15 +823,21 @@ public class SimpleJpaRepository implements JpaRepositoryImplementation topLevelProperties = new HashSet<>(); for (String property : requiredSelection) { - int separator = property.indexOf('.'); - String topLevelProperty = separator == -1 ? property : property.substring(0, separator); + if (inputPropertiesPresent && !interfaceProjection) { + PropertyPath path = PropertyPath.from(property, returnedType.getDomainType()); + selections.add(QueryUtils.toExpressionRecursively(root, path, true)); + } else { - if (!topLevelProperties.add(topLevelProperty)) { - continue; - } + int separator = property.indexOf('.'); + String topLevelProperty = separator == -1 ? property : property.substring(0, separator); + + if (!topLevelProperties.add(topLevelProperty)) { + continue; + } - PropertyPath path = PropertyPath.from(topLevelProperty, returnedType.getDomainType()); - selections.add(QueryUtils.toExpressionRecursively(root, path, true).alias(topLevelProperty)); + PropertyPath path = PropertyPath.from(topLevelProperty, returnedType.getDomainType()); + selections.add(QueryUtils.toExpressionRecursively(root, path, true).alias(topLevelProperty)); + } } Class typeToRead = returnedType.getReturnedType(); diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkNamespaceUserRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkNamespaceUserRepositoryTests.java index 859a9aad7..64c5da4b2 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkNamespaceUserRepositoryTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkNamespaceUserRepositoryTests.java @@ -39,6 +39,11 @@ import org.springframework.test.context.ContextConfiguration; @Disabled("hsqldb seems to hang on this test class without leaving a surefire report") class EclipseLinkNamespaceUserRepositoryTests extends NamespaceUserRepositoryTests { + @Disabled("EclipseLink does not support records, additionally, it does not support constructor creation using nested (join) properties") + @Override + @Test + public void findByFluentSpecificationWithDtoProjectionJoins() {} + /** * Ignored until https://bugs.eclipse.org/bugs/show_bug.cgi?id=422450 is resolved. */ 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 b345d748d..15ace1855 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 @@ -2846,21 +2846,36 @@ class UserRepositoryTests { flushTestUsers(); - User prototype = new User(); - prototype.setFirstname("v"); + record MyProjection(String name, int age, boolean active) { - record MyProjection(String name) { + } + List users = repository.findBy(userHasFirstnameLike("Oliver"), // + q -> q.project("firstname", "age", "active").as(MyProjection.class).all()); + + assertThat(users).hasSize(1).extracting(MyProjection::name).contains(firstUser.getFirstname()); + } + + @Test // GH-4172 + void findByFluentSpecificationWithDtoProjectionJoins() { + + flushTestUsers(); + firstUser.setManager(secondUser); + em.persist(firstUser); + em.flush(); + + record MyProjection(String name, int age, boolean active, String managerName, int managerAge) { } - List users = repository.findBy( - of(prototype, - matching().withIgnorePaths("age", "createdAt", "active").withMatcher("firstname", - GenericPropertyMatcher::contains)), // - q -> q.project("firstname").as(MyProjection.class).all()); + List result = repository.findBy(userHasFirstnameLike("Oliver"), // + q -> q.project("firstname", "age", "active", "manager.firstname", "manager.age").as(MyProjection.class).all()); - assertThat(users).extracting(MyProjection::name).containsExactlyInAnyOrder(firstUser.getFirstname(), - thirdUser.getFirstname(), fourthUser.getFirstname()); + assertThat(result).hasSize(1); + + MyProjection projection = result.get(0); + assertThat(projection.name()).isEqualTo(firstUser.getFirstname()); + assertThat(projection.managerName()).isEqualTo(secondUser.getFirstname()); + assertThat(projection.managerAge()).isEqualTo(secondUser.getAge()); } @Test // GH-2274