Skip to content

Commit ea9e6e1

Browse files
committed
Refine DTO projection rewriting.
We now consider dropping aliases (count(foo) as foo), support subselects and capture individual select items to avoid contextual information loss. Also, added a series of tests to cover edgecases. See #3895
1 parent 205912c commit ea9e6e1

22 files changed

+606
-353
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import jakarta.persistence.TypedQuery;
2525

2626
import java.lang.reflect.Constructor;
27+
import java.util.AbstractMap;
2728
import java.util.ArrayList;
2829
import java.util.List;
2930
import java.util.function.UnaryOperator;

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

Lines changed: 1 addition & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,6 @@
2929
import org.springframework.data.domain.Sort;
3030
import org.springframework.data.expression.ValueEvaluationContextProvider;
3131
import org.springframework.data.jpa.repository.QueryRewriter;
32-
import org.springframework.data.mapping.PropertyPath;
33-
import org.springframework.data.mapping.PropertyReferenceException;
3432
import org.springframework.data.repository.query.ResultProcessor;
3533
import org.springframework.data.repository.query.ReturnedType;
3634
import org.springframework.data.repository.query.ValueExpressionDelegate;
@@ -178,56 +176,7 @@ ReturnedType getReturnedType(ResultProcessor processor) {
178176
return new NonProjectingReturnedType(returnedType);
179177
}
180178

181-
if (query.isDefaultProjection()) {
182-
return returnedType;
183-
}
184-
String projectionToUse = query.<@Nullable String> doWithEnhancer(queryEnhancer -> {
185-
186-
String alias = queryEnhancer.detectAlias();
187-
String projection = queryEnhancer.getProjection();
188-
189-
// we can handle single-column and no function projections here only
190-
if (StringUtils.hasText(projection) && (projection.indexOf(',') != -1 || projection.indexOf('(') != -1)) {
191-
return null;
192-
}
193-
194-
if (StringUtils.hasText(alias) && StringUtils.hasText(projection)) {
195-
alias = alias.trim();
196-
projection = projection.trim();
197-
if (projection.startsWith(alias + ".")) {
198-
projection = projection.substring(alias.length() + 1);
199-
}
200-
}
201-
202-
int space = projection.indexOf(' ');
203-
204-
if (space != -1) {
205-
projection = projection.substring(0, space);
206-
}
207-
208-
return projection;
209-
});
210-
211-
if (StringUtils.hasText(projectionToUse)) {
212-
213-
Class<?> propertyType;
214-
215-
try {
216-
PropertyPath from = PropertyPath.from(projectionToUse, getQueryMethod().getEntityInformation().getJavaType());
217-
propertyType = from.getLeafType();
218-
} catch (PropertyReferenceException ignored) {
219-
propertyType = null;
220-
}
221-
222-
if (propertyType == null
223-
|| (returnedJavaType.isAssignableFrom(propertyType) || propertyType.isAssignableFrom(returnedJavaType))) {
224-
knownProjections.put(returnedJavaType, false);
225-
return new NonProjectingReturnedType(returnedType);
226-
} else {
227-
knownProjections.put(returnedJavaType, true);
228-
}
229-
}
230-
179+
knownProjections.put(returnedJavaType, true);
231180
return returnedType;
232181
}
233182

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

Lines changed: 80 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717

1818
import static org.springframework.data.jpa.repository.query.QueryTokens.*;
1919

20+
import java.util.ArrayList;
21+
import java.util.Iterator;
22+
import java.util.List;
23+
import java.util.function.Function;
24+
2025
import org.springframework.data.repository.query.ReturnedType;
2126

2227
/**
@@ -25,7 +30,8 @@
2530
* Query rewriting from a plain property/object selection towards constructor expression only works if either:
2631
* <ul>
2732
* <li>The query selects its primary alias ({@code SELECT p FROM Person p})</li>
28-
* <li>The query specifies a property list ({@code SELECT p.foo, p.bar FROM Person p})</li>
33+
* <li>The query specifies a property list ({@code SELECT p.foo, p.bar FROM Person p},
34+
* {@code SELECT COUNT(p.foo), p.bar AS bar FROM Person p})</li>
2935
* </ul>
3036
*
3137
* @author Mark Paluch
@@ -34,42 +40,94 @@
3440
class DtoProjectionTransformerDelegate {
3541

3642
private final ReturnedType returnedType;
43+
private final boolean applyRewriting;
44+
private final List<QueryTokenStream> selectItems = new ArrayList<>();
3745

3846
public DtoProjectionTransformerDelegate(ReturnedType returnedType) {
3947
this.returnedType = returnedType;
48+
this.applyRewriting = returnedType.isProjecting() && !returnedType.getReturnedType().isInterface()
49+
&& returnedType.needsCustomConstruction();
50+
}
51+
52+
public boolean applyRewriting() {
53+
return applyRewriting;
54+
}
55+
56+
public boolean canRewrite() {
57+
return applyRewriting() && !selectItems.isEmpty();
58+
}
59+
60+
public void appendSelectItem(QueryTokenStream selectItem) {
61+
62+
if (applyRewriting()) {
63+
selectItems.add(new DetachedStream(selectItem));
64+
}
4065
}
4166

42-
public QueryTokenStream transformSelectionList(QueryTokenStream selectionList) {
67+
public QueryTokenStream getRewrittenSelectionList() {
68+
69+
if (canRewrite()) {
70+
71+
QueryRenderer.QueryRendererBuilder builder = QueryRenderer.builder();
72+
builder.append(QueryTokens.TOKEN_NEW);
73+
builder.append(QueryTokens.token(returnedType.getReturnedType().getName()));
74+
builder.append(QueryTokens.TOKEN_OPEN_PAREN);
75+
76+
if (selectItems.size() == 1 && selectItems.get(0).size() == 1) {
4377

44-
if (!returnedType.isProjecting() || returnedType.getReturnedType().isInterface()
45-
|| !returnedType.needsCustomConstruction() || selectionList.stream().anyMatch(it -> it.equals(TOKEN_NEW))) {
46-
return selectionList;
78+
builder.appendInline(QueryTokenStream.concat(returnedType.getInputProperties(), property -> {
79+
80+
QueryRenderer.QueryRendererBuilder prop = QueryRenderer.builder();
81+
prop.appendInline(selectItems.get(0));
82+
prop.append(QueryTokens.TOKEN_DOT);
83+
prop.append(QueryTokens.token(property));
84+
85+
return prop.build();
86+
}, QueryTokens.TOKEN_COMMA));
87+
} else {
88+
builder.append(QueryTokenStream.concat(selectItems, Function.identity(), TOKEN_COMMA));
89+
}
90+
91+
builder.append(TOKEN_CLOSE_PAREN);
92+
93+
return builder.build();
4794
}
4895

49-
QueryRenderer.QueryRendererBuilder builder = QueryRenderer.builder();
50-
builder.append(QueryTokens.TOKEN_NEW);
51-
builder.append(QueryTokens.token(returnedType.getReturnedType().getName()));
52-
builder.append(QueryTokens.TOKEN_OPEN_PAREN);
96+
return QueryTokenStream.empty();
97+
}
98+
99+
private static class DetachedStream extends QueryRenderer {
53100

54-
// assume the selection points to the document
55-
if (selectionList.size() == 1) {
101+
private final QueryTokenStream delegate;
56102

57-
builder.appendInline(QueryTokenStream.concat(returnedType.getInputProperties(), property -> {
103+
private DetachedStream(QueryTokenStream delegate) {
104+
this.delegate = delegate;
105+
}
58106

59-
QueryRenderer.QueryRendererBuilder prop = QueryRenderer.builder();
60-
prop.append(QueryTokens.token(selectionList.getRequiredFirst().value()));
61-
prop.append(QueryTokens.TOKEN_DOT);
62-
prop.append(QueryTokens.token(property));
107+
@Override
108+
public boolean isExpression() {
109+
return delegate.isExpression();
110+
}
63111

64-
return prop.build();
65-
}, QueryTokens.TOKEN_COMMA));
112+
@Override
113+
public int size() {
114+
return delegate.size();
115+
}
66116

67-
} else {
68-
builder.appendInline(selectionList);
117+
@Override
118+
public boolean isEmpty() {
119+
return delegate.isEmpty();
69120
}
70121

71-
builder.append(QueryTokens.TOKEN_CLOSE_PAREN);
122+
@Override
123+
public Iterator<QueryToken> iterator() {
124+
return delegate.iterator();
125+
}
72126

73-
return builder.build();
127+
@Override
128+
public String render() {
129+
return delegate instanceof QueryRenderer ? ((QueryRenderer) delegate).render() : delegate.toString();
130+
}
74131
}
132+
75133
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717

1818
import static org.springframework.data.jpa.repository.query.QueryTokens.*;
1919

20-
import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder;
21-
2220
import org.jspecify.annotations.Nullable;
21+
22+
import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder;
2323
import org.springframework.data.jpa.repository.query.QueryTransformers.CountSelectionTokenStream;
2424

2525
/**

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@
2121
import java.util.Collections;
2222
import java.util.List;
2323

24-
import org.springframework.data.jpa.repository.query.EqlParser.Range_variable_declarationContext;
25-
2624
import org.jspecify.annotations.Nullable;
2725

26+
import org.springframework.data.jpa.repository.query.EqlParser.Range_variable_declarationContext;
27+
2828
/**
2929
* {@link ParsedQueryIntrospector} for EQL queries.
3030
*

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,15 @@ public QueryTokenStream visitDelete_clause(EqlParser.Delete_clauseContext ctx) {
612612
@Override
613613
public QueryTokenStream visitSelect_clause(EqlParser.Select_clauseContext ctx) {
614614

615+
QueryRendererBuilder builder = prepareSelectClause(ctx);
616+
617+
builder.appendExpression(QueryTokenStream.concat(ctx.select_item(), this::visit, TOKEN_COMMA));
618+
619+
return builder;
620+
}
621+
622+
QueryRendererBuilder prepareSelectClause(EqlParser.Select_clauseContext ctx) {
623+
615624
QueryRendererBuilder builder = QueryRenderer.builder();
616625

617626
builder.append(QueryTokens.expression(ctx.SELECT()));
@@ -620,8 +629,6 @@ public QueryTokenStream visitSelect_clause(EqlParser.Select_clauseContext ctx) {
620629
builder.append(QueryTokens.expression(ctx.DISTINCT()));
621630
}
622631

623-
builder.appendExpression(QueryTokenStream.concat(ctx.select_item(), this::visit, TOKEN_COMMA));
624-
625632
return builder;
626633
}
627634

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

Lines changed: 44 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@
1919

2020
import java.util.List;
2121

22-
import org.springframework.data.domain.Sort;
23-
2422
import org.jspecify.annotations.Nullable;
23+
24+
import org.springframework.data.domain.Sort;
2525
import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder;
2626
import org.springframework.data.repository.query.ReturnedType;
2727
import org.springframework.util.Assert;
@@ -89,17 +89,53 @@ public QueryTokenStream visitSelect_clause(EqlParser.Select_clauseContext ctx) {
8989
return super.visitSelect_clause(ctx);
9090
}
9191

92-
QueryRendererBuilder builder = QueryRenderer.builder();
92+
QueryRendererBuilder builder = prepareSelectClause(ctx);
93+
94+
QueryTokenStream selectItems = QueryTokenStream.concat(ctx.select_item(), this::visit, TOKEN_COMMA);
95+
96+
if (dtoDelegate != null && dtoDelegate.canRewrite()) {
97+
builder.append(dtoDelegate.getRewrittenSelectionList());
98+
} else {
99+
builder.append(selectItems);
100+
}
101+
102+
return builder;
103+
}
104+
105+
@Override
106+
public QueryTokenStream visitSelect_item(EqlParser.Select_itemContext ctx) {
93107

94-
builder.append(QueryTokens.expression(ctx.SELECT()));
108+
QueryTokenStream tokens = super.visitSelect_item(ctx);
95109

96-
if (ctx.DISTINCT() != null) {
97-
builder.append(QueryTokens.expression(ctx.DISTINCT()));
110+
if (ctx.result_variable() != null && !tokens.isEmpty()) {
111+
transformerSupport.registerAlias(ctx.result_variable().getText());
98112
}
99113

100-
QueryTokenStream tokenStream = QueryTokenStream.concat(ctx.select_item(), this::visit, TOKEN_COMMA);
114+
return tokens;
115+
}
116+
117+
@Override
118+
public QueryTokenStream visitSelect_expression(EqlParser.Select_expressionContext ctx) {
119+
120+
QueryTokenStream selectItem = super.visitSelect_expression(ctx);
121+
122+
if (dtoDelegate != null && dtoDelegate.applyRewriting() && ctx.constructor_expression() == null) {
123+
dtoDelegate.appendSelectItem(selectItem);
124+
}
101125

102-
return builder.append(dtoDelegate.transformSelectionList(tokenStream));
126+
return selectItem;
127+
}
128+
129+
@Override
130+
public QueryTokenStream visitJoin(EqlParser.JoinContext ctx) {
131+
132+
QueryTokenStream tokens = super.visitJoin(ctx);
133+
134+
if (ctx.identification_variable() != null) {
135+
transformerSupport.registerAlias(ctx.identification_variable().getText());
136+
}
137+
138+
return tokens;
103139
}
104140

105141
private void doVisitOrderBy(QueryRendererBuilder builder, EqlParser.Select_statementContext ctx) {
@@ -129,28 +165,4 @@ private void doVisitOrderBy(QueryRendererBuilder builder, EqlParser.Select_state
129165
}
130166
}
131167

132-
@Override
133-
public QueryTokenStream visitSelect_item(EqlParser.Select_itemContext ctx) {
134-
135-
QueryTokenStream tokens = super.visitSelect_item(ctx);
136-
137-
if (ctx.result_variable() != null && !tokens.isEmpty()) {
138-
transformerSupport.registerAlias(tokens.getRequiredLast());
139-
}
140-
141-
return tokens;
142-
}
143-
144-
@Override
145-
public QueryTokenStream visitJoin(EqlParser.JoinContext ctx) {
146-
147-
QueryTokenStream tokens = super.visitJoin(ctx);
148-
149-
if (!tokens.isEmpty()) {
150-
transformerSupport.registerAlias(tokens.getRequiredLast());
151-
}
152-
153-
return tokens;
154-
}
155-
156168
}

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ public QueryRendererBuilder visitFromQuery(HqlParser.FromQueryContext ctx) {
107107

108108
if (ctx.fromClause() != null) {
109109
builder.appendExpression(visit(ctx.fromClause()));
110-
if(primaryFromAlias == null) {
110+
if (primaryFromAlias == null) {
111111
builder.append(TOKEN_AS);
112112
builder.append(TOKEN_DOUBLE_UNDERSCORE);
113113
}
@@ -150,7 +150,6 @@ public QueryRendererBuilder visitJoin(HqlParser.JoinContext ctx) {
150150
return builder;
151151
}
152152

153-
154153
@Override
155154
public QueryTokenStream visitSelectClause(HqlParser.SelectClauseContext ctx) {
156155

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@
4444
import org.antlr.v4.runtime.tree.ParseTree;
4545
import org.antlr.v4.runtime.tree.TerminalNode;
4646
import org.hibernate.query.criteria.HibernateCriteriaBuilder;
47-
4847
import org.jspecify.annotations.Nullable;
48+
4949
import org.springframework.data.domain.Sort;
5050
import org.springframework.data.jpa.domain.JpaSort;
5151
import org.springframework.data.mapping.PropertyPath;

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -885,7 +885,7 @@ public QueryTokenStream visitSelection(HqlParser.SelectionContext ctx) {
885885
builder.appendExpression(visit(ctx.variable()));
886886
}
887887

888-
return builder;
888+
return builder.toInline();
889889
}
890890

891891
@Override

0 commit comments

Comments
 (0)