diff --git a/pom.xml b/pom.xml index 1847f4d2c4..de79e1d08f 100755 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-jpa-parent - 3.5.0-SNAPSHOT + 3.5.0-3076-SNAPSHOT pom Spring Data JPA Parent diff --git a/spring-data-envers/pom.xml b/spring-data-envers/pom.xml index 5dcbbb69fd..b38ec85271 100755 --- a/spring-data-envers/pom.xml +++ b/spring-data-envers/pom.xml @@ -5,12 +5,12 @@ org.springframework.data spring-data-envers - 3.5.0-SNAPSHOT + 3.5.0-3076-SNAPSHOT org.springframework.data spring-data-jpa-parent - 3.5.0-SNAPSHOT + 3.5.0-3076-SNAPSHOT ../pom.xml diff --git a/spring-data-jpa-distribution/pom.xml b/spring-data-jpa-distribution/pom.xml index 38a234cb71..4fb349b87d 100644 --- a/spring-data-jpa-distribution/pom.xml +++ b/spring-data-jpa-distribution/pom.xml @@ -14,7 +14,7 @@ org.springframework.data spring-data-jpa-parent - 3.5.0-SNAPSHOT + 3.5.0-3076-SNAPSHOT ../pom.xml diff --git a/spring-data-jpa/pom.xml b/spring-data-jpa/pom.xml index c6d9301c02..a33e4f9f2d 100644 --- a/spring-data-jpa/pom.xml +++ b/spring-data-jpa/pom.xml @@ -6,7 +6,7 @@ org.springframework.data spring-data-jpa - 3.5.0-SNAPSHOT + 3.5.0-3076-SNAPSHOT Spring Data JPA Spring Data module for JPA repositories. @@ -15,7 +15,7 @@ org.springframework.data spring-data-jpa-parent - 3.5.0-SNAPSHOT + 3.5.0-3076-SNAPSHOT ../pom.xml diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java index bbde6d9414..772b1d7032 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java @@ -23,6 +23,8 @@ import jakarta.persistence.TupleElement; import jakarta.persistence.TypedQuery; +import java.lang.reflect.Constructor; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; @@ -32,6 +34,8 @@ import java.util.function.UnaryOperator; import java.util.stream.Collectors; +import org.springframework.beans.BeanUtils; +import org.springframework.core.MethodParameter; import org.springframework.core.convert.converter.Converter; import org.springframework.data.jpa.provider.PersistenceProvider; import org.springframework.data.jpa.repository.EntityGraph; @@ -44,6 +48,8 @@ import org.springframework.data.jpa.repository.query.JpaQueryExecution.StreamExecution; import org.springframework.data.jpa.repository.support.QueryHints; import org.springframework.data.jpa.util.JpaMetamodel; +import org.springframework.data.mapping.PreferredConstructor; +import org.springframework.data.mapping.model.PreferredConstructorDiscoverer; import org.springframework.data.repository.query.RepositoryQuery; import org.springframework.data.repository.query.ResultProcessor; import org.springframework.data.repository.query.ReturnedType; @@ -51,6 +57,7 @@ import org.springframework.jdbc.support.JdbcUtils; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; /** * Abstract base class to implement {@link RepositoryQuery}s. @@ -283,9 +290,10 @@ protected Class getTypeToRead(ReturnedType returnedType) { return null; } - return returnedType.isProjecting() && !getMetamodel().isJpaManaged(returnedType.getReturnedType()) // - ? Tuple.class // - : null; + return returnedType.isProjecting() && returnedType.getReturnedType().isInterface() + && !getMetamodel().isJpaManaged(returnedType.getReturnedType()) // + ? Tuple.class // + : null; } /** @@ -304,12 +312,16 @@ protected Class getTypeToRead(ReturnedType returnedType) { */ protected abstract Query doCreateCountQuery(JpaParametersParameterAccessor accessor); - static class TupleConverter implements Converter { + public static class TupleConverter implements Converter { private final ReturnedType type; private final UnaryOperator tupleWrapper; + private final boolean dtoProjection; + + private final @Nullable PreferredConstructor preferredConstructor; + /** * Creates a new {@link TupleConverter} for the given {@link ReturnedType}. * @@ -332,6 +344,14 @@ public TupleConverter(ReturnedType type, boolean nativeQuery) { this.type = type; this.tupleWrapper = nativeQuery ? FallbackTupleWrapper::new : UnaryOperator.identity(); + this.dtoProjection = type.isProjecting() && !type.getReturnedType().isInterface() + && !type.getInputProperties().isEmpty(); + + if (this.dtoProjection) { + this.preferredConstructor = PreferredConstructorDiscoverer.discover(type.getReturnedType()); + } else { + this.preferredConstructor = null; + } } @Override @@ -352,9 +372,101 @@ public Object convert(Object source) { } } + if (dtoProjection) { + + Object[] ctorArgs = new Object[elements.size()]; + for (int i = 0; i < ctorArgs.length; i++) { + ctorArgs[i] = tuple.get(i); + } + + List> argTypes = getArgumentTypes(ctorArgs); + + if (preferredConstructor != null && isConstructorCompatible(preferredConstructor.getConstructor(), argTypes)) { + return BeanUtils.instantiateClass(preferredConstructor.getConstructor(), ctorArgs); + } + + return BeanUtils.instantiateClass(getFirstMatchingConstructor(ctorArgs, argTypes), ctorArgs); + } + return new TupleBackedMap(tupleWrapper.apply(tuple)); } + private Constructor getFirstMatchingConstructor(Object[] ctorArgs, List> argTypes) { + + for (Constructor ctor : type.getReturnedType().getDeclaredConstructors()) { + + if (ctor.getParameterCount() != ctorArgs.length) { + continue; + } + + if (isConstructorCompatible(ctor, argTypes)) { + return ctor; + } + } + + throw new IllegalStateException(String.format( + "Cannot find compatible constructor for DTO projection '%s' accepting '%s'", type.getReturnedType().getName(), + argTypes.stream().map(Class::getName).collect(Collectors.joining(", ")))); + } + + private static List> getArgumentTypes(Object[] ctorArgs) { + List> argTypes = new ArrayList<>(ctorArgs.length); + + for (Object ctorArg : ctorArgs) { + argTypes.add(ctorArg == null ? Void.class : ctorArg.getClass()); + } + return argTypes; + } + + public static boolean isConstructorCompatible(Constructor constructor, List> argumentTypes) { + + if (constructor.getParameterCount() != argumentTypes.size()) { + return false; + } + + for (int i = 0; i < argumentTypes.size(); i++) { + + MethodParameter methodParameter = MethodParameter.forExecutable(constructor, i); + Class argumentType = argumentTypes.get(i); + + if (!areAssignmentCompatible(methodParameter.getParameterType(), argumentType)) { + return false; + } + } + return true; + } + + private static boolean areAssignmentCompatible(Class to, Class from) { + + if (from == Void.class && !to.isPrimitive()) { + // treat Void as the bottom type, the class of null + return true; + } + + if (to.isPrimitive()) { + + if (to == Short.TYPE) { + return from == Character.class || from == Byte.class; + } + + if (to == Integer.TYPE) { + return from == Short.class || from == Character.class || from == Byte.class; + } + + if (to == Long.TYPE) { + return from == Integer.class || from == Short.class || from == Character.class || from == Byte.class; + } + + if (to == Double.TYPE) { + return from == Float.class; + } + + return ClassUtils.isAssignable(to, from); + } + + return ClassUtils.isAssignable(to, from); + } + /** * A {@link Map} implementation which delegates all calls to a {@link Tuple}. Depending on the provided * {@link Tuple} implementation it might return the same value for various keys of which only one will appear in the 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 0d257fe5a2..a85af07219 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 @@ -69,8 +69,7 @@ abstract class AbstractStringBasedJpaQuery extends AbstractJpaQuery { * @param valueExpressionDelegate must not be {@literal null}. */ public AbstractStringBasedJpaQuery(JpaQueryMethod method, EntityManager em, String queryString, - @Nullable String countQueryString, QueryRewriter queryRewriter, - ValueExpressionDelegate valueExpressionDelegate) { + @Nullable String countQueryString, QueryRewriter queryRewriter, ValueExpressionDelegate valueExpressionDelegate) { super(method, em); @@ -101,10 +100,15 @@ public AbstractStringBasedJpaQuery(JpaQueryMethod method, EntityManager em, Stri this.queryRewriter = queryRewriter; JpaParameters parameters = method.getParameters(); - if (parameters.hasPageableParameter() || parameters.hasSortParameter()) { - this.querySortRewriter = new CachingQuerySortRewriter(); + + if (parameters.hasDynamicProjection()) { + this.querySortRewriter = SimpleQuerySortRewriter.INSTANCE; } else { - this.querySortRewriter = NoOpQuerySortRewriter.INSTANCE; + if (parameters.hasPageableParameter() || parameters.hasSortParameter()) { + this.querySortRewriter = new CachingQuerySortRewriter(); + } else { + this.querySortRewriter = new UnsortedCachingQuerySortRewriter(); + } } Assert.isTrue(method.isNativeQuery() || !query.usesJdbcStyleParameters(), @@ -115,21 +119,20 @@ public AbstractStringBasedJpaQuery(JpaQueryMethod method, EntityManager em, Stri public Query doCreateQuery(JpaParametersParameterAccessor accessor) { Sort sort = accessor.getSort(); - String sortedQueryString = getSortedQueryString(sort); - ResultProcessor processor = getQueryMethod().getResultProcessor().withDynamicProjection(accessor); - - Query query = createJpaQuery(sortedQueryString, sort, accessor.getPageable(), processor.getReturnedType()); + ReturnedType returnedType = processor.getReturnedType(); + String sortedQueryString = getSortedQueryString(sort, returnedType); + Query query = createJpaQuery(sortedQueryString, sort, accessor.getPageable(), returnedType); QueryParameterSetter.QueryMetadata metadata = metadataCache.getMetadata(sortedQueryString, query); - // it is ok to reuse the binding contained in the ParameterBinder although we create a new query String because the + // it is ok to reuse the binding contained in the ParameterBinder, although we create a new query String because the // parameters in the query do not change. return parameterBinder.get().bindAndPrepare(query, metadata, accessor); } - String getSortedQueryString(Sort sort) { - return querySortRewriter.getSorted(query, sort); + String getSortedQueryString(Sort sort, ReturnedType returnedType) { + return querySortRewriter.getSorted(query, sort, returnedType); } @Override @@ -211,30 +214,47 @@ protected String potentiallyRewriteQuery(String originalQuery, Sort sort, @Nulla String applySorting(CachableQuery cachableQuery) { - return QueryEnhancerFactory.forQuery(cachableQuery.getDeclaredQuery()).applySorting(cachableQuery.getSort(), - cachableQuery.getAlias()); + return QueryEnhancerFactory.forQuery(cachableQuery.getDeclaredQuery()) + .rewrite(new DefaultQueryRewriteInformation(cachableQuery.getSort(), cachableQuery.getReturnedType())); } /** * Query Sort Rewriter interface. */ interface QuerySortRewriter { - String getSorted(DeclaredQuery query, Sort sort); + String getSorted(DeclaredQuery query, Sort sort, ReturnedType returnedType); } /** * No-op query rewriter. */ - enum NoOpQuerySortRewriter implements QuerySortRewriter { + enum SimpleQuerySortRewriter implements QuerySortRewriter { + INSTANCE; - public String getSorted(DeclaredQuery query, Sort sort) { + public String getSorted(DeclaredQuery query, Sort sort, ReturnedType returnedType) { + + return QueryEnhancerFactory.forQuery(query).rewrite(new DefaultQueryRewriteInformation(sort, returnedType)); + } + } + + static class UnsortedCachingQuerySortRewriter implements QuerySortRewriter { + + private volatile String cachedQueryString; + + public String getSorted(DeclaredQuery query, Sort sort, ReturnedType returnedType) { if (sort.isSorted()) { throw new UnsupportedOperationException("NoOpQueryCache does not support sorting"); } - return query.getQueryString(); + String cachedQueryString = this.cachedQueryString; + if (cachedQueryString == null) { + this.cachedQueryString = cachedQueryString = QueryEnhancerFactory.forQuery(query) + .rewrite(new DefaultQueryRewriteInformation(sort, returnedType)); + } + + return cachedQueryString; } } @@ -246,14 +266,22 @@ class CachingQuerySortRewriter implements QuerySortRewriter { private final ConcurrentLruCache queryCache = new ConcurrentLruCache<>(16, AbstractStringBasedJpaQuery.this::applySorting); + private volatile String cachedQueryString; + @Override - public String getSorted(DeclaredQuery query, Sort sort) { + public String getSorted(DeclaredQuery query, Sort sort, ReturnedType returnedType) { if (sort.isUnsorted()) { - return query.getQueryString(); + + String cachedQueryString = this.cachedQueryString; + if (cachedQueryString == null) { + this.cachedQueryString = cachedQueryString = queryCache.get(new CachableQuery(query, sort, returnedType)); + } + + return cachedQueryString; } - return queryCache.get(new CachableQuery(query, sort)); + return queryCache.get(new CachableQuery(query, sort, returnedType)); } } @@ -269,12 +297,14 @@ static class CachableQuery { private final DeclaredQuery declaredQuery; private final String queryString; private final Sort sort; + private final ReturnedType returnedType; - CachableQuery(DeclaredQuery query, Sort sort) { + CachableQuery(DeclaredQuery query, Sort sort, ReturnedType returnedType) { this.declaredQuery = query; this.queryString = query.getQueryString(); this.sort = sort; + this.returnedType = returnedType; } DeclaredQuery getDeclaredQuery() { @@ -285,9 +315,8 @@ Sort getSort() { return sort; } - @Nullable - String getAlias() { - return declaredQuery.getAlias(); + public ReturnedType getReturnedType() { + return returnedType; } @Override diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultQueryEnhancer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultQueryEnhancer.java index d8c5bb4a50..8ec778fb70 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultQueryEnhancer.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultQueryEnhancer.java @@ -52,6 +52,11 @@ public String applySorting(Sort sort, @Nullable String alias) { return QueryUtils.applySorting(this.query.getQueryString(), sort, alias); } + @Override + public String rewrite(QueryRewriteInformation rewriteInformation) { + return QueryUtils.applySorting(this.query.getQueryString(), rewriteInformation.getSort(), alias); + } + @Override public String createCountQueryFor(@Nullable String countProjection) { return QueryUtils.createCountQueryFor(this.query.getQueryString(), countProjection, this.query.isNativeQuery()); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultQueryRewriteInformation.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultQueryRewriteInformation.java new file mode 100644 index 0000000000..ee17ca3f04 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultQueryRewriteInformation.java @@ -0,0 +1,37 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.query; + +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.query.ReturnedType; + +/** + * Default {@link org.springframework.data.jpa.repository.query.QueryEnhancer.QueryRewriteInformation} implementation. + * + * @author Mark Paluch + */ +record DefaultQueryRewriteInformation(Sort sort, + ReturnedType returnedType) implements QueryEnhancer.QueryRewriteInformation { + @Override + public Sort getSort() { + return sort(); + } + + @Override + public ReturnedType getReturnedType() { + return returnedType(); + } +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DtoProjectionTransformerDelegate.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DtoProjectionTransformerDelegate.java new file mode 100644 index 0000000000..c87a5d63de --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DtoProjectionTransformerDelegate.java @@ -0,0 +1,75 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.query; + +import static org.springframework.data.jpa.repository.query.QueryTokens.*; + +import org.springframework.data.repository.query.ReturnedType; + +/** + * HQL Query Transformer that rewrites the query using constructor expressions. + *

+ * Query rewriting from a plain property/object selection towards constructor expression only works if either: + *

    + *
  • The query selects its primary alias ({@code SELECT p FROM Person p})
  • + *
  • The query specifies a property list ({@code SELECT p.foo, p.bar FROM Person p})
  • + *
+ * + * @author Mark Paluch + * @since 3.5 + */ +class DtoProjectionTransformerDelegate { + + private final ReturnedType returnedType; + + public DtoProjectionTransformerDelegate(ReturnedType returnedType) { + this.returnedType = returnedType; + } + + public QueryTokenStream transformSelectionList(QueryTokenStream selectionList) { + + if (!returnedType.isProjecting() || returnedType.getReturnedType().isInterface() + || selectionList.stream().anyMatch(it -> it.equals(TOKEN_NEW))) { + return selectionList; + } + + QueryRenderer.QueryRendererBuilder builder = QueryRenderer.builder(); + builder.append(QueryTokens.TOKEN_NEW); + builder.append(QueryTokens.token(returnedType.getReturnedType().getName())); + builder.append(QueryTokens.TOKEN_OPEN_PAREN); + + // assume the selection points to the document + if (selectionList.size() == 1) { + + builder.appendInline(QueryTokenStream.concat(returnedType.getInputProperties(), property -> { + + QueryRenderer.QueryRendererBuilder prop = QueryRenderer.builder(); + prop.append(QueryTokens.token(selectionList.getFirst().value())); + prop.append(QueryTokens.TOKEN_DOT); + prop.append(QueryTokens.token(property)); + + return prop.build(); + }, QueryTokens.TOKEN_COMMA)); + + } else { + builder.appendInline(selectionList); + } + + builder.append(QueryTokens.TOKEN_CLOSE_PAREN); + + return builder.build(); + } +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlSortedQueryTransformer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlSortedQueryTransformer.java index ed14e9afdf..edd906f07f 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlSortedQueryTransformer.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlSortedQueryTransformer.java @@ -21,6 +21,7 @@ import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder; +import org.springframework.data.repository.query.ReturnedType; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; @@ -40,13 +41,15 @@ class EqlSortedQueryTransformer extends EqlQueryRenderer { private final JpaQueryTransformerSupport transformerSupport = new JpaQueryTransformerSupport(); private final Sort sort; private final @Nullable String primaryFromAlias; + private final @Nullable DtoProjectionTransformerDelegate dtoDelegate; - EqlSortedQueryTransformer(Sort sort, @Nullable String primaryFromAlias) { + EqlSortedQueryTransformer(Sort sort, @Nullable String primaryFromAlias, @Nullable ReturnedType returnedType) { Assert.notNull(sort, "Sort must not be null"); this.sort = sort; this.primaryFromAlias = primaryFromAlias; + this.dtoDelegate = returnedType == null ? null : new DtoProjectionTransformerDelegate(returnedType); } @Override @@ -80,6 +83,26 @@ public QueryRendererBuilder visitSelect_statement(EqlParser.Select_statementCont return builder; } + @Override + public QueryTokenStream visitSelect_clause(EqlParser.Select_clauseContext ctx) { + + if (dtoDelegate == null) { + return super.visitSelect_clause(ctx); + } + + QueryRendererBuilder builder = QueryRenderer.builder(); + + builder.append(QueryTokens.expression(ctx.SELECT())); + + if (ctx.DISTINCT() != null) { + builder.append(QueryTokens.expression(ctx.DISTINCT())); + } + + QueryTokenStream tokenStream = QueryTokenStream.concat(ctx.select_item(), this::visit, TOKEN_COMMA); + + return builder.append(dtoDelegate.transformSelectionList(tokenStream)); + } + private void doVisitOrderBy(QueryRendererBuilder builder, EqlParser.Select_statementContext ctx, Sort sort) { if (ctx.orderby_clause() != null) { diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlSortedQueryTransformer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlSortedQueryTransformer.java index b6b8853f93..9953b3e6c1 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlSortedQueryTransformer.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlSortedQueryTransformer.java @@ -21,6 +21,7 @@ import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder; +import org.springframework.data.repository.query.ReturnedType; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; @@ -38,6 +39,7 @@ class HqlSortedQueryTransformer extends HqlQueryRenderer { private final JpaQueryTransformerSupport transformerSupport = new JpaQueryTransformerSupport(); private final Sort sort; private final @Nullable String primaryFromAlias; + private final @Nullable DtoProjectionTransformerDelegate dtoDelegate; HqlSortedQueryTransformer(Sort sort, @Nullable String primaryFromAlias) { @@ -45,6 +47,16 @@ class HqlSortedQueryTransformer extends HqlQueryRenderer { this.sort = sort; this.primaryFromAlias = primaryFromAlias; + this.dtoDelegate = null; + } + + HqlSortedQueryTransformer(Sort sort, @Nullable String primaryFromAlias, @Nullable ReturnedType returnedType) { + + Assert.notNull(sort, "Sort must not be null"); + + this.sort = sort; + this.primaryFromAlias = primaryFromAlias; + this.dtoDelegate = returnedType == null ? null : new DtoProjectionTransformerDelegate(returnedType); } @Override @@ -81,6 +93,18 @@ public QueryRendererBuilder visitOrderedQuery(HqlParser.OrderedQueryContext ctx) return visitOrderedQuery(ctx, this.sort); } + @Override + public QueryTokenStream visitSelectionList(HqlParser.SelectionListContext ctx) { + + QueryTokenStream tokenStream = super.visitSelectionList(ctx); + + if (dtoDelegate != null && !isSubquery(ctx)) { + return dtoDelegate.transformSelectionList(tokenStream); + } + + return tokenStream; + } + @Override public QueryTokenStream visitJoinPath(HqlParser.JoinPathContext ctx) { diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancer.java index 37ec06e12f..f9ebe1efa7 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancer.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancer.java @@ -297,12 +297,20 @@ public DeclaredQuery getQuery() { @Override public String applySorting(Sort sort) { - return applySorting(sort, detectAlias()); + return doApplySorting(sort, detectAlias()); + } + + @Override + public String rewrite(QueryRewriteInformation rewriteInformation) { + return doApplySorting(rewriteInformation.getSort(), primaryAlias); } @Override public String applySorting(Sort sort, @Nullable String alias) { + return doApplySorting(sort, alias); + } + private String doApplySorting(Sort sort, @Nullable String alias) { String queryString = query.getQueryString(); Assert.hasText(queryString, "Query must not be null or empty"); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java index 25e7c25ca9..9d767d004f 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java @@ -21,8 +21,6 @@ import jakarta.persistence.criteria.Root; import java.util.Collection; -import java.util.LinkedHashSet; -import java.util.Set; import org.springframework.data.domain.KeysetScrollPosition; import org.springframework.data.domain.Sort; @@ -77,9 +75,7 @@ Collection getRequiredSelection(Sort sort, ReturnedType returnedType) { Sort sortToUse = KeysetScrollSpecification.createSort(scrollPosition, sort, entityInformation); - Set selection = new LinkedHashSet<>(returnedType.getInputProperties()); - sortToUse.forEach(it -> selection.add(it.getProperty())); - - return selection; + return KeysetScrollDelegate.getProjectionInputProperties(entityInformation, returnedType.getInputProperties(), + sortToUse); } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryEnhancer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryEnhancer.java index c677b2efcc..f5ed753c97 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryEnhancer.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryEnhancer.java @@ -30,6 +30,7 @@ import org.antlr.v4.runtime.atn.PredictionMode; import org.antlr.v4.runtime.tree.ParseTreeVisitor; import org.springframework.data.domain.Sort; +import org.springframework.data.repository.query.ReturnedType; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -48,12 +49,12 @@ class JpaQueryEnhancer implements QueryEnhancer { private final ParserRuleContext context; private final ParsedQueryIntrospector introspector; private final String projection; - private final BiFunction> sortFunction; + private final SortedQueryRewriteFunction sortFunction; private final BiFunction> countQueryFunction; JpaQueryEnhancer(ParserRuleContext context, ParsedQueryIntrospector introspector, - @Nullable BiFunction> sortFunction, - @Nullable BiFunction> countQueryFunction) { + SortedQueryRewriteFunction sortFunction, + BiFunction> countQueryFunction) { this.context = context; this.introspector = introspector; @@ -136,6 +137,10 @@ public static JpaQueryEnhancer forEql(DeclaredQuery query) { return EqlQueryParser.parseQuery(query.getQueryString()); } + ParserRuleContext getContext() { + return context; + } + /** * Checks if the select clause has a new constructor instantiation in the JPA query. * @@ -191,7 +196,13 @@ public DeclaredQuery getQuery() { */ @Override public String applySorting(Sort sort) { - return QueryRenderer.TokenRenderer.render(sortFunction.apply(sort, detectAlias()).visit(context)); + return QueryRenderer.TokenRenderer.render(sortFunction.apply(sort, detectAlias(), null).visit(context)); + } + + @Override + public String rewrite(QueryRewriteInformation rewriteInformation) { + return QueryRenderer.TokenRenderer.render(sortFunction + .apply(rewriteInformation.getSort(), detectAlias(), rewriteInformation.getReturnedType()).visit(context)); } /** @@ -308,4 +319,17 @@ public static JpqlQueryParser parseQuery(String query) throws BadJpqlGrammarExce return new JpqlQueryParser(query); } } + + /** + * Functional interface to rewrite a query considering {@link Sort} and {@link ReturnedType}. The function returns a + * visitor object that can visit the parsed query tree. + * + * @since 3.5 + */ + @FunctionalInterface + interface SortedQueryRewriteFunction { + + ParseTreeVisitor apply(Sort sort, String primaryAlias, @Nullable ReturnedType returnedType); + + } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlSortedQueryTransformer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlSortedQueryTransformer.java index a545171bbf..2539322498 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlSortedQueryTransformer.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlSortedQueryTransformer.java @@ -21,6 +21,7 @@ import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder; +import org.springframework.data.repository.query.ReturnedType; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -38,6 +39,7 @@ class JpqlSortedQueryTransformer extends JpqlQueryRenderer { private final JpaQueryTransformerSupport transformerSupport = new JpaQueryTransformerSupport(); private final Sort sort; private final @Nullable String primaryFromAlias; + private final @Nullable DtoProjectionTransformerDelegate dtoDelegate; JpqlSortedQueryTransformer(Sort sort, @Nullable String primaryFromAlias) { @@ -45,6 +47,16 @@ class JpqlSortedQueryTransformer extends JpqlQueryRenderer { this.sort = sort; this.primaryFromAlias = primaryFromAlias; + this.dtoDelegate = null; + } + + JpqlSortedQueryTransformer(Sort sort, @Nullable String primaryFromAlias, @Nullable ReturnedType returnedType) { + + Assert.notNull(sort, "Sort must not be null"); + + this.sort = sort; + this.primaryFromAlias = primaryFromAlias; + this.dtoDelegate = returnedType == null ? null : new DtoProjectionTransformerDelegate(returnedType); } @Override @@ -72,6 +84,26 @@ public QueryTokenStream visitSelect_statement(JpqlParser.Select_statementContext return builder; } + @Override + public QueryTokenStream visitSelect_clause(JpqlParser.Select_clauseContext ctx) { + + if (dtoDelegate == null) { + return super.visitSelect_clause(ctx); + } + + QueryRendererBuilder builder = QueryRenderer.builder(); + + builder.append(QueryTokens.expression(ctx.SELECT())); + + if (ctx.DISTINCT() != null) { + builder.append(QueryTokens.expression(ctx.DISTINCT())); + } + + QueryTokenStream tokenStream = QueryTokenStream.concat(ctx.select_item(), this::visit, TOKEN_COMMA); + + return builder.append(dtoDelegate.transformSelectionList(tokenStream)); + } + private void doVisitOrderBy(QueryRendererBuilder builder, JpqlParser.Select_statementContext ctx) { if (ctx.orderby_clause() != null) { diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java index 2942fa0bce..66b9245b60 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java @@ -16,7 +16,9 @@ package org.springframework.data.jpa.repository.query; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -24,6 +26,7 @@ import org.springframework.data.domain.ScrollPosition.Direction; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Order; +import org.springframework.data.jpa.repository.support.JpaEntityInformation; import org.springframework.lang.Nullable; /** @@ -47,6 +50,25 @@ public static KeysetScrollDelegate of(Direction direction) { return direction == Direction.FORWARD ? FORWARD : REVERSE; } + /** + * Return a collection of property names required to construct a keyset selection query that include all keyset and + * identifier properties required to resume keyset scrolling. + * + * @param entity the underlying entity. + * @param projectionProperties projection property names. + * @param sort sort properties. + * @return a collection of property names required to construct a keyset selection query + */ + public static Collection getProjectionInputProperties(JpaEntityInformation entity, + Collection projectionProperties, Sort sort) { + + Collection properties = new LinkedHashSet<>(projectionProperties); + sort.forEach(it -> properties.add(it.getProperty())); + properties.addAll(entity.getIdAttributeNames()); + + return properties; + } + @Nullable public P createPredicate(KeysetScrollPosition keyset, Sort sort, QueryStrategy strategy) { diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancer.java index 55c168a4f5..3697c22980 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancer.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancer.java @@ -18,6 +18,7 @@ import java.util.Set; import org.springframework.data.domain.Sort; +import org.springframework.data.repository.query.ReturnedType; import org.springframework.lang.Nullable; /** @@ -81,10 +82,20 @@ public interface QueryEnhancer { * @param sort the sort specification to apply. * @param alias the alias to be used in the order by clause. May be {@literal null} or empty. * @return the modified query string. + * @deprecated since 3.5, use {@link #rewrite(QueryRewriteInformation)} instead. */ - @Deprecated + @Deprecated(since = "3.5", forRemoval = true) String applySorting(Sort sort, @Nullable String alias); + /** + * Rewrite the query to include sorting and apply {@link ReturnedType} customizations. + * + * @param rewriteInformation the rewrite information to apply. + * @return the modified query string. + * @since 3.5 + */ + String rewrite(QueryRewriteInformation rewriteInformation); + /** * Creates a count projected query from the given original query. * @@ -101,4 +112,23 @@ default String createCountQueryFor() { * @return a query String to be used a count query for pagination. Guaranteed to be not {@literal null}. */ String createCountQueryFor(@Nullable String countProjection); + + /** + * Interface to describe the information needed to rewrite a query. + * + * @since 3.5 + */ + interface QueryRewriteInformation { + + /** + * @return the sort specification to apply. + */ + Sort getSort(); + + /** + * @return type expected to be returned by the query. + */ + ReturnedType getReturnedType(); + } + } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTokens.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTokens.java index 80df0b3300..cc339f4adf 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTokens.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTokens.java @@ -38,6 +38,7 @@ class QueryTokens { static final QueryToken TOKEN_EQUALS = token(" = "); static final QueryToken TOKEN_OPEN_PAREN = token("("); static final QueryToken TOKEN_CLOSE_PAREN = token(")"); + static final QueryToken TOKEN_NEW = expression("new"); static final QueryToken TOKEN_ORDER_BY = expression("order by"); static final QueryToken TOKEN_LOWER_FUNC = token("lower("); static final QueryToken TOKEN_SELECT_COUNT = token("select count("); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTransformers.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTransformers.java index 46bdc36003..b8f06d1368 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTransformers.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTransformers.java @@ -61,7 +61,7 @@ static CountSelectionTokenStream create(QueryTokenStream selection) { token = QueryTokens.token(token.value()); } - if (!containsNew && token.value().contains("new")) { + if (!containsNew && token.equals(TOKEN_NEW)) { containsNew = true; } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java index 3c06c7079b..ad812a5e84 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java @@ -769,7 +769,8 @@ static Expression toExpressionRecursively(From from, PropertyPath p return toExpressionRecursively(from, property, false); } - static Expression toExpressionRecursively(From from, PropertyPath property, boolean isForSelection) { + public static Expression toExpressionRecursively(From from, PropertyPath property, + boolean isForSelection) { return toExpressionRecursively(from, property, isForSelection, false); } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByPredicate.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByPredicate.java index 9ed0a0ce3e..4302c63650 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByPredicate.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByPredicate.java @@ -16,7 +16,6 @@ package org.springframework.data.jpa.repository.support; import jakarta.persistence.EntityManager; -import jakarta.persistence.Query; import java.util.ArrayList; import java.util.Collection; @@ -27,19 +26,29 @@ import java.util.stream.Stream; import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.data.domain.KeysetScrollPosition; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Window; +import org.springframework.data.jpa.repository.query.KeysetScrollDelegate; import org.springframework.data.jpa.repository.query.ScrollDelegate; import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.repository.query.FluentQuery.FetchableFluentQuery; +import org.springframework.data.repository.query.ReturnedType; import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import com.querydsl.core.types.EntityPath; +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.ExpressionBase; import com.querydsl.core.types.Predicate; +import com.querydsl.core.types.Visitor; +import com.querydsl.core.types.dsl.PathBuilder; +import com.querydsl.jpa.JPQLSerializer; import com.querydsl.jpa.impl.AbstractJPAQuery; /** @@ -57,33 +66,40 @@ */ class FetchableFluentQueryByPredicate extends FluentQuerySupport implements FetchableFluentQuery { + private final EntityPath entityPath; + private final JpaEntityInformation entityInformation; + private final ScrollQueryFactory> scrollQueryFactory; private final Predicate predicate; private final Function> finder; - private final PredicateScrollDelegate scroll; private final BiFunction> pagedFinder; private final Function countOperation; private final Function existsOperation; private final EntityManager entityManager; - FetchableFluentQueryByPredicate(Predicate predicate, Class entityType, - Function> finder, PredicateScrollDelegate scroll, + FetchableFluentQueryByPredicate(EntityPath entityPath, Predicate predicate, + JpaEntityInformation entityInformation, Function> finder, + ScrollQueryFactory> scrollQueryFactory, BiFunction> pagedFinder, Function countOperation, Function existsOperation, EntityManager entityManager, ProjectionFactory projectionFactory) { - this(predicate, entityType, (Class) entityType, Sort.unsorted(), 0, Collections.emptySet(), finder, scroll, - pagedFinder, countOperation, existsOperation, entityManager, projectionFactory); + this(entityPath, predicate, entityInformation, (Class) entityInformation.getJavaType(), Sort.unsorted(), 0, + Collections.emptySet(), finder, scrollQueryFactory, pagedFinder, countOperation, existsOperation, entityManager, + projectionFactory); } - private FetchableFluentQueryByPredicate(Predicate predicate, Class entityType, Class resultType, Sort sort, - int limit, Collection properties, Function> finder, - PredicateScrollDelegate scroll, BiFunction> pagedFinder, - Function countOperation, Function existsOperation, - EntityManager entityManager, ProjectionFactory projectionFactory) { + private FetchableFluentQueryByPredicate(EntityPath entityPath, Predicate predicate, + JpaEntityInformation entityInformation, Class resultType, Sort sort, int limit, + Collection properties, Function> finder, + ScrollQueryFactory> scrollQueryFactory, + BiFunction> pagedFinder, Function countOperation, + Function existsOperation, EntityManager entityManager, ProjectionFactory projectionFactory) { - super(resultType, sort, limit, properties, entityType, projectionFactory); + super(resultType, sort, limit, properties, entityInformation.getJavaType(), projectionFactory); + this.entityInformation = entityInformation; + this.entityPath = entityPath; this.predicate = predicate; this.finder = finder; - this.scroll = scroll; + this.scrollQueryFactory = scrollQueryFactory; this.pagedFinder = pagedFinder; this.countOperation = countOperation; this.existsOperation = existsOperation; @@ -95,8 +111,9 @@ public FetchableFluentQuery sortBy(Sort sort) { Assert.notNull(sort, "Sort must not be null"); - return new FetchableFluentQueryByPredicate<>(predicate, entityType, resultType, this.sort.and(sort), limit, - properties, finder, scroll, pagedFinder, countOperation, existsOperation, entityManager, projectionFactory); + return new FetchableFluentQueryByPredicate<>(entityPath, predicate, entityInformation, resultType, + this.sort.and(sort), limit, properties, finder, scrollQueryFactory, pagedFinder, countOperation, + existsOperation, entityManager, projectionFactory); } @Override @@ -104,8 +121,9 @@ public FetchableFluentQuery limit(int limit) { Assert.isTrue(limit >= 0, "Limit must not be negative"); - return new FetchableFluentQueryByPredicate<>(predicate, entityType, resultType, sort, limit, properties, finder, - scroll, pagedFinder, countOperation, existsOperation, entityManager, projectionFactory); + return new FetchableFluentQueryByPredicate<>(entityPath, predicate, entityInformation, resultType, sort, limit, + properties, finder, scrollQueryFactory, pagedFinder, countOperation, existsOperation, entityManager, + projectionFactory); } @Override @@ -113,20 +131,17 @@ public FetchableFluentQuery as(Class resultType) { Assert.notNull(resultType, "Projection target type must not be null"); - if (!resultType.isInterface()) { - throw new UnsupportedOperationException("Class-based DTOs are not yet supported."); - } - - return new FetchableFluentQueryByPredicate<>(predicate, entityType, resultType, sort, limit, properties, finder, - scroll, pagedFinder, countOperation, existsOperation, entityManager, projectionFactory); + return new FetchableFluentQueryByPredicate<>(entityPath, predicate, entityInformation, resultType, sort, limit, + properties, finder, scrollQueryFactory, pagedFinder, countOperation, existsOperation, entityManager, + projectionFactory); } @Override public FetchableFluentQuery project(Collection properties) { - return new FetchableFluentQueryByPredicate<>(predicate, entityType, resultType, sort, limit, - mergeProperties(properties), finder, scroll, pagedFinder, countOperation, existsOperation, entityManager, - projectionFactory); + return new FetchableFluentQueryByPredicate<>(entityPath, predicate, entityInformation, resultType, sort, limit, + mergeProperties(properties), finder, scrollQueryFactory, pagedFinder, countOperation, existsOperation, + entityManager, projectionFactory); } @Override @@ -163,7 +178,8 @@ public Window scroll(ScrollPosition scrollPosition) { Assert.notNull(scrollPosition, "ScrollPosition must not be null"); - return scroll.scroll(sort, limit, scrollPosition).map(getConversionFunction()); + return new PredicateScrollDelegate<>(scrollQueryFactory, entityInformation) + .scroll(returnedType, sort, limit, scrollPosition).map(getConversionFunction()); } @Override @@ -192,6 +208,33 @@ public boolean exists() { private AbstractJPAQuery createSortedAndProjectedQuery() { AbstractJPAQuery query = finder.apply(sort); + applyQuerySettings(this.returnedType, this.limit, query, null); + return query; + } + + private void applyQuerySettings(ReturnedType returnedType, int limit, AbstractJPAQuery query, + @Nullable ScrollPosition scrollPosition) { + + List inputProperties = returnedType.getInputProperties(); + + if (returnedType.needsCustomConstruction()) { + + Collection requiredSelection; + if (scrollPosition instanceof KeysetScrollPosition && returnedType.getReturnedType().isInterface()) { + requiredSelection = KeysetScrollDelegate.getProjectionInputProperties(entityInformation, inputProperties, sort); + } else { + requiredSelection = inputProperties; + } + + PathBuilder builder = new PathBuilder<>(entityPath.getType(), entityPath.getMetadata()); + Expression[] projection = requiredSelection.stream().map(builder::get).toArray(Expression[]::new); + + if (returnedType.getReturnedType().isInterface()) { + query.select(new JakartaTuple(projection)); + } else { + query.select(new DtoProjection(returnedType.getReturnedType(), projection)); + } + } if (!properties.isEmpty()) { query.setHint(EntityGraphFactory.HINT, EntityGraphFactory.create(entityManager, entityType, properties)); @@ -200,8 +243,6 @@ public boolean exists() { if (limit != 0) { query.limit(limit); } - - return query; } private Page readPage(Pageable pageable) { @@ -233,23 +274,60 @@ private Function getConversionFunction() { return getConversionFunction(entityType, resultType); } - static class PredicateScrollDelegate extends ScrollDelegate { + class PredicateScrollDelegate extends ScrollDelegate { - private final ScrollQueryFactory scrollFunction; + private final ScrollQueryFactory> scrollFunction; - PredicateScrollDelegate(ScrollQueryFactory scrollQueryFactory, JpaEntityInformation entity) { + PredicateScrollDelegate(ScrollQueryFactory> scrollQueryFactory, + JpaEntityInformation entity) { super(entity); this.scrollFunction = scrollQueryFactory; } - public Window scroll(Sort sort, int limit, ScrollPosition scrollPosition) { + public Window scroll(ReturnedType returnedType, Sort sort, int limit, ScrollPosition scrollPosition) { - Query query = scrollFunction.createQuery(sort, scrollPosition); - if (limit > 0) { - query = query.setMaxResults(limit); - } - return scroll(query, sort, scrollPosition); + AbstractJPAQuery query = scrollFunction.createQuery(returnedType, sort, scrollPosition); + + applyQuerySettings(returnedType, limit, query, scrollPosition); + + return scroll(query.createQuery(), sort, scrollPosition); } } + /** + * @since 3.5 + */ + private static class DtoProjection extends ExpressionBase { + + private final Expression[] projection; + + public DtoProjection(Class resultType, Expression[] projection) { + super(resultType); + this.projection = projection; + } + + @SuppressWarnings("unchecked") + @Override + public R accept(Visitor v, @Nullable C context) { + + if (v instanceof JPQLSerializer s) { + + s.append("new ").append(getType().getName()).append("("); + boolean first = true; + for (Expression expression : projection) { + if (first) { + first = false; + } else { + s.append(", "); + } + + expression.accept(v, context); + } + + s.append(")"); + } + + return (R) this; + } + } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryBySpecification.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryBySpecification.java index 08659af984..78601566d3 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryBySpecification.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryBySpecification.java @@ -23,6 +23,7 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.function.BiFunction; import java.util.function.Function; import java.util.stream.Stream; @@ -38,6 +39,7 @@ import org.springframework.data.jpa.support.PageableUtils; import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.repository.query.FluentQuery; +import org.springframework.data.repository.query.ReturnedType; import org.springframework.data.support.PageableExecutionUtils; import org.springframework.util.Assert; @@ -55,13 +57,14 @@ class FetchableFluentQueryBySpecification extends FluentQuerySupport implements FluentQuery.FetchableFluentQuery { private final Specification spec; - private final Function> finder; + private final BiFunction> finder; private final SpecificationScrollDelegate scroll; private final Function, Long> countOperation; private final Function, Boolean> existsOperation; private final EntityManager entityManager; - FetchableFluentQueryBySpecification(Specification spec, Class entityType, Function> finder, + FetchableFluentQueryBySpecification(Specification spec, Class entityType, + BiFunction> finder, SpecificationScrollDelegate scrollDelegate, Function, Long> countOperation, Function, Boolean> existsOperation, EntityManager entityManager, ProjectionFactory projectionFactory) { @@ -70,7 +73,7 @@ class FetchableFluentQueryBySpecification extends FluentQuerySupport } private FetchableFluentQueryBySpecification(Specification spec, Class entityType, Class resultType, - Sort sort, int limit, Collection properties, Function> finder, + Sort sort, int limit, Collection properties, BiFunction> finder, SpecificationScrollDelegate scrollDelegate, Function, Long> countOperation, Function, Boolean> existsOperation, EntityManager entityManager, ProjectionFactory projectionFactory) { @@ -106,9 +109,6 @@ public FetchableFluentQuery limit(int limit) { public FetchableFluentQuery as(Class resultType) { Assert.notNull(resultType, "Projection target type must not be null"); - if (!resultType.isInterface()) { - throw new UnsupportedOperationException("Class-based DTOs are not yet supported."); - } return new FetchableFluentQueryBySpecification<>(spec, entityType, resultType, sort, limit, properties, finder, scroll, countOperation, existsOperation, entityManager, projectionFactory); @@ -155,7 +155,7 @@ public Window scroll(ScrollPosition scrollPosition) { Assert.notNull(scrollPosition, "ScrollPosition must not be null"); - return scroll.scroll(sort, limit, scrollPosition).map(getConversionFunction()); + return scroll.scroll(returnedType, sort, limit, scrollPosition).map(getConversionFunction()); } @Override @@ -183,7 +183,7 @@ public boolean exists() { private TypedQuery createSortedAndProjectedQuery() { - TypedQuery query = finder.apply(sort); + TypedQuery query = finder.apply(returnedType, sort); if (!properties.isEmpty()) { query.setHint(EntityGraphFactory.HINT, EntityGraphFactory.create(entityManager, entityType, properties)); @@ -227,16 +227,17 @@ private Function getConversionFunction() { static class SpecificationScrollDelegate extends ScrollDelegate { - private final ScrollQueryFactory scrollFunction; + private final ScrollQueryFactory> scrollFunction; - SpecificationScrollDelegate(ScrollQueryFactory scrollQueryFactory, JpaEntityInformation entity) { + SpecificationScrollDelegate(ScrollQueryFactory> scrollQueryFactory, + JpaEntityInformation entity) { super(entity); this.scrollFunction = scrollQueryFactory; } - public Window scroll(Sort sort, int limit, ScrollPosition scrollPosition) { + public Window scroll(ReturnedType returnedType, Sort sort, int limit, ScrollPosition scrollPosition) { - Query query = scrollFunction.createQuery(sort, scrollPosition); + Query query = scrollFunction.createQuery(returnedType, sort, scrollPosition); if (limit > 0) { query = query.setMaxResults(limit); 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 5917a119f5..668d4b536b 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 @@ -15,8 +15,6 @@ */ package org.springframework.data.jpa.repository.support; -import jakarta.persistence.Query; - import java.util.Collection; import java.util.Collections; import java.util.HashSet; @@ -26,7 +24,9 @@ import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.query.AbstractJpaQuery; import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.repository.query.ReturnedType; import org.springframework.lang.Nullable; /** @@ -41,6 +41,7 @@ */ abstract class FluentQuerySupport { + protected final ReturnedType returnedType; protected final Class resultType; protected final Sort sort; protected final int limit; @@ -51,6 +52,7 @@ abstract class FluentQuerySupport { FluentQuerySupport(Class resultType, Sort sort, int limit, @Nullable Collection properties, Class entityType, ProjectionFactory projectionFactory) { + this.returnedType = ReturnedType.of(resultType, entityType, projectionFactory); this.resultType = resultType; this.sort = sort; this.limit = limit; @@ -80,15 +82,20 @@ final Function getConversionFunction(Class inputType, Class tar return (Function) Function.identity(); } - if (targetType.isInterface()) { - return o -> projectionFactory.createProjection(targetType, o); + if (returnedType.isProjecting()) { + + AbstractJpaQuery.TupleConverter tupleConverter = new AbstractJpaQuery.TupleConverter(returnedType); + + if (resultType.isInterface()) { + return o -> projectionFactory.createProjection(targetType, tupleConverter.convert(o)); + } } return o -> DefaultConversionService.getSharedInstance().convert(o, targetType); } - interface ScrollQueryFactory { - Query createQuery(Sort sort, ScrollPosition scrollPosition); + interface ScrollQueryFactory { + Q createQuery(ReturnedType returnedType, Sort sort, ScrollPosition scrollPosition); } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JakartaTuple.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JakartaTuple.java new file mode 100644 index 0000000000..1f1a18ac44 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JakartaTuple.java @@ -0,0 +1,96 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.support; + +import jakarta.persistence.Tuple; + +import java.util.Arrays; +import java.util.List; + +import org.springframework.lang.Nullable; + +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.ExpressionBase; +import com.querydsl.core.types.ExpressionUtils; +import com.querydsl.core.types.FactoryExpression; +import com.querydsl.core.types.Ops; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.Visitor; +import com.querydsl.jpa.JPQLSerializer; + +/** + * Expression based on a {@link Tuple}. It's a simplified variant of {@link com.querydsl.core.types.QTuple} without + * being a {@link com.querydsl.core.types.FactoryExpressionBase} as we do not want Querydsl to instantiate any tuples. + * JPA is doing that for us. + * + * @author Mark Paluch + * @since 3.5 + */ +@SuppressWarnings("unchecked") +class JakartaTuple extends ExpressionBase { + + private final List> args; + + /** + * Create a new JakartaTuple instance + * + * @param args + */ + protected JakartaTuple(Expression... args) { + this(Arrays.asList(args)); + } + + /** + * Create a new JakartaTuple instance + * + * @param args + */ + protected JakartaTuple(List> args) { + super(Tuple.class); + this.args = args.stream().map(it -> { + + if (it instanceof Path p) { + return ExpressionUtils.operation(p.getType(), Ops.ALIAS, p, p); + } + + return it; + }).toList(); + } + + @Override + @Nullable + public R accept(Visitor v, @Nullable C context) { + + if (v instanceof JPQLSerializer) { + return Projections.tuple(args).accept(v, context); + } + + return (R) this; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } else if (obj instanceof FactoryExpression c) { + return args.equals(c.getArgs()) && getType().equals(c.getType()); + } else { + return false; + } + } + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaMetamodelEntityInformation.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaMetamodelEntityInformation.java index 9979fc773b..9f25baa47d 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaMetamodelEntityInformation.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaMetamodelEntityInformation.java @@ -161,7 +161,9 @@ public ID getId(T entity) { return (ID) t.get(idMetadata.getSimpleIdAttribute().getName()); } - return (ID) persistenceUnitUtil.getIdentifier(entity); + if (getJavaType().isInstance(entity)) { + return (ID) persistenceUnitUtil.getIdentifier(entity); + } } // otherwise, check if the complex id type has any partially filled fields @@ -172,6 +174,10 @@ public ID getId(T entity) { Object propertyValue = entityWrapper.getPropertyValue(attribute.getName()); + if (idMetadata.hasSimpleId()) { + return (ID) propertyValue; + } + if (propertyValue != null) { partialIdValueFound = true; } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/Querydsl.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/Querydsl.java index 0ade24f133..4e8ef371d2 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/Querydsl.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/Querydsl.java @@ -25,6 +25,7 @@ import org.springframework.data.jpa.provider.PersistenceProvider; import org.springframework.data.mapping.PropertyPath; import org.springframework.data.querydsl.QSort; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; import com.querydsl.core.types.EntityPath; @@ -37,6 +38,7 @@ import com.querydsl.jpa.EclipseLinkTemplates; import com.querydsl.jpa.HQLTemplates; import com.querydsl.jpa.JPQLQuery; +import com.querydsl.jpa.JPQLTemplates; import com.querydsl.jpa.impl.AbstractJPAQuery; import com.querydsl.jpa.impl.JPAQuery; @@ -77,11 +79,25 @@ public Querydsl(EntityManager em, PathBuilder builder) { */ public AbstractJPAQuery> createQuery() { - return switch (provider) { - case ECLIPSELINK -> new JPAQuery<>(em, EclipseLinkTemplates.DEFAULT); - case HIBERNATE -> new JPAQuery<>(em, HQLTemplates.DEFAULT); - default -> new JPAQuery<>(em); - }; + JPQLTemplates templates = getTemplates(); + return templates != null ? new SpringDataJpaQuery<>(em, templates) : new SpringDataJpaQuery<>(em); + } + + /** + * Obtains the {@link JPQLTemplates} for the configured {@link EntityManager}. Can return {@literal null} to use the + * default templates. + * + * @return the {@link JPQLTemplates} for the configured {@link EntityManager} or {@literal null} to use the default. + * @since 3.5 + */ + @Nullable + public JPQLTemplates getTemplates() { + + return switch (provider) { + case ECLIPSELINK -> EclipseLinkTemplates.DEFAULT; + case HIBERNATE -> HQLTemplates.DEFAULT; + default -> JPQLTemplates.DEFAULT; + }; } /** @@ -198,11 +214,11 @@ private NullHandling toQueryDslNullHandling(org.springframework.data.domain.Sort Assert.notNull(nullHandling, "NullHandling must not be null"); - return switch (nullHandling) { - case NULLS_FIRST -> NullHandling.NullsFirst; - case NULLS_LAST -> NullHandling.NullsLast; - default -> NullHandling.Default; - }; + return switch (nullHandling) { + case NULLS_FIRST -> NullHandling.NullsFirst; + case NULLS_LAST -> NullHandling.NullsLast; + default -> NullHandling.Default; + }; } /** diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutor.java index c16f95c0a1..a2abcb9e38 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutor.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutor.java @@ -34,7 +34,6 @@ import org.springframework.data.jpa.repository.query.KeysetScrollDelegate; import org.springframework.data.jpa.repository.query.KeysetScrollDelegate.QueryStrategy; import org.springframework.data.jpa.repository.query.KeysetScrollSpecification; -import org.springframework.data.jpa.repository.support.FetchableFluentQueryByPredicate.PredicateScrollDelegate; import org.springframework.data.jpa.repository.support.FluentQuerySupport.ScrollQueryFactory; import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.projection.SpelAwareProxyProjectionFactory; @@ -188,7 +187,7 @@ public R findBy(Predicate predicate, Function { + ScrollQueryFactory> scroll = (returnedType, sort, scrollPosition) -> { Predicate predicateToUse = predicate; @@ -214,7 +213,7 @@ public R findBy(Predicate predicate, Function> pagedFinder = (sort, pageable) -> { @@ -229,10 +228,11 @@ public R findBy(Predicate predicate, Function fluentQuery = new FetchableFluentQueryByPredicate<>( // + path, predicate, // - this.entityInformation.getJavaType(), // + this.entityInformation, // finder, // - new PredicateScrollDelegate<>(scroll, entityInformation), // + scroll, // pagedFinder, // this::count, // this::exists, // 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 bf2faea2f4..99d3199c62 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 @@ -30,6 +30,7 @@ import jakarta.persistence.criteria.Path; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; +import jakarta.persistence.criteria.Selection; import java.io.Serial; import java.util.ArrayList; @@ -40,6 +41,7 @@ import java.util.Map; import java.util.Optional; import java.util.function.BiConsumer; +import java.util.function.BiFunction; import java.util.function.Function; import org.springframework.data.domain.Example; @@ -48,21 +50,25 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.convert.QueryByExamplePredicateBuilder; import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.provider.PersistenceProvider; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.query.EscapeCharacter; +import org.springframework.data.jpa.repository.query.KeysetScrollDelegate; import org.springframework.data.jpa.repository.query.KeysetScrollSpecification; import org.springframework.data.jpa.repository.query.QueryUtils; import org.springframework.data.jpa.repository.support.FetchableFluentQueryBySpecification.SpecificationScrollDelegate; import org.springframework.data.jpa.repository.support.FluentQuerySupport.ScrollQueryFactory; import org.springframework.data.jpa.repository.support.QueryHints.NoHints; import org.springframework.data.jpa.support.PageableUtils; +import org.springframework.data.mapping.PropertyPath; import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.data.repository.query.FluentQuery.FetchableFluentQuery; +import org.springframework.data.repository.query.ReturnedType; import org.springframework.data.support.PageableExecutionUtils; import org.springframework.data.util.ProxyUtils; import org.springframework.data.util.Streamable; @@ -107,7 +113,7 @@ public class SimpleJpaRepository implements JpaRepositoryImplementation entityInformation, EntityM this.entityInformation = entityInformation; this.entityManager = entityManager; this.provider = PersistenceProvider.fromEntityManager(entityManager); + this.projectionFactory = new SpelAwareProxyProjectionFactory(); } /** @@ -502,7 +509,7 @@ private R doFindBy(Specification spec, Class domainClass, Assert.notNull(spec, "Specification must not be null"); Assert.notNull(queryFunction, "Query function must not be null"); - ScrollQueryFactory scrollFunction = (sort, scrollPosition) -> { + ScrollQueryFactory> scrollFunction = (returnedType, sort, scrollPosition) -> { Specification specToUse = spec; @@ -512,7 +519,7 @@ private R doFindBy(Specification spec, Class domainClass, specToUse = specToUse.and(keysetSpec); } - TypedQuery query = getQuery(specToUse, domainClass, sort); + TypedQuery query = getQuery(returnedType, specToUse, domainClass, sort, scrollPosition); if (scrollPosition instanceof OffsetScrollPosition offset) { if (!offset.isInitial()) { @@ -523,7 +530,8 @@ private R doFindBy(Specification spec, Class domainClass, return query; }; - Function> finder = sort -> getQuery(spec, domainClass, sort); + BiFunction> finder = (returnedType, sort) -> getQuery(returnedType, spec, + domainClass, sort, null); SpecificationScrollDelegate scrollDelegate = new SpecificationScrollDelegate<>(scrollFunction, entityInformation); @@ -745,12 +753,61 @@ protected TypedQuery getQuery(@Nullable Specification spec, Sort sort) { * @param sort must not be {@literal null}. */ protected TypedQuery getQuery(@Nullable Specification spec, Class domainClass, Sort sort) { + return getQuery(ReturnedType.of(domainClass, domainClass, projectionFactory), spec, domainClass, sort, null); + } + + /** + * Creates a {@link TypedQuery} for the given {@link Specification} and {@link Sort}. + * + * @param returnedType must not be {@literal null}. + * @param spec can be {@literal null}. + * @param domainClass must not be {@literal null}. + * @param sort must not be {@literal null}. + */ + private TypedQuery getQuery(ReturnedType returnedType, @Nullable Specification spec, + Class domainClass, Sort sort, @Nullable ScrollPosition scrollPosition) { CriteriaBuilder builder = entityManager.getCriteriaBuilder(); - CriteriaQuery query = builder.createQuery(domainClass); + CriteriaQuery query; + + List inputProperties = returnedType.getInputProperties(); + + if (returnedType.needsCustomConstruction()) { + query = (CriteriaQuery) (returnedType.getReturnedType().isInterface() ? builder.createTupleQuery() + : builder.createQuery(returnedType.getReturnedType())); + } else { + query = builder.createQuery(domainClass); + } Root root = applySpecificationToCriteria(spec, domainClass, query); - query.select(root); + + if (returnedType.needsCustomConstruction()) { + + Collection requiredSelection; + + if (scrollPosition instanceof KeysetScrollPosition && returnedType.getReturnedType().isInterface()) { + requiredSelection = KeysetScrollDelegate.getProjectionInputProperties(entityInformation, inputProperties, sort); + } else { + requiredSelection = inputProperties; + } + + List> selections = new ArrayList<>(); + + for (String property : requiredSelection) { + + PropertyPath path = PropertyPath.from(property, returnedType.getDomainType()); + selections.add(QueryUtils.toExpressionRecursively(root, path, true).alias(property)); + } + + Class typeToRead = returnedType.getReturnedType(); + + query = typeToRead.isInterface() // + ? query.multiselect(selections) // + : query.select((Selection) builder.construct(typeToRead, // + selections.toArray(new Selection[0]))); + } else { + query.select(root); + } if (sort.isSorted()) { query.orderBy(toOrders(sort, root, builder)); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SpringDataJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SpringDataJpaQuery.java new file mode 100644 index 0000000000..5eba7dd36d --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SpringDataJpaQuery.java @@ -0,0 +1,96 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.support; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import jakarta.persistence.Tuple; + +import java.util.Map; + +import org.springframework.lang.Nullable; + +import com.querydsl.core.QueryModifiers; +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.FactoryExpression; +import com.querydsl.jpa.JPQLSerializer; +import com.querydsl.jpa.JPQLTemplates; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAUtil; + +/** + * Customized String-Query implementation that specifically routes tuple query creation to + * {@code EntityManager#createQuery(queryString, Tuple.class)}. + * + * @author Mark Paluch + * @since 3.5 + */ +class SpringDataJpaQuery extends JPAQuery { + + public SpringDataJpaQuery(EntityManager em) { + super(em); + } + + public SpringDataJpaQuery(EntityManager em, JPQLTemplates templates) { + super(em, templates); + } + + protected Query createQuery(@Nullable QueryModifiers modifiers, boolean forCount) { + + JPQLSerializer serializer = serialize(forCount); + String queryString = serializer.toString(); + logQuery(queryString); + + Query query = getMetadata().getProjection() instanceof JakartaTuple + ? entityManager.createQuery(queryString, Tuple.class) + : entityManager.createQuery(queryString); + + JPAUtil.setConstants(query, serializer.getConstants(), getMetadata().getParams()); + if (modifiers != null && modifiers.isRestricting()) { + Integer limit = modifiers.getLimitAsInteger(); + Integer offset = modifiers.getOffsetAsInteger(); + if (limit != null) { + query.setMaxResults(limit); + } + if (offset != null) { + query.setFirstResult(offset); + } + } + if (lockMode != null) { + query.setLockMode(lockMode); + } + if (flushMode != null) { + query.setFlushMode(flushMode); + } + + for (Map.Entry entry : hints.entrySet()) { + query.setHint(entry.getKey(), entry.getValue()); + } + + // set transformer, if necessary and possible + Expression projection = getMetadata().getProjection(); + this.projection = null; // necessary when query is reused + + if (!forCount && projection instanceof FactoryExpression) { + if (!queryHandler.transform(query, (FactoryExpression) projection)) { + this.projection = (FactoryExpression) projection; + } + } + + return query; + } + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkUserRepositoryFinderTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkUserRepositoryFinderTests.java index 99343adb99..18e0570de8 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkUserRepositoryFinderTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkUserRepositoryFinderTests.java @@ -36,4 +36,8 @@ void executesNotInQueryCorrectly() {} @Override void executesInKeywordForPageCorrectly() {} + @Disabled + @Override + void rawMapProjectionWithEntityAndAggregatedValue() {} + } 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 fdf3330ca5..09281ed623 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,18 +15,24 @@ */ package org.springframework.data.jpa.repository; -import static org.assertj.core.api.Assertions.*; -import static org.springframework.data.domain.Sort.Direction.*; +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 jakarta.persistence.EntityManager; import java.util.Arrays; import java.util.List; +import java.util.Map; +import org.assertj.core.data.Offset; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; 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; @@ -45,6 +51,9 @@ import org.springframework.data.jpa.repository.sample.UserRepository.IdOnly; import org.springframework.data.jpa.repository.sample.UserRepository.NameOnly; import org.springframework.data.jpa.repository.sample.UserRepository.RolesAndFirstname; +import org.springframework.data.jpa.repository.sample.UserRepository.UserExcerpt; +import org.springframework.data.jpa.repository.sample.UserRepository.UserRoleCountDtoProjection; +import org.springframework.data.jpa.repository.sample.UserRepository.UserRoleCountInterfaceProjection; import org.springframework.data.repository.query.QueryLookupStrategy; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; @@ -247,9 +256,9 @@ void executesQueryWithLimitAndScrollPosition() { @Test // GH-3409 void executesWindowQueryWithPageable() { - Window first = userRepository.findByLastnameOrderByFirstname("Matthews", PageRequest.of(0,1)); + Window first = userRepository.findByLastnameOrderByFirstname("Matthews", PageRequest.of(0, 1)); - Window next = userRepository.findByLastnameOrderByFirstname("Matthews", PageRequest.of(1,1)); + Window next = userRepository.findByLastnameOrderByFirstname("Matthews", PageRequest.of(1, 1)); assertThat(first).containsExactly(dave); assertThat(next).containsExactly(oliver); @@ -378,7 +387,7 @@ void executesNamedQueryWithConstructorExpression() { } @Test // DATAJPA-1713, GH-2008 - public void selectProjectionWithSubselect() { + void selectProjectionWithSubselect() { List dtos = userRepository.findProjectionBySubselect(); @@ -405,4 +414,93 @@ void findByNegatingSimplePropertyUsingMixedNullNonNullArgument() { result = userRepository.findUserByLastname(carter.getLastname()); assertThat(result).containsExactly(carter); } + + @Test // GH-3076 + void dtoProjectionShouldApplyConstructorExpressionRewriting() { + + List dtos = userRepository.findRecordProjection(); + + assertThat(dtos).flatExtracting(UserRepository.UserExcerpt::firstname) // + .contains("Dave", "Carter", "Oliver August"); + } + + @Test // GH-3076 + void dtoMultiselectProjectionShouldApplyConstructorExpressionRewriting() { + + List dtos = userRepository.findMultiselectRecordProjection(); + + assertThat(dtos).flatExtracting(UserRepository.UserExcerpt::firstname) // + .contains("Dave", "Carter", "Oliver August"); + } + + @Test // GH-3076 + void dynamicDtoProjection() { + + List dtos = userRepository.findRecordProjection(UserExcerpt.class); + + assertThat(dtos).flatExtracting(UserRepository.UserExcerpt::firstname) // + .contains("Dave", "Carter", "Oliver August"); + } + + @Test // GH-3076 + void dtoProjectionWithEntityAndAggregatedValue() { + + Map musicians = Map.of(carter.getFirstname(), carter, dave.getFirstname(), dave, + oliver.getFirstname(), oliver); + + assertThat(userRepository.dtoProjectionEntityAndAggregatedValue()).allSatisfy(projection -> { + assertThat(projection.user()).isIn(musicians.values()); + assertThat(projection.roleCount()).isCloseTo(musicians.get(projection.user().getFirstname()).getRoles().size(), + Offset.offset(0L)); + }); + } + + @Test // GH-3076 + void interfaceProjectionWithEntityAndAggregatedValue() { + + Map musicians = Map.of(carter.getFirstname(), carter, dave.getFirstname(), dave, + oliver.getFirstname(), oliver); + + assertThat(userRepository.interfaceProjectionEntityAndAggregatedValue()).allSatisfy(projection -> { + assertThat(projection.getUser()).isIn(musicians.values()); + assertThat(projection.getRoleCount()) + .isCloseTo(musicians.get(projection.getUser().getFirstname()).getRoles().size(), Offset.offset(0L)); + }); + } + + @Test // GH-3076 + void rawMapProjectionWithEntityAndAggregatedValue() { + + Map musicians = Map.of(carter.getFirstname(), carter, dave.getFirstname(), dave, + oliver.getFirstname(), oliver); + + assertThat(userRepository.rawMapProjectionEntityAndAggregatedValue()).allSatisfy(projection -> { + assertThat(projection.get("user")).isIn(musicians.values()); + assertThat(projection).containsKey("roleCount"); + }); + } + + @Test // GH-3076 + void dtoProjectionWithEntityAndAggregatedValueWithPageable() { + + Map musicians = Map.of(carter.getFirstname(), carter, dave.getFirstname(), dave, + oliver.getFirstname(), oliver); + + assertThat( + userRepository.dtoProjectionEntityAndAggregatedValue(PageRequest.of(0, 10).withSort(Sort.by("firstname")))) + .allSatisfy(projection -> { + assertThat(projection.user()).isIn(musicians.values()); + assertThat(projection.roleCount()) + .isCloseTo(musicians.get(projection.user().getFirstname()).getRoles().size(), Offset.offset(0L)); + }); + } + + @ParameterizedTest // GH-3076 + @ValueSource(classes = { UserRoleCountDtoProjection.class, UserRoleCountInterfaceProjection.class }) + void dynamicProjectionWithEntityAndAggregated(Class resultType) { + + assertThat(userRepository.findMultiselectRecordDynamicProjection(resultType)).hasSize(3) + .hasOnlyElementsOfType(resultType); + } + } 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 caf40e1d99..c03f8132c1 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 @@ -1409,6 +1409,57 @@ void scrollByPredicateKeysetBackward() { assertThat(previousWindow.hasNext()).isFalse(); } + @Test // GH-2327 + void scrollByPredicateKeysetWithInterfaceProjection() { + + User jane1 = new User("Jane", "Doe", "jane@doe1.com"); + User jane2 = new User("Jane", "Doe", "jane@doe2.com"); + User john1 = new User("John", "Doe", "john@doe1.com"); + User john2 = new User("John", "Doe", "john@doe2.com"); + + repository.saveAllAndFlush(Arrays.asList(john1, john2, jane1, jane2)); + + Window firstWindow = repository.findBy(QUser.user.firstname.startsWith("J"), + q -> q.limit(1).sortBy(Sort.by("firstname", "emailAddress")).as(UserProjectionInterfaceBased.class) + .scroll(ScrollPosition.keyset())); + + assertThat(firstWindow.getContent()).extracting(UserProjectionInterfaceBased::getFirstname) + .containsOnly(jane1.getFirstname()); + assertThat(firstWindow.hasNext()).isTrue(); + + Window nextWindow = repository.findBy(QUser.user.firstname.startsWith("J"), + q -> q.limit(2).sortBy(Sort.by("firstname", "emailAddress")).as(UserProjectionInterfaceBased.class) + .scroll(firstWindow.positionAt(0))); + + assertThat(nextWindow.getContent()).extracting(UserProjectionInterfaceBased::getFirstname) + .containsExactly(jane2.getFirstname(), john1.getFirstname()); + assertThat(nextWindow.hasNext()).isTrue(); + } + + @Test // GH-2327 + void scrollByPredicateKeysetWithDtoProjection() { + + User jane1 = new User("Jane", "Doe", "jane@doe1.com"); + User jane2 = new User("Jane", "Doe", "jane@doe2.com"); + User john1 = new User("John", "Doe", "john@doe1.com"); + User john2 = new User("John", "Doe", "john@doe2.com"); + + repository.saveAllAndFlush(Arrays.asList(john1, john2, jane1, jane2)); + + Window firstWindow = repository.findBy(QUser.user.firstname.startsWith("J"), + q -> q.limit(1).sortBy(Sort.by("firstname", "emailAddress")).as(UserDto.class).scroll(ScrollPosition.keyset())); + + assertThat(firstWindow.getContent()).extracting(UserDto::firstname).containsOnly(jane1.getFirstname()); + assertThat(firstWindow.hasNext()).isTrue(); + + Window nextWindow = repository.findBy(QUser.user.firstname.startsWith("J"), q -> q.limit(2) + .sortBy(Sort.by("firstname", "emailAddress")).as(UserDto.class).scroll(firstWindow.positionAt(0))); + + assertThat(nextWindow.getContent()).extracting(UserDto::firstname).containsExactly(jane2.getFirstname(), + john1.getFirstname()); + assertThat(nextWindow.hasNext()).isTrue(); + } + @Test // GH-2878 void scrollByPartTreeKeysetBackward() { @@ -2556,40 +2607,6 @@ void findByFluentExampleWithSortedInterfaceBasedProjection() { .containsExactlyInAnyOrder(thirdUser.getFirstname(), firstUser.getFirstname(), fourthUser.getFirstname()); } - @Test // GH-2294 - void fluentExamplesWithClassBasedDtosNotYetSupported() { - - class UserDto { - String firstname; - - public UserDto() {} - - public String getFirstname() { - return this.firstname; - } - - public void setFirstname(String firstname) { - this.firstname = firstname; - } - - public String toString() { - return "UserDto(firstname=" + this.getFirstname() + ")"; - } - } - - assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> { - - User prototype = new User(); - prototype.setFirstname("v"); - - repository.findBy( - of(prototype, - matching().withIgnorePaths("age", "createdAt", "active").withMatcher("firstname", - GenericPropertyMatcher::contains)), // - q -> q.as(UserDto.class).sortBy(Sort.by("firstname")).all()); - }); - } - @Test // GH-2294 void countByFluentExample() { @@ -2691,6 +2708,17 @@ void findByFluentSpecificationWithInterfaceBasedProjection() { .containsExactlyInAnyOrder(firstUser.getFirstname(), thirdUser.getFirstname(), fourthUser.getFirstname()); } + @Test // GH-2327 + void findByFluentSpecificationWithDtoProjection() { + + flushTestUsers(); + + List users = repository.findBy(userHasFirstnameLike("v"), q -> q.as(UserDto.class).all()); + + assertThat(users).extracting(UserDto::firstname).containsExactlyInAnyOrder(firstUser.getFirstname(), + thirdUser.getFirstname(), fourthUser.getFirstname()); + } + @Test // GH-2274 void findByFluentSpecificationWithSimplePropertyPathsDoesntLoadUnrequestedPaths() { @@ -2801,32 +2829,6 @@ void findByFluentSpecificationWithSortedInterfaceBasedProjection() { .containsExactlyInAnyOrder(thirdUser.getFirstname(), firstUser.getFirstname(), fourthUser.getFirstname()); } - @Test // GH-2274 - void fluentSpecificationWithClassBasedDtosNotYetSupported() { - - class UserDto { - String firstname; - - public UserDto() {} - - public String getFirstname() { - return this.firstname; - } - - public void setFirstname(String firstname) { - this.firstname = firstname; - } - - public String toString() { - return "UserDto(firstname=" + this.getFirstname() + ")"; - } - } - - assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> { - repository.findBy(userHasFirstnameLike("v"), q -> q.as(UserDto.class).sortBy(Sort.by("firstname")).all()); - }); - } - @Test // GH-2274 void countByFluentSpecification() { @@ -2957,6 +2959,16 @@ void dynamicProjectionReturningList() { assertThat(users).hasSize(1); } + @Test // GH-2327 + void dynamicOpenProjectionReturningList() { + + flushTestUsers(); + + List users = repository.findAsListByFirstnameLike("%O%", UserProjectionUsingSpEL.class); + + assertThat(users).hasSize(1); + } + @Test // DATAJPA-1179 void duplicateSpelsWorkAsIntended() { @@ -3455,6 +3467,10 @@ private interface UserProjectionInterfaceBased { String getFirstname(); } + record UserDto(Integer id, String firstname, String lastname, String emailAddress) { + + } + private interface UserProjectionUsingSpEL { @Value("#{@greetingsFrom.groot(target.firstname)}") diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQueryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQueryUnitTests.java index b2e6ba4fce..9611cd11e2 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQueryUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQueryUnitTests.java @@ -52,6 +52,7 @@ * Unit tests for {@link AbstractStringBasedJpaQuery}. * * @author Christoph Strobl + * @author Mark Paluch */ class AbstractStringBasedJpaQueryUnitTests { @@ -64,13 +65,14 @@ void shouldNotAttemptToAppendSortIfNoSortArgumentPresent() { stringQuery.neverCalled("applySorting"); } - @Test // GH-3310 - void shouldNotAttemptToAppendSortIfSortIndicatesUnsorted() { + @Test // GH-3310, GH-3076 + void shouldRunQueryRewriterOnce() { InvocationCapturingStringQueryStub stringQuery = forMethod(TestRepo.class, "find", Sort.class); stringQuery.createQueryWithArguments(Sort.unsorted()); + stringQuery.createQueryWithArguments(Sort.unsorted()); - stringQuery.neverCalled("applySorting"); + stringQuery.called("applySorting").times(1); } @Test // GH-3310 diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlDtoQueryTransformerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlDtoQueryTransformerUnitTests.java new file mode 100644 index 0000000000..4e0a7ead47 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlDtoQueryTransformerUnitTests.java @@ -0,0 +1,112 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.query; + +import static org.assertj.core.api.Assertions.*; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; + +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.provider.PersistenceProvider; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; + +/** + * Unit tests for {@link DtoProjectionTransformerDelegate}. + * + * @author Mark Paluch + */ +class EqlDtoQueryTransformerUnitTests { + + JpaQueryMethod method = getMethod("dtoProjection"); + EqlSortedQueryTransformer transformer = new EqlSortedQueryTransformer(Sort.unsorted(), null, + method.getResultProcessor().getReturnedType()); + + @Test // GH-3076 + void shouldTranslateSingleProjectionToDto() { + + JpaQueryEnhancer.EqlQueryParser parser = JpaQueryEnhancer.EqlQueryParser.parseQuery("SELECT p from Person p"); + + QueryTokenStream visit = transformer.visit(parser.getContext()); + + assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo( + "SELECT new org.springframework.data.jpa.repository.query.EqlDtoQueryTransformerUnitTests$MyRecord(p.foo, p.bar) from Person p"); + } + + @Test // GH-3076 + void shouldRewriteQueriesWithSubselect() { + + JpaQueryEnhancer.EqlQueryParser parser = JpaQueryEnhancer.EqlQueryParser + .parseQuery("select u from User u left outer join u.roles r where r in (select r from Role r)"); + + QueryTokenStream visit = transformer.visit(parser.getContext()); + + assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo( + "select new org.springframework.data.jpa.repository.query.EqlDtoQueryTransformerUnitTests$MyRecord(u.foo, u.bar) from User u left outer join u.roles r where r in (select r from Role r)"); + } + + @Test // GH-3076 + void shouldNotTranslateConstructorExpressionQuery() { + + JpaQueryEnhancer.EqlQueryParser parser = JpaQueryEnhancer.EqlQueryParser + .parseQuery("SELECT NEW String(p) from Person p"); + + QueryTokenStream visit = transformer.visit(parser.getContext()); + + assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo("SELECT NEW String(p) from Person p"); + } + + @Test + void shouldTranslatePropertySelectionToDto() { + + JpaQueryEnhancer.EqlQueryParser parser = JpaQueryEnhancer.EqlQueryParser + .parseQuery("SELECT p.foo, p.bar, sum(p.age) from Person p"); + + QueryTokenStream visit = transformer.visit(parser.getContext()); + + assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo( + "SELECT new org.springframework.data.jpa.repository.query.EqlDtoQueryTransformerUnitTests$MyRecord(p.foo, p.bar, sum(p.age)) from Person p"); + } + + private JpaQueryMethod getMethod(String name, Class... parameterTypes) { + + try { + Method method = MyRepo.class.getMethod(name, parameterTypes); + PersistenceProvider persistenceProvider = PersistenceProvider.HIBERNATE; + + return new JpaQueryMethod(method, new DefaultRepositoryMetadata(MyRepo.class), + new SpelAwareProxyProjectionFactory(), persistenceProvider); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + + interface MyRepo extends Repository { + + MyRecord dtoProjection(); + } + + record Person(String id) { + + } + + record MyRecord(String foo, String bar) { + + } +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlDtoQueryTransformerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlDtoQueryTransformerUnitTests.java new file mode 100644 index 0000000000..425d68fba1 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlDtoQueryTransformerUnitTests.java @@ -0,0 +1,112 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.query; + +import static org.assertj.core.api.Assertions.*; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; + +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.provider.PersistenceProvider; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; + +/** + * Unit tests for {@link DtoProjectionTransformerDelegate}. + * + * @author Mark Paluch + */ +class HqlDtoQueryTransformerUnitTests { + + JpaQueryMethod method = getMethod("dtoProjection"); + HqlSortedQueryTransformer transformer = new HqlSortedQueryTransformer(Sort.unsorted(), null, + method.getResultProcessor().getReturnedType()); + + @Test // GH-3076 + void shouldTranslateSingleProjectionToDto() { + + JpaQueryEnhancer.HqlQueryParser parser = JpaQueryEnhancer.HqlQueryParser.parseQuery("SELECT p from Person p"); + + QueryTokenStream visit = transformer.visit(parser.getContext()); + + assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo( + "SELECT new org.springframework.data.jpa.repository.query.HqlDtoQueryTransformerUnitTests$MyRecord(p.foo, p.bar) from Person p"); + } + + @Test // GH-3076 + void shouldRewriteQueriesWithSubselect() { + + JpaQueryEnhancer.HqlQueryParser parser = JpaQueryEnhancer.HqlQueryParser + .parseQuery("select u from User u left outer join u.roles r where r in (select r from Role r)"); + + QueryTokenStream visit = transformer.visit(parser.getContext()); + + assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo( + "select new org.springframework.data.jpa.repository.query.HqlDtoQueryTransformerUnitTests$MyRecord(u.foo, u.bar) from User u left outer join u.roles r where r in (select r from Role r)"); + } + + @Test // GH-3076 + void shouldNotTranslateConstructorExpressionQuery() { + + JpaQueryEnhancer.HqlQueryParser parser = JpaQueryEnhancer.HqlQueryParser + .parseQuery("SELECT NEW String(p) from Person p"); + + QueryTokenStream visit = transformer.visit(parser.getContext()); + + assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo("SELECT NEW String(p) from Person p"); + } + + @Test + void shouldTranslatePropertySelectionToDto() { + + JpaQueryEnhancer.HqlQueryParser parser = JpaQueryEnhancer.HqlQueryParser + .parseQuery("SELECT p.foo, p.bar, sum(p.age) from Person p"); + + QueryTokenStream visit = transformer.visit(parser.getContext()); + + assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo( + "SELECT new org.springframework.data.jpa.repository.query.HqlDtoQueryTransformerUnitTests$MyRecord(p.foo, p.bar, sum(p.age)) from Person p"); + } + + private JpaQueryMethod getMethod(String name, Class... parameterTypes) { + + try { + Method method = MyRepo.class.getMethod(name, parameterTypes); + PersistenceProvider persistenceProvider = PersistenceProvider.HIBERNATE; + + return new JpaQueryMethod(method, new DefaultRepositoryMetadata(MyRepo.class), + new SpelAwareProxyProjectionFactory(), persistenceProvider); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + + interface MyRepo extends Repository { + + MyRecord dtoProjection(); + } + + record Person(String id) { + + } + + record MyRecord(String foo, String bar) { + + } +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlDtoQueryTransformerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlDtoQueryTransformerUnitTests.java new file mode 100644 index 0000000000..ab13c719a1 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlDtoQueryTransformerUnitTests.java @@ -0,0 +1,132 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.query; + +import static org.assertj.core.api.Assertions.*; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; + +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.provider.PersistenceProvider; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; + +/** + * Unit tests for {@link DtoProjectionTransformerDelegate}. + * + * @author Mark Paluch + */ +class JpqlDtoQueryTransformerUnitTests { + + JpaQueryMethod method = getMethod("dtoProjection"); + JpqlSortedQueryTransformer transformer = new JpqlSortedQueryTransformer(Sort.unsorted(), null, + method.getResultProcessor().getReturnedType()); + + @Test // GH-3076 + void shouldTranslateSingleProjectionToDto() { + + JpaQueryEnhancer.JpqlQueryParser parser = JpaQueryEnhancer.JpqlQueryParser.parseQuery("SELECT p from Person p"); + + QueryTokenStream visit = transformer.visit(parser.getContext()); + + assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo( + "SELECT new org.springframework.data.jpa.repository.query.JpqlDtoQueryTransformerUnitTests$MyRecord(p.foo, p.bar) from Person p"); + } + +// @Test // GH-3076 +// void xxx() { +// +// JpaQueryMethod method = getMethod("dtoProjection2"); +// JpqlSortedQueryTransformer transformer = new JpqlSortedQueryTransformer(Sort.unsorted(), null, +// method.getResultProcessor().getReturnedType()); +// +// JpaQueryEnhancer.JpqlQueryParser parser = JpaQueryEnhancer.JpqlQueryParser.parseQuery("select u.foo, u.bar, count(r) from User u left outer join u.role r group by u"); +// +// QueryTokenStream visit = transformer.visit(parser.getContext()); +// +// assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo( +// "select new org.springframework.data.jpa.repository.query.JpqlDtoQueryTransformerUnitTests$MyRecord2(u.foo, u.bar, count(r)) from User u left outer join u.role r group by u"); +// } + + @Test // GH-3076 + void shouldRewriteQueriesWithSubselect() { + + JpaQueryEnhancer.JpqlQueryParser parser = JpaQueryEnhancer.JpqlQueryParser + .parseQuery("select u from User u left outer join u.roles r where r in (select r from Role r)"); + + QueryTokenStream visit = transformer.visit(parser.getContext()); + + assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo( + "select new org.springframework.data.jpa.repository.query.JpqlDtoQueryTransformerUnitTests$MyRecord(u.foo, u.bar) from User u left outer join u.roles r where r in (select r from Role r)"); + } + + @Test // GH-3076 + void shouldNotTranslateConstructorExpressionQuery() { + + JpaQueryEnhancer.JpqlQueryParser parser = JpaQueryEnhancer.JpqlQueryParser + .parseQuery("SELECT NEW String(p) from Person p"); + + QueryTokenStream visit = transformer.visit(parser.getContext()); + + assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo("SELECT NEW String(p) from Person p"); + } + + @Test + void shouldTranslatePropertySelectionToDto() { + + JpaQueryEnhancer.JpqlQueryParser parser = JpaQueryEnhancer.JpqlQueryParser + .parseQuery("SELECT p.foo, p.bar, sum(p.age) from Person p"); + + QueryTokenStream visit = transformer.visit(parser.getContext()); + + assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo( + "SELECT new org.springframework.data.jpa.repository.query.JpqlDtoQueryTransformerUnitTests$MyRecord(p.foo, p.bar, sum(p.age)) from Person p"); + } + + private JpaQueryMethod getMethod(String name, Class... parameterTypes) { + + try { + Method method = MyRepo.class.getMethod(name, parameterTypes); + PersistenceProvider persistenceProvider = PersistenceProvider.HIBERNATE; + + return new JpaQueryMethod(method, new DefaultRepositoryMetadata(MyRepo.class), + new SpelAwareProxyProjectionFactory(), persistenceProvider); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + + interface MyRepo extends Repository { + + MyRecord dtoProjection(); + MyRecord2 dtoProjection2(); + } + + record Person(String id) { + + } + + record MyRecord(String foo, String bar) { + + } + + record MyRecord2(String foo, String bar, Integer count) { + + } +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NativeJpaQueryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NativeJpaQueryUnitTests.java index 7a9cf35d1f..9522bf4859 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NativeJpaQueryUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NativeJpaQueryUnitTests.java @@ -65,15 +65,8 @@ void setUp() { @Test // GH-3546 void shouldApplySorting() { - NativeJpaQuery query = getQuery(TestRepo.class, "find", Sort.class); - String sql = query.getSortedQueryString(Sort.by("foo", "bar")); - - assertThat(sql).isEqualTo("SELECT e FROM Employee e order by e.foo asc, e.bar asc"); - } - - private NativeJpaQuery getQuery(Class repository, String method, Class... args) { - Method respositoryMethod = ReflectionUtils.findMethod(repository, method, args); - RepositoryMetadata repositoryMetadata = new DefaultRepositoryMetadata(repository); + Method respositoryMethod = ReflectionUtils.findMethod(TestRepo.class, "find", Sort.class); + RepositoryMetadata repositoryMetadata = new DefaultRepositoryMetadata(TestRepo.class); SpelAwareProxyProjectionFactory projectionFactory = mock(SpelAwareProxyProjectionFactory.class); QueryExtractor queryExtractor = mock(QueryExtractor.class); JpaQueryMethod queryMethod = new JpaQueryMethod(respositoryMethod, repositoryMetadata, projectionFactory, @@ -83,7 +76,9 @@ private NativeJpaQuery getQuery(Class repository, String method, Class... NativeJpaQuery query = new NativeJpaQuery(queryMethod, em, annotation.value(), annotation.countQuery(), QueryRewriter.IdentityQueryRewriter.INSTANCE, ValueExpressionDelegate.create()); - return query; + String sql = query.getSortedQueryString(Sort.by("foo", "bar"), queryMethod.getResultProcessor().getReturnedType()); + + assertThat(sql).isEqualTo("SELECT e FROM Employee e order by e.foo asc, e.bar asc"); } interface TestRepo extends Repository { diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/TupleConverterUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/TupleConverterUnitTests.java index 13dc550fb0..8682012fa4 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/TupleConverterUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/TupleConverterUnitTests.java @@ -18,14 +18,14 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; +import jakarta.persistence.Tuple; +import jakarta.persistence.TupleElement; + import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; -import jakarta.persistence.Tuple; -import jakarta.persistence.TupleElement; - import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -37,6 +37,7 @@ import org.springframework.data.jpa.repository.query.AbstractJpaQuery.TupleConverter; import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.core.RepositoryMetadata; import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; @@ -48,6 +49,8 @@ * * @author Oliver Gierke * @author Jens Schauder + * @author Christoph Strobl + * @author Mark Paluch * @soundtrack James Bay - Let it go (Chaos and the Calm) */ @ExtendWith(MockitoExtension.class) @@ -113,6 +116,91 @@ void findsValuesForAllVariantsSupportedByTheTuple() { softly.assertAll(); } + @Test // GH-3076 + void dealsWithNullsInArguments() { + + ReturnedType returnedType = ReturnedType.of(WithPC.class, DomainType.class, new SpelAwareProxyProjectionFactory()); + + doReturn(List.of(element, element, element)).when(tuple).getElements(); + when(tuple.get(eq(0))).thenReturn("one"); + when(tuple.get(eq(1))).thenReturn(null); + when(tuple.get(eq(2))).thenReturn(1); + + Object result = new TupleConverter(returnedType).convert(tuple); + assertThat(result).isInstanceOf(WithPC.class); + } + + @Test // GH-3076 + void fallsBackToCompatibleConstructor() { + + ReturnedType returnedType = spy( + ReturnedType.of(MultipleConstructors.class, DomainType.class, new SpelAwareProxyProjectionFactory())); + when(returnedType.isProjecting()).thenReturn(true); + when(returnedType.getInputProperties()).thenReturn(Arrays.asList("one", "two", "three")); + + doReturn(List.of(element, element, element)).when(tuple).getElements(); + when(tuple.get(eq(0))).thenReturn("one"); + when(tuple.get(eq(1))).thenReturn(null); + when(tuple.get(eq(2))).thenReturn(2); + + MultipleConstructors result = (MultipleConstructors) new TupleConverter(returnedType).convert(tuple); + + assertThat(result.one).isEqualTo("one"); + assertThat(result.two).isNull(); + assertThat(result.three).isEqualTo(2); + + reset(tuple); + + doReturn(List.of(element, element, element)).when(tuple).getElements(); + when(tuple.get(eq(0))).thenReturn("one"); + when(tuple.get(eq(1))).thenReturn(null); + when(tuple.get(eq(2))).thenReturn('a'); + + result = (MultipleConstructors) new TupleConverter(returnedType).convert(tuple); + + assertThat(result.one).isEqualTo("one"); + assertThat(result.two).isNull(); + assertThat(result.three).isEqualTo(97); + } + + @Test // GH-3076 + void acceptsConstructorWithCastableType() { + + ReturnedType returnedType = spy( + ReturnedType.of(MultipleConstructors.class, DomainType.class, new SpelAwareProxyProjectionFactory())); + when(returnedType.isProjecting()).thenReturn(true); + when(returnedType.getInputProperties()).thenReturn(Arrays.asList("one", "two", "three", "four")); + + doReturn(List.of(element, element, element, element)).when(tuple).getElements(); + when(tuple.get(eq(0))).thenReturn("one"); + when(tuple.get(eq(1))).thenReturn(null); + when(tuple.get(eq(2))).thenReturn((byte) 2); + when(tuple.get(eq(3))).thenReturn(2.1f); + + MultipleConstructors result = (MultipleConstructors) new TupleConverter(returnedType).convert(tuple); + + assertThat(result.one).isEqualTo("one"); + assertThat(result.two).isNull(); + assertThat(result.three).isEqualTo(2); + assertThat(result.four).isEqualTo(2, offset(0.1d)); + } + + @Test // GH-3076 + void failsForNonResolvableConstructor() { + + ReturnedType returnedType = spy( + ReturnedType.of(MultipleConstructors.class, DomainType.class, new SpelAwareProxyProjectionFactory())); + when(returnedType.isProjecting()).thenReturn(true); + when(returnedType.getInputProperties()).thenReturn(Arrays.asList("one", "two")); + + doReturn(List.of(element, element)).when(tuple).getElements(); + when(tuple.get(eq(0))).thenReturn(1); + when(tuple.get(eq(1))).thenReturn(null); + + assertThatIllegalStateException().isThrownBy(() -> new TupleConverter(returnedType).convert(tuple)) + .withMessageContaining("Cannot find compatible constructor for DTO projection"); + } + interface SampleRepository extends CrudRepository { String someMethod(); } @@ -177,4 +265,45 @@ public String getAlias() { } } } + + static class DomainType { + String one, two, three; + } + + static class WithPC { + String one; + String two; + long three; + + public WithPC(String one, String two, long three) { + this.one = one; + this.two = two; + this.three = three; + } + } + + static class MultipleConstructors { + String one; + String two; + long three; + double four; + + public MultipleConstructors(String one) { + this.one = one; + } + + public MultipleConstructors(String one, String two, long three) { + this.one = one; + this.two = two; + this.three = three; + } + + public MultipleConstructors(String one, String two, short three, double four) { + this.one = one; + this.two = two; + this.three = three; + this.four = four; + } + + } } 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 76896ece7a..1676f886a5 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 @@ -18,6 +18,8 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.QueryHint; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.util.Collection; import java.util.Date; import java.util.List; @@ -721,8 +723,36 @@ List findAllAndSortByFunctionResultNamedParameter(@Param("namedParameter @Query("select u from User u where u.firstname >= (select Min(u0.firstname) from User u0)") List findProjectionBySubselect(); + @Query("select u from User u") + List findRecordProjection(); + + @Query("select u from User u") + List findRecordProjection(Class projectionType); + + @Query("select u.firstname, u.lastname from User u") + List findMultiselectRecordProjection(); + + @UserRoleCountProjectingQuery + List dtoProjectionEntityAndAggregatedValue(); + + @UserRoleCountProjectingQuery + Page dtoProjectionEntityAndAggregatedValue(PageRequest page); + + @Query("select u as user, count(r) as roleCount from User u left outer join u.roles r group by u") + List interfaceProjectionEntityAndAggregatedValue(); + + @Query("select u as user, count(r) as roleCount from User u left outer join u.roles r group by u") + List> rawMapProjectionEntityAndAggregatedValue(); + + @UserRoleCountProjectingQuery + List findMultiselectRecordDynamicProjection(Class projectionType); + Window findBy(OffsetScrollPosition position); + @Retention(RetentionPolicy.RUNTIME) + @Query("select u, count(r) from User u left outer join u.roles r group by u") + @interface UserRoleCountProjectingQuery {} + interface RolesAndFirstname { String getFirstname(); @@ -747,4 +777,15 @@ interface IdOnly { int getId(); } + record UserExcerpt(String firstname, String lastname) { + + } + + record UserRoleCountDtoProjection(User user, Long roleCount) {} + + interface UserRoleCountInterfaceProjection { + User getUser(); + Long getRoleCount(); + } + } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByPredicateUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByPredicateUnitTests.java index f2c5e9f00c..8d2b159c79 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByPredicateUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByPredicateUnitTests.java @@ -20,6 +20,8 @@ import org.junit.jupiter.api.Test; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Order; +import org.springframework.data.jpa.domain.sample.User; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; /** * Unit tests for {@link FetchableFluentQueryByPredicate}. @@ -32,10 +34,13 @@ class FetchableFluentQueryByPredicateUnitTests { @SuppressWarnings({ "rawtypes", "unchecked" }) void multipleSortBy() { + JpaEntityInformationSupport entityInformation = new JpaEntityInformationSupportUnitTests.DummyJpaEntityInformation( + User.class); + Sort s1 = Sort.by(Order.by("s1")); Sort s2 = Sort.by(Order.by("s2")); - FetchableFluentQueryByPredicate f = new FetchableFluentQueryByPredicate(null, null, null, null, null, null, null, - null, null); + FetchableFluentQueryByPredicate f = new FetchableFluentQueryByPredicate(null, null, entityInformation, null, null, + null, null, null, null, new SpelAwareProxyProjectionFactory()); f = (FetchableFluentQueryByPredicate) f.sortBy(s1).sortBy(s2); assertThat(f.sort).isEqualTo(s1.and(s2)); } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutorUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutorUnitTests.java index 7962695b6a..b7995dec45 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutorUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutorUnitTests.java @@ -23,13 +23,13 @@ import java.sql.Date; import java.time.LocalDate; import java.util.List; -import java.util.Set; import java.util.stream.Stream; import org.hibernate.LazyInitializationException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; + import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -403,6 +403,16 @@ void findByFluentPredicateWithInterfaceBasedProjection() { .containsExactlyInAnyOrder(dave.getFirstname(), oliver.getFirstname()); } + @Test // GH-2327 + void findByFluentPredicateWithDtoProjection() { + + List users = predicateExecutor.findBy(user.firstname.contains("v"), + q -> q.as(UserProjectionDto.class).all()); + + assertThat(users).extracting(UserProjectionDto::firstname).containsExactlyInAnyOrder(dave.getFirstname(), + oliver.getFirstname()); + } + @Test // GH-2294 void findByFluentPredicateWithSortedInterfaceBasedProjection() { @@ -435,31 +445,6 @@ void existsByFluentPredicate() { assertThat(exists).isTrue(); } - @Test // GH-2294 - void fluentExamplesWithClassBasedDtosNotYetSupported() { - - class UserDto { - String firstname; - - public UserDto() {} - - public String getFirstname() { - return this.firstname; - } - - public void setFirstname(String firstname) { - this.firstname = firstname; - } - - public String toString() { - return "UserDto(firstname=" + this.getFirstname() + ")"; - } - } - - assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> predicateExecutor - .findBy(user.firstname.contains("v"), q -> q.as(UserDto.class).sortBy(Sort.by("firstname")).all())); - } - @Test // GH-2329 void findByFluentPredicateWithSimplePropertyPathsDoesntLoadUnrequestedPaths() { @@ -534,6 +519,9 @@ private interface UserProjectionInterfaceBased { String getFirstname(); - Set getRoles(); + String getLastname(); + } + + public record UserProjectionDto(String firstname, String lastname) { } } diff --git a/src/main/antora/modules/ROOT/pages/repositories/projections.adoc b/src/main/antora/modules/ROOT/pages/repositories/projections.adoc index 00a6a8c8ef..c5e113c8f4 100644 --- a/src/main/antora/modules/ROOT/pages/repositories/projections.adoc +++ b/src/main/antora/modules/ROOT/pages/repositories/projections.adoc @@ -36,6 +36,48 @@ When using <> with JPQL, you must use (Note the usage of a FQDN for the DTO type!) This JPQL expression can be used in `@Query` annotations as well where you define any named queries. As a workaround you may use named queries with `ResultSetMapping` or the Hibernate-specific javadoc:{hibernatejavadocurl}org.hibernate.query.ResultListTransformer[] +===== DTO Projection JPQL Query Rewriting + +JPQL queries allow selection of the root object, individual properties, and DTO objects through constructor expressions. +Using a constructor expression can quickly add a lot of text to a query and make it difficult to read the actual query. +Spring Data JPA can support you with your JPQL queries by introducing constructor expressions for your convenience. + +Consider the following queries: + +.Projection Queries +==== +[source,java] +---- +interface UserRepository extends Repository { + + @Query("SELECT u FROM USER u") <1> + List findByLastname(String lastname); + + @Query("SELECT u.firstname, u.lastname FROM USER u") <2> + List findMultipleColumnsByLastname(String lastname); +} + +record UserDto(String firstname, String lastname){} +---- + +<1> Selection of the top-level entity. +This query gets rewritten to `SELECT new UserDto(u.firstname, u.lastname) FROM USER u`. +<2> Multi-select of `firstname` and `lastname` properties. +This query gets rewritten to `SELECT new UserDto(u.firstname, u.lastname) FROM USER u`. +==== + +[WARNING] +==== +JPQL constructor expressions must not contain aliases for selected columns and query rewriting will not remove them for you. +While `SELECT u as user, count(u.roles) as roleCount FROM USER u …` is a valid query for interface-based projections that rely on column names from the returned `Tuple`, the same construct is invalid when requesting a DTO where it needs to be `SELECT u, count(u.roles) FROM USER u …`. + +Some persistence providers may be lenient about this, others not. +==== + +Repository query methods that return a DTO projection type (a Java type outside the domain type hierarchy) are subject for query rewriting. +If an `@Query`-annotated query already uses constructor expressions, then Spring Data backs off and doesn't apply DTO constructor expression rewriting. + +Make sure that your DTO types provide an all-args constructor for the projection, otherwise the query will fail. + ==== Native Queries When using <>, their usage requires slightly more consideration depending on your :