Skip to content

Commit 2a4559c

Browse files
committed
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
1 parent 15b3241 commit 2a4559c

File tree

10 files changed

+262
-3
lines changed

10 files changed

+262
-3
lines changed

spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,26 @@
1818
import jakarta.persistence.EntityManager;
1919
import jakarta.persistence.Query;
2020

21+
import java.util.List;
22+
import java.util.Map;
2123
import java.util.Objects;
24+
import java.util.concurrent.ConcurrentHashMap;
2225

2326
import org.jspecify.annotations.Nullable;
2427

2528
import org.springframework.data.domain.Pageable;
2629
import org.springframework.data.domain.Sort;
2730
import org.springframework.data.expression.ValueEvaluationContextProvider;
2831
import org.springframework.data.jpa.repository.QueryRewriter;
32+
import org.springframework.data.mapping.PropertyPath;
33+
import org.springframework.data.mapping.PropertyReferenceException;
2934
import org.springframework.data.repository.query.ResultProcessor;
3035
import org.springframework.data.repository.query.ReturnedType;
3136
import org.springframework.data.repository.query.ValueExpressionDelegate;
3237
import org.springframework.data.util.Lazy;
3338
import org.springframework.util.Assert;
3439
import org.springframework.util.ConcurrentLruCache;
40+
import org.springframework.util.StringUtils;
3541

3642
/**
3743
* Base class for {@link String} based JPA queries.
@@ -49,6 +55,7 @@
4955
abstract class AbstractStringBasedJpaQuery extends AbstractJpaQuery {
5056

5157
private final EntityQuery query;
58+
private final Map<Class<?>, Boolean> knownProjections = new ConcurrentHashMap<>();
5259
private final Lazy<ParametrizedQuery> countQuery;
5360
private final ValueExpressionDelegate valueExpressionDelegate;
5461
private final QueryRewriter queryRewriter;
@@ -132,7 +139,7 @@ public Query doCreateQuery(JpaParametersParameterAccessor accessor) {
132139

133140
Sort sort = accessor.getSort();
134141
ResultProcessor processor = getQueryMethod().getResultProcessor().withDynamicProjection(accessor);
135-
ReturnedType returnedType = processor.getReturnedType();
142+
ReturnedType returnedType = getReturnedType(processor);
136143
QueryProvider sortedQuery = getSortedQuery(sort, returnedType);
137144
Query query = createJpaQuery(sortedQuery, sort, accessor.getPageable(), returnedType);
138145

@@ -141,6 +148,86 @@ public Query doCreateQuery(JpaParametersParameterAccessor accessor) {
141148
return parameterBinder.get().bindAndPrepare(query, accessor);
142149
}
143150

151+
/**
152+
* Post-process {@link ReturnedType} to determine if the query is projecting by checking the projection and property
153+
* assignability.
154+
*
155+
* @param processor
156+
* @return
157+
*/
158+
private ReturnedType getReturnedType(ResultProcessor processor) {
159+
160+
ReturnedType returnedType = processor.getReturnedType();
161+
Class<?> returnedJavaType = processor.getReturnedType().getReturnedType();
162+
163+
if (query.isDefaultProjection() || !returnedType.isProjecting() || returnedJavaType.isInterface()
164+
|| query.isNative()) {
165+
return returnedType;
166+
}
167+
168+
Boolean known = knownProjections.get(returnedJavaType);
169+
170+
if (known != null && known) {
171+
return returnedType;
172+
}
173+
174+
if ((known != null && !known) || returnedJavaType.isArray()) {
175+
if (known == null) {
176+
knownProjections.put(returnedJavaType, false);
177+
}
178+
return new NonProjectingReturnedType(returnedType);
179+
}
180+
181+
String projectionToUse = query.<@Nullable String> doWithEnhancer(queryEnhancer -> {
182+
183+
String alias = queryEnhancer.detectAlias();
184+
String projection = queryEnhancer.getProjection();
185+
186+
// we can handle single-column and no function projections here only
187+
if (StringUtils.hasText(projection) && (projection.indexOf(',') != -1 || projection.indexOf('(') != -1)) {
188+
return null;
189+
}
190+
191+
if (StringUtils.hasText(alias) && StringUtils.hasText(projection)) {
192+
alias = alias.trim();
193+
projection = projection.trim();
194+
if (projection.startsWith(alias + ".")) {
195+
projection = projection.substring(alias.length() + 1);
196+
}
197+
}
198+
199+
int space = projection.indexOf(' ');
200+
201+
if (space != -1) {
202+
projection = projection.substring(0, space);
203+
}
204+
205+
return projection;
206+
});
207+
208+
if (StringUtils.hasText(projectionToUse)) {
209+
210+
Class<?> propertyType;
211+
212+
try {
213+
PropertyPath from = PropertyPath.from(projectionToUse, getQueryMethod().getEntityInformation().getJavaType());
214+
propertyType = from.getLeafType();
215+
} catch (PropertyReferenceException ignored) {
216+
propertyType = null;
217+
}
218+
219+
if (propertyType == null
220+
|| (returnedJavaType.isAssignableFrom(propertyType) || propertyType.isAssignableFrom(returnedJavaType))) {
221+
knownProjections.put(returnedJavaType, false);
222+
return new NonProjectingReturnedType(returnedType);
223+
} else {
224+
knownProjections.put(returnedJavaType, true);
225+
}
226+
}
227+
228+
return returnedType;
229+
}
230+
144231
QueryProvider getSortedQuery(Sort sort, ReturnedType returnedType) {
145232
return querySortRewriter.getSorted(query, sort, returnedType);
146233
}
@@ -355,4 +442,46 @@ public int hashCode() {
355442
return result;
356443
}
357444
}
445+
446+
/**
447+
* Non-projecting {@link ReturnedType} wrapper that delegates to the original {@link ReturnedType} but always returns
448+
* {@code false} for {@link #isProjecting()}. This type is to indicate that this query is not projecting, even if the
449+
* original {@link ReturnedType} was because we e.g. select a nested property and do not want DTO constructor
450+
* expression rewriting to kick in.
451+
*/
452+
private static class NonProjectingReturnedType extends ReturnedType {
453+
454+
private final ReturnedType delegate;
455+
456+
NonProjectingReturnedType(ReturnedType delegate) {
457+
super(delegate.getDomainType());
458+
this.delegate = delegate;
459+
}
460+
461+
@Override
462+
public boolean isProjecting() {
463+
return false;
464+
}
465+
466+
@Override
467+
public Class<?> getReturnedType() {
468+
return delegate.getReturnedType();
469+
}
470+
471+
@Override
472+
public boolean needsCustomConstruction() {
473+
return false;
474+
}
475+
476+
@Override
477+
@Nullable
478+
public Class<?> getTypeToRead() {
479+
return delegate.getTypeToRead();
480+
}
481+
482+
@Override
483+
public List<String> getInputProperties() {
484+
return delegate.getInputProperties();
485+
}
486+
}
358487
}

spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultEntityQuery.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package org.springframework.data.jpa.repository.query;
1717

1818
import java.util.List;
19+
import java.util.function.Function;
1920

2021
import org.jspecify.annotations.Nullable;
2122

@@ -46,6 +47,11 @@ class DefaultEntityQuery implements EntityQuery, DeclaredQuery {
4647
this.queryEnhancer = queryEnhancerFactory.create(query);
4748
}
4849

50+
@Override
51+
public <T> T doWithEnhancer(Function<QueryEnhancer, T> function) {
52+
return function.apply(queryEnhancer);
53+
}
54+
4955
@Override
5056
public boolean isNative() {
5157
return query.isNative();

spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EmptyIntrospectedQuery.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import java.util.Collections;
1919
import java.util.List;
20+
import java.util.function.Function;
2021

2122
import org.jspecify.annotations.Nullable;
2223

@@ -33,6 +34,8 @@ enum EmptyIntrospectedQuery implements EntityQuery {
3334

3435
EmptyIntrospectedQuery() {}
3536

37+
38+
3639
@Override
3740
public boolean hasParameterBindings() {
3841
return false;
@@ -57,11 +60,21 @@ public List<ParameterBinding> getParameterBindings() {
5760
return null;
5861
}
5962

63+
@Override
64+
public <T> T doWithEnhancer(Function<QueryEnhancer, T> function) {
65+
return null;
66+
}
67+
6068
@Override
6169
public boolean hasConstructorExpression() {
6270
return false;
6371
}
6472

73+
@Override
74+
public boolean isNative() {
75+
return false;
76+
}
77+
6578
@Override
6679
public boolean isDefaultProjection() {
6780
return false;

spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EntityQuery.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
*/
1616
package org.springframework.data.jpa.repository.query;
1717

18+
import java.util.function.Function;
19+
1820
import org.jspecify.annotations.Nullable;
1921

2022
/**
@@ -45,13 +47,27 @@ static EntityQuery create(DeclaredQuery query, QueryEnhancerSelector selector) {
4547
return new DefaultEntityQuery(preparsed, enhancerFactory);
4648
}
4749

50+
/**
51+
* Apply a {@link Function} to the query enhancer used by this query.
52+
*
53+
* @param function the callback function.
54+
* @return
55+
* @param <T>
56+
*/
57+
<T extends @Nullable Object> T doWithEnhancer(Function<QueryEnhancer, T> function);
58+
4859
/**
4960
* Returns whether the query is using a constructor expression.
5061
*
5162
* @since 1.10
5263
*/
5364
boolean hasConstructorExpression();
5465

66+
/**
67+
* @return whether the underlying query has at least one named parameter.
68+
*/
69+
boolean isNative();
70+
5571
/**
5672
* Returns whether the query uses the default projection, i.e. returns the main alias defined for the query.
5773
*/

spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParametrizedQuery.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
* @see EntityQuery#create(DeclaredQuery, QueryEnhancerSelector)
3131
* @see TemplatedQuery#create(String, JpaQueryMethod, JpaQueryConfiguration)
3232
*/
33-
interface ParametrizedQuery extends QueryProvider {
33+
public interface ParametrizedQuery extends QueryProvider {
3434

3535
/**
3636
* @return whether the underlying query has at least one parameter.

spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Address.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717

1818
import jakarta.persistence.Embeddable;
1919

20+
import org.springframework.util.ObjectUtils;
21+
2022
/**
2123
* @author Thomas Darimont
2224
*/
@@ -52,4 +54,26 @@ public String getStreetName() {
5254
public String getStreetNo() {
5355
return streetNo;
5456
}
57+
58+
@Override
59+
public boolean equals(Object o) {
60+
if (!(o instanceof Address address)) {
61+
return false;
62+
}
63+
if (!ObjectUtils.nullSafeEquals(country, address.country)) {
64+
return false;
65+
}
66+
if (!ObjectUtils.nullSafeEquals(city, address.city)) {
67+
return false;
68+
}
69+
if (!ObjectUtils.nullSafeEquals(streetName, address.streetName)) {
70+
return false;
71+
}
72+
return ObjectUtils.nullSafeEquals(streetNo, address.streetNo);
73+
}
74+
75+
@Override
76+
public int hashCode() {
77+
return ObjectUtils.nullSafeHash(country, city, streetName, streetNo);
78+
}
5579
}

spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Role.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
import jakarta.persistence.GeneratedValue;
2020
import jakarta.persistence.Id;
2121

22+
import org.springframework.util.ObjectUtils;
23+
2224
/**
2325
* Sample domain class representing roles. Mapped with XML.
2426
*
@@ -55,4 +57,17 @@ public String toString() {
5557
public boolean isNew() {
5658
return id == null;
5759
}
60+
61+
@Override
62+
public boolean equals(Object o) {
63+
if (!(o instanceof Role role)) {
64+
return false;
65+
}
66+
return ObjectUtils.nullSafeEquals(id, role.id);
67+
}
68+
69+
@Override
70+
public int hashCode() {
71+
return ObjectUtils.nullSafeHash(id);
72+
}
5873
}

spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
import org.springframework.data.domain.Slice;
4343
import org.springframework.data.domain.Sort;
4444
import org.springframework.data.domain.Window;
45+
import org.springframework.data.jpa.domain.sample.Address;
4546
import org.springframework.data.jpa.domain.sample.Role;
4647
import org.springframework.data.jpa.domain.sample.User;
4748
import org.springframework.data.jpa.provider.PersistenceProvider;
@@ -421,6 +422,11 @@ void dtoProjectionShouldApplyConstructorExpressionRewriting() {
421422

422423
assertThat(dtos).flatExtracting(UserRepository.UserExcerpt::firstname) //
423424
.contains("Dave", "Carter", "Oliver August");
425+
426+
dtos = userRepository.findRecordProjectionWithFunctions();
427+
428+
assertThat(dtos).flatExtracting(UserRepository.UserExcerpt::lastname) //
429+
.contains("matthews", "beauford");
424430
}
425431

426432
@Test // GH-3076
@@ -441,6 +447,29 @@ void dynamicDtoProjection() {
441447
.contains("Dave", "Carter", "Oliver August");
442448
}
443449

450+
@Test // GH-3862
451+
void shouldNotRewritePrimitiveSelectionToDtoProjection() {
452+
453+
oliver.setAge(28);
454+
em.persist(oliver);
455+
456+
assertThat(userRepository.findAgeByAnnotatedQuery(oliver.getEmailAddress())).contains(28);
457+
}
458+
459+
@Test // GH-3862
460+
void shouldNotRewritePropertySelectionToDtoProjection() {
461+
462+
Address address = new Address("DE", "Dresden", "some street", "12345");
463+
dave.setAddress(address);
464+
userRepository.save(dave);
465+
em.flush();
466+
em.clear();
467+
468+
assertThat(userRepository.findAddressByAnnotatedQuery(dave.getEmailAddress())).contains(address);
469+
assertThat(userRepository.findCityByAnnotatedQuery(dave.getEmailAddress())).contains("Dresden");
470+
assertThat(userRepository.findRolesByAnnotatedQuery(dave.getEmailAddress())).contains(singer);
471+
}
472+
444473
@Test // GH-3076
445474
void dtoProjectionWithEntityAndAggregatedValue() {
446475

0 commit comments

Comments
 (0)