From 6fe1b2ba38a058f5809cad97f98660254705ba0b Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Tue, 24 Sep 2024 12:18:03 +0200 Subject: [PATCH] Add support for class-based DTOs for Fluent API. Also, interface-based projections now use Tuple queries to consistently use tuple-based queries. Closes #2327 --- .../repository/query/AbstractJpaQuery.java | 2 +- .../data/jpa/repository/query/QueryUtils.java | 3 +- .../FetchableFluentQueryByPredicate.java | 147 ++++++++++++++---- .../FetchableFluentQueryBySpecification.java | 25 +-- .../support/FluentQuerySupport.java | 19 ++- .../jpa/repository/support/JakartaTuple.java | 85 ++++++++++ .../JpaMetamodelEntityInformation.java | 8 +- .../data/jpa/repository/support/Querydsl.java | 6 +- .../support/QuerydslJpaPredicateExecutor.java | 10 +- .../support/SimpleJpaRepository.java | 71 ++++++++- .../support/SpringDataJpaQuery.java | 92 +++++++++++ .../jpa/repository/UserRepositoryTests.java | 126 ++++++++------- ...chableFluentQueryByPredicateUnitTests.java | 9 +- ...QuerydslJpaPredicateExecutorUnitTests.java | 42 ++--- 14 files changed, 487 insertions(+), 158 deletions(-) create mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JakartaTuple.java create mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SpringDataJpaQuery.java 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 909053364b..f797284c5a 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 @@ -305,7 +305,7 @@ 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; 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..335b6e33bb 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,17 +16,18 @@ package org.springframework.data.jpa.repository.support; import jakarta.persistence.EntityManager; -import jakarta.persistence.Query; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; import java.util.function.BiFunction; import java.util.function.Function; 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; @@ -36,10 +37,18 @@ 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,41 @@ */ 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, + 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, + 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 +112,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 +122,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,19 +132,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, + return new FetchableFluentQueryByPredicate<>(entityPath, predicate, entityInformation, resultType, sort, limit, + mergeProperties(properties), finder, scrollQueryFactory, pagedFinder, countOperation, existsOperation, + entityManager, projectionFactory); } @@ -163,7 +180,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 +210,35 @@ 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() && !inputProperties.isEmpty()) { + + Collection requiredSelection; + if (scrollPosition instanceof KeysetScrollPosition && returnedType.getReturnedType().isInterface()) { + requiredSelection = new LinkedHashSet<>(inputProperties); + sort.forEach(it -> requiredSelection.add(it.getProperty())); + entityInformation.getIdAttributeNames().forEach(requiredSelection::add); + } 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 +247,6 @@ public boolean exists() { if (limit != 0) { query.limit(limit); } - - return query; } private Page readPage(Pageable pageable) { @@ -233,23 +278,57 @@ 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); } } + 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..cf6c29fdea --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JakartaTuple.java @@ -0,0 +1,85 @@ +/* + * 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 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; + +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 + public R accept(Visitor v, 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) { + FactoryExpression c = (FactoryExpression) obj; + 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..b3faa0fb5c 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 @@ -78,9 +78,9 @@ 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); + case ECLIPSELINK -> new SpringDataJpaQuery<>(em, EclipseLinkTemplates.DEFAULT); + case HIBERNATE -> new SpringDataJpaQuery<>(em, HQLTemplates.DEFAULT); + default -> new SpringDataJpaQuery<>(em); }; } 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..80d2325076 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,16 +30,19 @@ 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; import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; 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,6 +51,7 @@ 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; @@ -60,9 +64,11 @@ 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,63 @@ 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() && !inputProperties.isEmpty()) { + 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() && !inputProperties.isEmpty()) { + + Collection requiredSelection; + + if (scrollPosition instanceof KeysetScrollPosition && returnedType.getReturnedType().isInterface()) { + requiredSelection = new LinkedHashSet<>(inputProperties); + sort.stream().map(Sort.Order::getProperty).forEach(requiredSelection::add); + entityInformation.getIdAttributeNames().forEach(requiredSelection::add); + } 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..5d747dea4c --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SpringDataJpaQuery.java @@ -0,0 +1,92 @@ +/* + * 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; + +/** + * @author Mark Paluch + */ +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/UserRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java index caf40e1d99..e23f383fb1 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() { @@ -3455,6 +3457,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/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) { } }