diff --git a/pom.xml b/pom.xml index c77686453f..ef398792fa 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-jpa-parent - 3.1.0-SNAPSHOT + 3.1.0-GH-2878-SNAPSHOT pom Spring Data JPA Parent diff --git a/spring-data-envers/pom.xml b/spring-data-envers/pom.xml index db915d7c3b..1b1fbf19a1 100755 --- a/spring-data-envers/pom.xml +++ b/spring-data-envers/pom.xml @@ -5,12 +5,12 @@ org.springframework.data spring-data-envers - 3.1.0-SNAPSHOT + 3.1.0-GH-2878-SNAPSHOT org.springframework.data spring-data-jpa-parent - 3.1.0-SNAPSHOT + 3.1.0-GH-2878-SNAPSHOT ../pom.xml diff --git a/spring-data-jpa-distribution/pom.xml b/spring-data-jpa-distribution/pom.xml index a5cb2f09b5..5ba4dbed1f 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.1.0-SNAPSHOT + 3.1.0-GH-2878-SNAPSHOT ../pom.xml diff --git a/spring-data-jpa/pom.xml b/spring-data-jpa/pom.xml index 27313e9e3c..c6408dd0ad 100644 --- a/spring-data-jpa/pom.xml +++ b/spring-data-jpa/pom.xml @@ -6,7 +6,7 @@ org.springframework.data spring-data-jpa - 3.1.0-SNAPSHOT + 3.1.0-GH-2878-SNAPSHOT Spring Data JPA Spring Data module for JPA repositories. @@ -15,7 +15,7 @@ org.springframework.data spring-data-jpa-parent - 3.1.0-SNAPSHOT + 3.1.0-GH-2878-SNAPSHOT ../pom.xml diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/CollectionUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/CollectionUtils.java new file mode 100644 index 0000000000..582d30eb1d --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/CollectionUtils.java @@ -0,0 +1,61 @@ +/* + * Copyright 2023 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 java.util.List; + +/** + * Utility methods to obtain sublists. + * + * @author Mark Paluch + */ +class CollectionUtils { + + /** + * Return the first {@code count} items from the list. + * + * @param count the number of first elements to be included in the returned list. + * @param list must not be {@literal null} + * @return the returned sublist if the {@code list} is greater {@code count}. + * @param + */ + public static List getFirst(int count, List list) { + + if (count > 0 && list.size() > count) { + return list.subList(0, count); + } + + return list; + } + + /** + * Return the last {@code count} items from the list. + * + * @param count the number of last elements to be included in the returned list. + * @param list must not be {@literal null} + * @return the returned sublist if the {@code list} is greater {@code count}. + * @param + */ + public static List getLast(int count, List list) { + + if (count > 0 && list.size() > count) { + return list.subList(list.size() - (count), list.size()); + } + + return list; + } + +} 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 new file mode 100644 index 0000000000..5dbf036b4b --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java @@ -0,0 +1,83 @@ +/* + * Copyright 2023 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 jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; +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; +import org.springframework.data.jpa.repository.support.JpaEntityInformation; +import org.springframework.data.repository.query.ReturnedType; +import org.springframework.data.repository.query.parser.PartTree; +import org.springframework.lang.Nullable; + +/** + * Extension to {@link JpaQueryCreator} to create queries considering {@link KeysetScrollPosition keyset scrolling}. + * + * @author Mark Paluch + * @since 3.1 + */ +class JpaKeysetScrollQueryCreator extends JpaQueryCreator { + + private final JpaEntityInformation entityInformation; + private final KeysetScrollPosition scrollPosition; + + public JpaKeysetScrollQueryCreator(PartTree tree, ReturnedType type, CriteriaBuilder builder, + ParameterMetadataProvider provider, JpaEntityInformation entityInformation, + KeysetScrollPosition scrollPosition) { + super(tree, type, builder, provider); + this.entityInformation = entityInformation; + this.scrollPosition = scrollPosition; + } + + @Override + protected CriteriaQuery complete(@Nullable Predicate predicate, Sort sort, CriteriaQuery query, + CriteriaBuilder builder, Root root) { + + KeysetScrollSpecification keysetSpec = new KeysetScrollSpecification<>(scrollPosition, sort, + entityInformation); + Predicate keysetPredicate = keysetSpec.createPredicate(root, builder); + + CriteriaQuery queryToUse = super.complete(predicate, keysetSpec.sort(), query, builder, root); + + if (keysetPredicate != null) { + if (queryToUse.getRestriction() != null) { + return queryToUse.where(builder.and(queryToUse.getRestriction(), keysetPredicate)); + } + return queryToUse.where(keysetPredicate); + } + + return queryToUse; + } + + @Override + 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; + } +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java index b6def0ee40..431d1b7111 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java @@ -118,7 +118,6 @@ public List> getParameterExpressions() { @Override protected Predicate create(Part part, Iterator iterator) { - return toPredicate(part, root); } @@ -158,9 +157,10 @@ protected CriteriaQuery complete(@Nullable Predicate predicate if (returnedType.needsCustomConstruction()) { + Collection requiredSelection = getRequiredSelection(sort, returnedType); List> selections = new ArrayList<>(); - for (String property : returnedType.getInputProperties()) { + for (String property : requiredSelection) { PropertyPath path = PropertyPath.from(property, returnedType.getDomainType()); selections.add(toExpressionRecursively(root, path, true).alias(property)); @@ -195,6 +195,10 @@ protected CriteriaQuery complete(@Nullable Predicate predicate return predicate == null ? select : select.where(predicate); } + Collection getRequiredSelection(Sort sort, ReturnedType returnedType) { + return returnedType.getInputProperties(); + } + /** * Creates a {@link Predicate} from the given {@link Part}. * diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryExecution.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryExecution.java index 6ee5ec163a..618ba586c2 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryExecution.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryExecution.java @@ -15,23 +15,25 @@ */ package org.springframework.data.jpa.repository.query; -import java.lang.reflect.Method; -import java.util.Collection; -import java.util.List; -import java.util.Optional; - import jakarta.persistence.EntityManager; import jakarta.persistence.NoResultException; import jakarta.persistence.Query; import jakarta.persistence.StoredProcedureQuery; +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.support.ConfigurableConversionService; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Slice; import org.springframework.data.domain.SliceImpl; +import org.springframework.data.domain.Sort; import org.springframework.data.jpa.provider.PersistenceProvider; import org.springframework.data.repository.core.support.SurroundingTransactionDetectorMethodInterceptor; import org.springframework.data.support.PageableExecutionUtils; @@ -128,6 +130,33 @@ protected Object doExecute(AbstractJpaQuery query, JpaParametersParameterAccesso } } + /** + * Executes the query to return a {@link org.springframework.data.domain.Window} of entities. + * + * @author Mark Paluch + * @since 3.1 + */ + static class ScrollExecution extends JpaQueryExecution { + + private final Sort sort; + private final ScrollDelegate delegate; + + ScrollExecution(Sort sort, ScrollDelegate delegate) { + this.sort = sort; + this.delegate = delegate; + } + + @Override + @SuppressWarnings("unchecked") + protected Object doExecute(AbstractJpaQuery query, JpaParametersParameterAccessor accessor) { + + ScrollPosition scrollPosition = accessor.getScrollPosition(); + Query scrollQuery = query.createQuery(accessor); + + return delegate.scroll(scrollQuery, sort.and(accessor.getSort()), scrollPosition); + } + } + /** * Executes the query to return a {@link Slice} of entities. * diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryFactory.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryFactory.java index 8a91b5ea5c..ef95696cb6 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryFactory.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryFactory.java @@ -18,6 +18,7 @@ import jakarta.persistence.EntityManager; import org.springframework.data.jpa.repository.QueryRewriter; +import org.springframework.data.repository.query.QueryCreationException; import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; import org.springframework.data.repository.query.RepositoryQuery; import org.springframework.expression.spel.standard.SpelExpressionParser; @@ -49,6 +50,10 @@ AbstractJpaQuery fromMethodWithQueryString(JpaQueryMethod method, EntityManager @Nullable String countQueryString, QueryRewriter queryRewriter, QueryMethodEvaluationContextProvider evaluationContextProvider) { + if (method.isScrollQuery()) { + throw QueryCreationException.create(method, "Scroll queries are not supported using String-based queries"); + } + return method.isNativeQuery() ? new NativeJpaQuery(method, em, queryString, countQueryString, queryRewriter, evaluationContextProvider, PARSER) @@ -64,6 +69,11 @@ AbstractJpaQuery fromMethodWithQueryString(JpaQueryMethod method, EntityManager * @return */ public StoredProcedureJpaQuery fromProcedureAnnotation(JpaQueryMethod method, EntityManager em) { + + if (method.isScrollQuery()) { + throw QueryCreationException.create(method, "Scroll queries are not supported using stored procedures"); + } + return new StoredProcedureJpaQuery(method, em); } } 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 new file mode 100644 index 0000000000..0ce89ff114 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java @@ -0,0 +1,197 @@ +/* + * Copyright 2023 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 java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.data.domain.KeysetScrollPosition.Direction; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Order; +import org.springframework.lang.Nullable; + +/** + * Delegate for keyset scrolling. + * + * @author Mark Paluch + * @since 3.1 + */ +public class KeysetScrollDelegate { + + private static final KeysetScrollDelegate forward = new KeysetScrollDelegate(); + private static final KeysetScrollDelegate reverse = new ReverseKeysetScrollDelegate(); + + /** + * Factory method to obtain the right {@link KeysetScrollDelegate}. + * + * @param direction + * @return + */ + public static KeysetScrollDelegate of(Direction direction) { + return direction == Direction.Forward ? forward : reverse; + } + + @Nullable + public P createPredicate(KeysetScrollPosition keyset, Sort sort, QueryStrategy strategy) { + + Map keysetValues = keyset.getKeys(); + + // first query doesn't come with a keyset + if (keysetValues.isEmpty()) { + return null; + } + + List

or = new ArrayList<>(); + int i = 0; + + // progressive query building to reconstruct a query matching sorting rules + for (Order order : sort) { + + if (!keysetValues.containsKey(order.getProperty())) { + throw new IllegalStateException(String + .format("KeysetScrollPosition does not contain all keyset values. Missing key: %s", order.getProperty())); + } + + List

sortConstraint = new ArrayList<>(); + + int j = 0; + for (Order inner : sort) { + + E propertyExpression = strategy.createExpression(inner.getProperty()); + Object o = keysetValues.get(inner.getProperty()); + + if (j >= i) { // tail segment + + sortConstraint.add(strategy.compare(inner, propertyExpression, o)); + break; + } + + sortConstraint.add(strategy.compare(propertyExpression, o)); + j++; + } + + if (!sortConstraint.isEmpty()) { + or.add(strategy.and(sortConstraint)); + } + + i++; + } + + if (or.isEmpty()) { + return null; + } + + return strategy.or(or); + } + + protected Sort getSortOrders(Sort sort) { + return sort; + } + + @SuppressWarnings("unchecked") + protected List postProcessResults(List result) { + return result; + } + + protected List getResultWindow(List list, int limit) { + return CollectionUtils.getFirst(limit, list); + } + + /** + * Reverse scrolling variant applying {@link Direction#Backward}. In reverse scrolling, we need to flip directions for + * the actual query so that we do not get everything from the top position and apply the limit but rather flip the + * sort direction, apply the limit and then reverse the result to restore the actual sort order. + */ + private static class ReverseKeysetScrollDelegate extends KeysetScrollDelegate { + + protected Sort getSortOrders(Sort sort) { + + List orders = new ArrayList<>(); + for (Order order : sort) { + orders.add(new Order(order.isAscending() ? Sort.Direction.DESC : Sort.Direction.ASC, order.getProperty())); + } + + return Sort.by(orders); + } + + @Override + protected List postProcessResults(List result) { + Collections.reverse(result); + return result; + } + + @Override + protected List getResultWindow(List list, int limit) { + return CollectionUtils.getLast(limit, list); + } + } + + /** + * Adapter to construct scroll queries. + * + * @param property path expression type. + * @param

predicate type. + */ + public interface QueryStrategy { + + /** + * Create an expression object from the given {@code property} path. + * + * @param property must not be {@literal null}. + * @return + */ + E createExpression(String property); + + /** + * Create a comparison object according to the {@link Order}. + * + * @param order must not be {@literal null}. + * @param propertyExpression must not be {@literal null}. + * @param value + * @return + */ + P compare(Order order, E propertyExpression, Object value); + + /** + * Create an equals-comparison object. + * + * @param propertyExpression must not be {@literal null}. + * @param value + * @return + */ + P compare(E propertyExpression, @Nullable Object value); + + /** + * AND-combine the {@code intermediate} predicates. + * + * @param intermediate + * @return + */ + P and(List

intermediate); + + /** + * OR-combine the {@code intermediate} predicates. + * + * @param intermediate + * @return + */ + P or(List

intermediate); + } + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java new file mode 100644 index 0000000000..cfec4f06df --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java @@ -0,0 +1,128 @@ +/* + * Copyright 2023 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 jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Expression; +import jakarta.persistence.criteria.From; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; + +import java.util.List; + +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Order; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.query.KeysetScrollDelegate.QueryStrategy; +import org.springframework.data.jpa.repository.support.JpaEntityInformation; +import org.springframework.data.mapping.PropertyPath; +import org.springframework.lang.Nullable; + +/** + * {@link Specification} to create scroll queries using keyset-scrolling. + * + * @author Mark Paluch + * @author Christoph Strobl + * @since 3.1 + */ +public record KeysetScrollSpecification (KeysetScrollPosition position, Sort sort, + JpaEntityInformation entity) implements Specification { + + public KeysetScrollSpecification(KeysetScrollPosition position, Sort sort, JpaEntityInformation entity) { + + this.position = position; + this.entity = entity; + this.sort = createSort(position, sort, entity); + } + + /** + * Create a {@link Sort} object to be used with the actual query. + * + * @param position must not be {@literal null}. + * @param sort must not be {@literal null}. + * @param entity must not be {@literal null}. + * @return + */ + public static Sort createSort(KeysetScrollPosition position, Sort sort, JpaEntityInformation entity) { + + KeysetScrollDelegate delegate = KeysetScrollDelegate.of(position.getDirection()); + + Sort sortToUse; + if (entity.hasCompositeId()) { + sortToUse = sort.and(Sort.by(entity.getIdAttributeNames().toArray(new String[0]))); + } else { + sortToUse = sort.and(Sort.by(entity.getRequiredIdAttribute().getName())); + } + + return delegate.getSortOrders(sortToUse); + } + + @Override + public Predicate toPredicate(Root root, CriteriaQuery query, CriteriaBuilder criteriaBuilder) { + return createPredicate(root, criteriaBuilder); + } + + @Nullable + public Predicate createPredicate(Root root, CriteriaBuilder criteriaBuilder) { + + KeysetScrollDelegate delegate = KeysetScrollDelegate.of(position.getDirection()); + return delegate.createPredicate(position, sort, new JpaQueryStrategy(root, criteriaBuilder)); + } + + @SuppressWarnings("rawtypes") + private static class JpaQueryStrategy implements QueryStrategy, Predicate> { + + private final From from; + private final CriteriaBuilder cb; + + public JpaQueryStrategy(From from, CriteriaBuilder cb) { + + this.from = from; + this.cb = cb; + } + + @Override + public Expression createExpression(String property) { + + PropertyPath path = PropertyPath.from(property, from.getJavaType()); + return QueryUtils.toExpressionRecursively(from, path); + } + + @Override + public Predicate compare(Order order, Expression propertyExpression, Object value) { + + return order.isAscending() ? cb.greaterThan(propertyExpression, (Comparable) value) + : cb.lessThan(propertyExpression, (Comparable) value); + } + + @Override + public Predicate compare(Expression propertyExpression, @Nullable Object value) { + return value == null ? cb.isNull(propertyExpression) : cb.equal(propertyExpression, value); + } + + @Override + public Predicate and(List intermediate) { + return cb.and(intermediate.toArray(new Predicate[0])); + } + + @Override + public Predicate or(List intermediate) { + return cb.or(intermediate.toArray(new Predicate[0])); + } + } +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NamedQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NamedQuery.java index 45442a4c49..311f47eafd 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NamedQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NamedQuery.java @@ -145,6 +145,10 @@ public static RepositoryQuery lookupFrom(JpaQueryMethod method, EntityManager em return null; } + if (method.isScrollQuery()) { + throw QueryCreationException.create(method, "Scroll queries are not supported using String-based queries"); + } + try { RepositoryQuery query = new NamedQuery(method, em); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java index 8bfbb72a49..78cfde34a5 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java @@ -15,19 +15,25 @@ */ package org.springframework.data.jpa.repository.query; -import java.util.List; - import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceUnitUtil; import jakarta.persistence.Query; import jakarta.persistence.TypedQuery; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaQuery; +import java.util.List; + +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.data.domain.OffsetScrollPosition; +import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.query.JpaParameters.JpaParameter; import org.springframework.data.jpa.repository.query.JpaQueryExecution.DeleteExecution; import org.springframework.data.jpa.repository.query.JpaQueryExecution.ExistsExecution; +import org.springframework.data.jpa.repository.query.JpaQueryExecution.ScrollExecution; import org.springframework.data.jpa.repository.query.ParameterMetadataProvider.ParameterMetadata; +import org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformation; import org.springframework.data.repository.query.ResultProcessor; import org.springframework.data.repository.query.ReturnedType; import org.springframework.data.repository.query.parser.Part; @@ -55,6 +61,7 @@ public class PartTreeJpaQuery extends AbstractJpaQuery { private final QueryPreparer countQuery; private final EntityManager em; private final EscapeCharacter escape; + private final JpaMetamodelEntityInformation entityInformation; /** * Creates a new {@link PartTreeJpaQuery}. @@ -79,10 +86,14 @@ public class PartTreeJpaQuery extends AbstractJpaQuery { this.em = em; this.escape = escape; - Class domainClass = method.getEntityInformation().getJavaType(); this.parameters = method.getParameters(); - boolean recreationRequired = parameters.hasDynamicProjection() || parameters.potentiallySortsDynamically(); + Class domainClass = method.getEntityInformation().getJavaType(); + PersistenceUnitUtil persistenceUnitUtil = em.getEntityManagerFactory().getPersistenceUnitUtil(); + this.entityInformation = new JpaMetamodelEntityInformation<>(domainClass, em.getMetamodel(), persistenceUnitUtil); + + boolean recreationRequired = parameters.hasDynamicProjection() || parameters.potentiallySortsDynamically() + || method.isScrollQuery(); try { @@ -111,7 +122,9 @@ public TypedQuery doCreateCountQuery(JpaParametersParameterAccessor access @Override protected JpaQueryExecution getExecution() { - if (this.tree.isDelete()) { + if (this.getQueryMethod().isScrollQuery()) { + return new ScrollExecution(this.tree.getSort(), new ScrollDelegate<>(entityInformation)); + } else if (this.tree.isDelete()) { return new DeleteExecution(em); } else if (this.tree.isExistsProjection()) { return new ExistsExecution(); @@ -228,7 +241,11 @@ public Query createQuery(JpaParametersParameterAccessor accessor) { TypedQuery query = createQuery(criteriaQuery); - return restrictMaxResultsIfNecessary(invokeBinding(parameterBinder, query, accessor, this.metadataCache)); + ScrollPosition scrollPosition = accessor.getParameters().hasScrollPositionParameter() + ? accessor.getScrollPosition() + : null; + return restrictMaxResultsIfNecessary(invokeBinding(parameterBinder, query, accessor, this.metadataCache), + scrollPosition); } /** @@ -236,10 +253,14 @@ public Query createQuery(JpaParametersParameterAccessor accessor) { * limited. */ @SuppressWarnings("ConstantConditions") - private Query restrictMaxResultsIfNecessary(Query query) { + private Query restrictMaxResultsIfNecessary(Query query, @Nullable ScrollPosition scrollPosition) { if (tree.isLimiting()) { + if (scrollPosition instanceof OffsetScrollPosition offset) { + query.setFirstResult(Math.toIntExact(offset.getOffset())); + } + if (query.getMaxResults() != Integer.MAX_VALUE) { /* * In order to return the correct results, we have to adjust the first result offset to be returned if: @@ -298,6 +319,10 @@ protected JpaQueryCreator createCreator(@Nullable JpaParametersParameterAccessor returnedType = processor.getReturnedType(); } + if (accessor != null && accessor.getScrollPosition()instanceof KeysetScrollPosition keyset) { + return new JpaKeysetScrollQueryCreator(tree, returnedType, builder, provider, entityInformation, keyset); + } + return new JpaQueryCreator(tree, returnedType, builder, provider); } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ScrollDelegate.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ScrollDelegate.java new file mode 100644 index 0000000000..94cc960a9b --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ScrollDelegate.java @@ -0,0 +1,105 @@ +/* + * Copyright 2023 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 jakarta.persistence.Query; + +import java.util.List; +import java.util.Map; +import java.util.function.IntFunction; + +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.data.domain.KeysetScrollPosition.Direction; +import org.springframework.data.domain.OffsetScrollPosition; +import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Order; +import org.springframework.data.domain.Window; +import org.springframework.data.jpa.repository.support.JpaEntityInformation; +import org.springframework.util.Assert; + +/** + * Delegate to run {@link ScrollPosition scroll queries} and create result {@link Window}. + * + * @author Mark Paluch + * @since 3.1 + */ +public class ScrollDelegate { + + private final JpaEntityInformation entity; + + protected ScrollDelegate(JpaEntityInformation entity) { + this.entity = entity; + } + + /** + * Run the {@link Query} and return a scroll {@link Window}. + * + * @param query must not be {@literal null}. + * @param sort must not be {@literal null}. + * @param scrollPosition must not be {@literal null}. + * @return the scroll {@link Window}. + */ + @SuppressWarnings("unchecked") + public Window scroll(Query query, Sort sort, ScrollPosition scrollPosition) { + + Assert.notNull(scrollPosition, "ScrollPosition must not be null"); + + int limit = query.getMaxResults(); + if (limit > 0 && limit != Integer.MAX_VALUE) { + query = query.setMaxResults(limit + 1); + } + + List result = query.getResultList(); + + if (scrollPosition instanceof KeysetScrollPosition keyset) { + return createWindow(sort, limit, keyset.getDirection(), entity, result); + } + + if (scrollPosition instanceof OffsetScrollPosition offset) { + return createWindow(result, limit, OffsetScrollPosition.positionFunction(offset.getOffset())); + } + + throw new UnsupportedOperationException("ScrollPosition " + scrollPosition + " not supported"); + } + + private static Window createWindow(Sort sort, int limit, Direction direction, + JpaEntityInformation entity, List result) { + + KeysetScrollDelegate delegate = KeysetScrollDelegate.of(direction); + List resultsToUse = delegate.postProcessResults(result); + + IntFunction positionFunction = value -> { + + T object = result.get(value); + Map keys = entity.getKeyset(sort.stream().map(Order::getProperty).toList(), object); + + return KeysetScrollPosition.of(keys); + }; + + return Window.from(delegate.getResultWindow(resultsToUse, limit), positionFunction, hasMoreElements(result, limit)); + } + + private static Window createWindow(List result, int limit, + IntFunction positionFunction) { + return Window.from(CollectionUtils.getFirst(limit, result), positionFunction, hasMoreElements(result, limit)); + } + + private static boolean hasMoreElements(List result, int limit) { + return !result.isEmpty() && result.size() > limit; + } + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByExample.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByExample.java deleted file mode 100644 index d1ddea66ba..0000000000 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByExample.java +++ /dev/null @@ -1,206 +0,0 @@ -/* - * Copyright 2021-2023 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.TypedQuery; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.function.Function; -import java.util.stream.Stream; - -import org.springframework.dao.IncorrectResultSizeDataAccessException; -import org.springframework.data.domain.Example; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.data.jpa.repository.query.EscapeCharacter; -import org.springframework.data.jpa.support.PageableUtils; -import org.springframework.data.repository.query.FluentQuery.FetchableFluentQuery; -import org.springframework.data.support.PageableExecutionUtils; -import org.springframework.util.Assert; - -/** - * Immutable implementation of {@link FetchableFluentQuery} based on Query by {@link Example}. All methods that return a - * {@link FetchableFluentQuery} will return a new instance, not the original. - * - * @param Domain type - * @param Result type - * @author Greg Turnquist - * @author Mark Paluch - * @author Jens Schauder - * @author J.R. Onyschak - * @since 2.6 - */ -class FetchableFluentQueryByExample extends FluentQuerySupport implements FetchableFluentQuery { - - private final Example example; - private final Function> finder; - private final Function, Long> countOperation; - private final Function, Boolean> existsOperation; - private final EntityManager entityManager; - private final EscapeCharacter escapeCharacter; - - public FetchableFluentQueryByExample(Example example, Function> finder, - Function, Long> countOperation, Function, Boolean> existsOperation, - EntityManager entityManager, EscapeCharacter escapeCharacter) { - this(example, example.getProbeType(), (Class) example.getProbeType(), Sort.unsorted(), Collections.emptySet(), - finder, countOperation, existsOperation, entityManager, escapeCharacter); - } - - private FetchableFluentQueryByExample(Example example, Class entityType, Class returnType, Sort sort, - Collection properties, Function> finder, Function, Long> countOperation, - Function, Boolean> existsOperation, EntityManager entityManager, EscapeCharacter escapeCharacter) { - - super(returnType, sort, properties, entityType); - this.example = example; - this.finder = finder; - this.countOperation = countOperation; - this.existsOperation = existsOperation; - this.entityManager = entityManager; - this.escapeCharacter = escapeCharacter; - } - - @Override - public FetchableFluentQuery sortBy(Sort sort) { - - Assert.notNull(sort, "Sort must not be null"); - - return new FetchableFluentQueryByExample<>(example, entityType, resultType, this.sort.and(sort), properties, finder, - countOperation, existsOperation, entityManager, escapeCharacter); - } - - @Override - 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 FetchableFluentQueryByExample<>(example, entityType, resultType, sort, properties, finder, - countOperation, existsOperation, entityManager, escapeCharacter); - } - - @Override - public FetchableFluentQuery project(Collection properties) { - - return new FetchableFluentQueryByExample<>(example, entityType, resultType, sort, mergeProperties(properties), - finder, countOperation, existsOperation, entityManager, escapeCharacter); - } - - @Override - public R oneValue() { - - TypedQuery limitedQuery = createSortedAndProjectedQuery(); - limitedQuery.setMaxResults(2); // Never need more than 2 values - - List results = limitedQuery.getResultList(); - - if (results.size() > 1) { - throw new IncorrectResultSizeDataAccessException(1); - } - - return results.isEmpty() ? null : getConversionFunction().apply(results.get(0)); - } - - @Override - public R firstValue() { - - TypedQuery limitedQuery = createSortedAndProjectedQuery(); - limitedQuery.setMaxResults(1); // Never need more than 1 value - - List results = limitedQuery.getResultList(); - - return results.isEmpty() ? null : getConversionFunction().apply(results.get(0)); - } - - @Override - public List all() { - - List resultList = createSortedAndProjectedQuery().getResultList(); - - return convert(resultList); - } - - @Override - public Page page(Pageable pageable) { - return pageable.isUnpaged() ? new PageImpl<>(all()) : readPage(pageable); - } - - @Override - public Stream stream() { - - return createSortedAndProjectedQuery() // - .getResultStream() // - .map(getConversionFunction()); - } - - @Override - public long count() { - return countOperation.apply(example); - } - - @Override - public boolean exists() { - return existsOperation.apply(example); - } - - private Page readPage(Pageable pageable) { - - TypedQuery pagedQuery = createSortedAndProjectedQuery(); - - if (pageable.isPaged()) { - pagedQuery.setFirstResult(PageableUtils.getOffsetAsInteger(pageable)); - pagedQuery.setMaxResults(pageable.getPageSize()); - } - - List paginatedResults = convert(pagedQuery.getResultList()); - - return PageableExecutionUtils.getPage(paginatedResults, pageable, () -> countOperation.apply(example)); - } - - private TypedQuery createSortedAndProjectedQuery() { - - TypedQuery query = finder.apply(sort); - - if (!properties.isEmpty()) { - query.setHint(EntityGraphFactory.HINT, EntityGraphFactory.create(entityManager, entityType, properties)); - } - - return query; - } - - private List convert(List resultList) { - - Function conversionFunction = getConversionFunction(); - List mapped = new ArrayList<>(resultList.size()); - - for (S s : resultList) { - mapped.add(conversionFunction.apply(s)); - } - return mapped; - } - - private Function getConversionFunction() { - return getConversionFunction(example.getProbeType(), resultType); - } - -} 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 b70b3b4d42..1ac5affdea 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,6 +16,7 @@ package org.springframework.data.jpa.repository.support; import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; import java.util.ArrayList; import java.util.Collection; @@ -29,7 +30,10 @@ 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.ScrollDelegate; import org.springframework.data.repository.query.FluentQuery.FetchableFluentQuery; import org.springframework.data.support.PageableExecutionUtils; import org.springframework.util.Assert; @@ -53,27 +57,31 @@ class FetchableFluentQueryByPredicate extends FluentQuerySupport imp 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; public FetchableFluentQueryByPredicate(Predicate predicate, Class entityType, - Function> finder, BiFunction> pagedFinder, - Function countOperation, Function existsOperation, - EntityManager entityManager) { - this(predicate, entityType, (Class) entityType, Sort.unsorted(), Collections.emptySet(), finder, pagedFinder, - countOperation, existsOperation, entityManager); + Function> finder, PredicateScrollDelegate scroll, + BiFunction> pagedFinder, Function countOperation, + Function existsOperation, EntityManager entityManager) { + this(predicate, entityType, (Class) entityType, Sort.unsorted(), 0, Collections.emptySet(), finder, scroll, + pagedFinder, countOperation, existsOperation, entityManager); } private FetchableFluentQueryByPredicate(Predicate predicate, Class entityType, Class resultType, Sort sort, - Collection properties, Function> finder, - BiFunction> pagedFinder, Function countOperation, - Function existsOperation, EntityManager entityManager) { + int limit, Collection properties, Function> finder, + PredicateScrollDelegate scroll, BiFunction> pagedFinder, + Function countOperation, Function existsOperation, + EntityManager entityManager) { - super(resultType, sort, properties, entityType); + super(resultType, sort, limit, properties, entityType); this.predicate = predicate; this.finder = finder; + this.scroll = scroll; this.pagedFinder = pagedFinder; this.countOperation = countOperation; this.existsOperation = existsOperation; @@ -85,8 +93,17 @@ public FetchableFluentQuery sortBy(Sort sort) { Assert.notNull(sort, "Sort must not be null"); - return new FetchableFluentQueryByPredicate<>(predicate, entityType, resultType, this.sort.and(sort), properties, - finder, pagedFinder, countOperation, existsOperation, entityManager); + return new FetchableFluentQueryByPredicate<>(predicate, entityType, resultType, this.sort.and(sort), limit, + properties, finder, scroll, pagedFinder, countOperation, existsOperation, entityManager); + } + + @Override + 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); } @Override @@ -98,15 +115,15 @@ public FetchableFluentQuery as(Class resultType) { throw new UnsupportedOperationException("Class-based DTOs are not yet supported."); } - return new FetchableFluentQueryByPredicate<>(predicate, entityType, resultType, sort, properties, finder, - pagedFinder, countOperation, existsOperation, entityManager); + return new FetchableFluentQueryByPredicate<>(predicate, entityType, resultType, sort, limit, properties, finder, + scroll, pagedFinder, countOperation, existsOperation, entityManager); } @Override public FetchableFluentQuery project(Collection properties) { - return new FetchableFluentQueryByPredicate<>(predicate, entityType, resultType, sort, mergeProperties(properties), - finder, pagedFinder, countOperation, existsOperation, entityManager); + return new FetchableFluentQueryByPredicate<>(predicate, entityType, resultType, sort, limit, + mergeProperties(properties), finder, scroll, pagedFinder, countOperation, existsOperation, entityManager); } @Override @@ -138,6 +155,14 @@ public List all() { return convert(createSortedAndProjectedQuery().fetch()); } + @Override + public Window scroll(ScrollPosition scrollPosition) { + + Assert.notNull(scrollPosition, "ScrollPosition must not be null"); + + return scroll.scroll(sort, limit, scrollPosition).map(getConversionFunction()); + } + @Override public Page page(Pageable pageable) { return pageable.isUnpaged() ? new PageImpl<>(all()) : readPage(pageable); @@ -169,6 +194,10 @@ public boolean exists() { query.setHint(EntityGraphFactory.HINT, EntityGraphFactory.create(entityManager, entityType, properties)); } + if (limit != 0) { + query.limit(limit); + } + return query; } @@ -201,4 +230,24 @@ private Function getConversionFunction() { return getConversionFunction(entityType, resultType); } + + static class PredicateScrollDelegate extends ScrollDelegate { + + private final ScrollQueryFactory scrollFunction; + + PredicateScrollDelegate(ScrollQueryFactory scrollQueryFactory, JpaEntityInformation entity) { + super(entity); + this.scrollFunction = scrollQueryFactory; + } + + public Window scroll(Sort sort, int limit, ScrollPosition scrollPosition) { + + Query query = scrollFunction.createQuery(sort, scrollPosition); + if (limit > 0) { + query = query.setMaxResults(limit); + } + return scroll(query, sort, scrollPosition); + } + } + } 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 74d2d67afd..d8193f52a4 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 @@ -16,6 +16,7 @@ package org.springframework.data.jpa.repository.support; import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; import jakarta.persistence.TypedQuery; import java.util.ArrayList; @@ -29,8 +30,11 @@ 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.domain.Specification; +import org.springframework.data.jpa.repository.query.ScrollDelegate; import org.springframework.data.jpa.support.PageableUtils; import org.springframework.data.repository.query.FluentQuery; import org.springframework.data.support.PageableExecutionUtils; @@ -50,26 +54,28 @@ class FetchableFluentQueryBySpecification extends FluentQuerySupport private final Specification spec; private final Function> finder; + private final SpecificationScrollDelegate scroll; private final Function, Long> countOperation; private final Function, Boolean> existsOperation; private final EntityManager entityManager; - public FetchableFluentQueryBySpecification(Specification spec, Class entityType, Sort sort, - Collection properties, Function> finder, + public FetchableFluentQueryBySpecification(Specification spec, Class entityType, + Function> finder, SpecificationScrollDelegate scrollDelegate, Function, Long> countOperation, Function, Boolean> existsOperation, EntityManager entityManager) { - this(spec, entityType, (Class) entityType, Sort.unsorted(), Collections.emptySet(), finder, countOperation, - existsOperation, entityManager); + this(spec, entityType, (Class) entityType, Sort.unsorted(), 0, Collections.emptySet(), finder, scrollDelegate, + countOperation, existsOperation, entityManager); } private FetchableFluentQueryBySpecification(Specification spec, Class entityType, Class resultType, - Sort sort, Collection properties, Function> finder, - Function, Long> countOperation, Function, Boolean> existsOperation, - EntityManager entityManager) { + Sort sort, int limit, Collection properties, Function> finder, + SpecificationScrollDelegate scrollDelegate, Function, Long> countOperation, + Function, Boolean> existsOperation, EntityManager entityManager) { - super(resultType, sort, properties, entityType); + super(resultType, sort, limit, properties, entityType); this.spec = spec; this.finder = finder; + this.scroll = scrollDelegate; this.countOperation = countOperation; this.existsOperation = existsOperation; this.entityManager = entityManager; @@ -80,8 +86,17 @@ public FetchableFluentQuery sortBy(Sort sort) { Assert.notNull(sort, "Sort must not be null"); - return new FetchableFluentQueryBySpecification<>(spec, entityType, resultType, this.sort.and(sort), properties, - finder, countOperation, existsOperation, entityManager); + return new FetchableFluentQueryBySpecification<>(spec, entityType, resultType, this.sort.and(sort), limit, + properties, finder, scroll, countOperation, existsOperation, entityManager); + } + + @Override + public FetchableFluentQuery limit(int limit) { + + Assert.isTrue(limit >= 0, "Limit must not be negative"); + + return new FetchableFluentQueryBySpecification<>(spec, entityType, resultType, this.sort.and(sort), limit, + properties, finder, scroll, countOperation, existsOperation, entityManager); } @Override @@ -92,15 +107,15 @@ public FetchableFluentQuery as(Class resultType) { throw new UnsupportedOperationException("Class-based DTOs are not yet supported."); } - return new FetchableFluentQueryBySpecification<>(spec, entityType, resultType, sort, properties, finder, - countOperation, existsOperation, entityManager); + return new FetchableFluentQueryBySpecification<>(spec, entityType, resultType, sort, limit, properties, finder, + scroll, countOperation, existsOperation, entityManager); } @Override public FetchableFluentQuery project(Collection properties) { - return new FetchableFluentQueryBySpecification<>(spec, entityType, resultType, sort, properties, finder, - countOperation, existsOperation, entityManager); + return new FetchableFluentQueryBySpecification<>(spec, entityType, resultType, sort, limit, properties, finder, + scroll, countOperation, existsOperation, entityManager); } @Override @@ -132,6 +147,14 @@ public List all() { return convert(createSortedAndProjectedQuery().getResultList()); } + @Override + public Window scroll(ScrollPosition scrollPosition) { + + Assert.notNull(scrollPosition, "ScrollPosition must not be null"); + + return scroll.scroll(sort, limit, scrollPosition).map(getConversionFunction()); + } + @Override public Page page(Pageable pageable) { return pageable.isUnpaged() ? new PageImpl<>(all()) : readPage(pageable); @@ -163,6 +186,10 @@ private TypedQuery createSortedAndProjectedQuery() { query.setHint(EntityGraphFactory.HINT, EntityGraphFactory.create(entityManager, entityType, properties)); } + if (limit != 0) { + query.setMaxResults(limit); + } + return query; } @@ -194,4 +221,25 @@ private List convert(List resultList) { private Function getConversionFunction() { return getConversionFunction(entityType, resultType); } + + static class SpecificationScrollDelegate extends ScrollDelegate { + + private final ScrollQueryFactory scrollFunction; + + SpecificationScrollDelegate(ScrollQueryFactory scrollQueryFactory, JpaEntityInformation entity) { + super(entity); + this.scrollFunction = scrollQueryFactory; + } + + public Window scroll(Sort sort, int limit, ScrollPosition scrollPosition) { + + Query query = scrollFunction.createQuery(sort, scrollPosition); + + if (limit > 0) { + query = query.setMaxResults(limit); + } + + return scroll(query, sort, scrollPosition); + } + } } 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 4ac26874a6..5aa8352ed8 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,6 +15,8 @@ */ package org.springframework.data.jpa.repository.support; +import jakarta.persistence.Query; + import java.util.Collection; import java.util.Collections; import java.util.HashSet; @@ -22,6 +24,7 @@ import java.util.function.Function; import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Sort; import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.lang.Nullable; @@ -32,21 +35,25 @@ * @param The resulting type of the query. * @author Greg Turnquist * @author Jens Schauder + * @author Mark Paluch * @since 2.6 */ abstract class FluentQuerySupport { protected final Class resultType; protected final Sort sort; + protected final int limit; protected final Set properties; protected final Class entityType; private final SpelAwareProxyProjectionFactory projectionFactory = new SpelAwareProxyProjectionFactory(); - FluentQuerySupport(Class resultType, Sort sort, @Nullable Collection properties, Class entityType) { + FluentQuerySupport(Class resultType, Sort sort, int limit, @Nullable Collection properties, + Class entityType) { this.resultType = resultType; this.sort = sort; + this.limit = limit; if (properties != null) { this.properties = new HashSet<>(properties); @@ -78,4 +85,9 @@ final Function getConversionFunction(Class inputType, Class tar return o -> DefaultConversionService.getSharedInstance().convert(o, targetType); } + + interface ScrollQueryFactory { + Query createQuery(Sort sort, ScrollPosition scrollPosition); + } + } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEntityInformation.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEntityInformation.java index f8b438ff82..f192386db0 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEntityInformation.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEntityInformation.java @@ -17,6 +17,9 @@ import jakarta.persistence.metamodel.SingularAttribute; +import java.util.Collection; +import java.util.Map; + import org.springframework.data.jpa.repository.query.JpaEntityMetadata; import org.springframework.data.repository.core.EntityInformation; import org.springframework.lang.Nullable; @@ -70,7 +73,7 @@ public interface JpaEntityInformation extends EntityInformation, J * * @return */ - Iterable getIdAttributeNames(); + Collection getIdAttributeNames(); /** * Extracts the value for the given id attribute from a composite id @@ -81,4 +84,14 @@ public interface JpaEntityInformation extends EntityInformation, J */ @Nullable Object getCompositeIdAttributeValue(Object id, String idAttribute); + + /** + * Extract a keyset for {@code propertyPaths} and the primary key (including composite key components if applicable). + * + * @param propertyPaths + * @param entity + * @return + * @since 3.1 + */ + Map getKeyset(Iterable propertyPaths, T entity); } 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 fa64f1cc51..c740887b27 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 @@ -26,9 +26,12 @@ import jakarta.persistence.metamodel.Type; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; @@ -61,7 +64,7 @@ public class JpaMetamodelEntityInformation extends JpaEntityInformationSu /** * Creates a new {@link JpaMetamodelEntityInformation} for the given domain class and {@link Metamodel}. - * + * * @param domainClass must not be {@literal null}. * @param metamodel must not be {@literal null}. * @param persistenceUnitUtil must not be {@literal null}. @@ -190,7 +193,7 @@ public boolean hasCompositeId() { } @Override - public Iterable getIdAttributeNames() { + public Collection getIdAttributeNames() { List attributeNames = new ArrayList<>(idMetadata.attributes.size()); @@ -222,6 +225,30 @@ public boolean isNew(T entity) { return versionAttribute.map(it -> wrapper.getPropertyValue(it.getName()) == null).orElse(true); } + @Override + public Map getKeyset(Iterable propertyPaths, T entity) { + + // TODO: Proxy handling requires more elaborate refactoring, see + // https://github.com/spring-projects/spring-data-jpa/issues/2784 + BeanWrapper entityWrapper = new DirectFieldAccessFallbackBeanWrapper(entity); + + Map keyset = new LinkedHashMap<>(); + + if (hasCompositeId()) { + for (String idAttributeName : getIdAttributeNames()) { + keyset.put(idAttributeName, entityWrapper.getPropertyValue(idAttributeName)); + } + } else { + keyset.put(getIdAttribute().getName(), getId(entity)); + } + + for (String propertyPath : propertyPaths) { + keyset.put(propertyPath, entityWrapper.getPropertyValue(propertyPath)); + } + + return keyset; + } + /** * Simple value object to encapsulate id specific metadata. * 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 1f4c5883e7..2e38c4c2b3 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 @@ -15,10 +15,10 @@ */ package org.springframework.data.jpa.repository.support; -import java.util.List; - import jakarta.persistence.EntityManager; +import java.util.List; + import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Order; @@ -245,4 +245,29 @@ private Expression buildOrderPropertyPathFrom(Order order) { return sortPropertyExpression; } + + /** + * Creates an {@link Expression} for the given {@code property} property. + * + * @param property must not be {@literal null}. + * @return + */ + Expression createExpression(String property) { + + Assert.notNull(property, "Property must not be null"); + + PropertyPath path = PropertyPath.from(property, builder.getType()); + Expression sortPropertyExpression = builder; + + while (path != null) { + + sortPropertyExpression = !path.hasNext() && String.class.equals(path.getType()) // + ? Expressions.stringPath((Path) sortPropertyExpression, path.getSegment()) // + : Expressions.path(path.getType(), (Path) sortPropertyExpression, path.getSegment()); + + path = path.next(); + } + + return sortPropertyExpression; + } } 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 2396e39ae3..c899d051ab 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 @@ -15,19 +15,27 @@ */ package org.springframework.data.jpa.repository.support; +import jakarta.persistence.EntityManager; +import jakarta.persistence.LockModeType; + import java.util.List; import java.util.Optional; import java.util.function.BiFunction; import java.util.function.Function; -import jakarta.persistence.EntityManager; -import jakarta.persistence.LockModeType; - import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.data.domain.OffsetScrollPosition; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Order; import org.springframework.data.jpa.repository.EntityGraph; +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.querydsl.EntityPathResolver; import org.springframework.data.querydsl.QSort; import org.springframework.data.querydsl.QuerydslPredicateExecutor; @@ -37,9 +45,14 @@ import org.springframework.util.Assert; import com.querydsl.core.NonUniqueResultException; +import com.querydsl.core.types.ConstantImpl; import com.querydsl.core.types.EntityPath; +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.NullExpression; +import com.querydsl.core.types.Ops; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Predicate; +import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.Expressions; import com.querydsl.core.types.dsl.PathBuilder; import com.querydsl.jpa.JPQLQuery; @@ -63,6 +76,7 @@ public class QuerydslJpaPredicateExecutor implements QuerydslPredicateExecuto private final JpaEntityInformation entityInformation; private final EntityPath path; private final Querydsl querydsl; + private final QuerydslQueryStrategy scrollQueryAdapter; private final EntityManager entityManager; private final CrudMethodMetadata metadata; @@ -83,6 +97,7 @@ public QuerydslJpaPredicateExecutor(JpaEntityInformation entityInformation this.path = resolver.createPath(entityInformation.getJavaType()); this.querydsl = new Querydsl(entityManager, new PathBuilder(path.getType(), path.getMetadata())); this.entityManager = entityManager; + this.scrollQueryAdapter = new QuerydslQueryStrategy(); } @Override @@ -160,6 +175,33 @@ public R findBy(Predicate predicate, Function scroll = (sort, scrollPosition) -> { + + Predicate predicateToUse = predicate; + + if (scrollPosition instanceof KeysetScrollPosition keyset) { + + KeysetScrollDelegate delegate = KeysetScrollDelegate.of(keyset.getDirection()); + sort = KeysetScrollSpecification.createSort(keyset, sort, entityInformation); + BooleanExpression keysetPredicate = delegate.createPredicate(keyset, sort, scrollQueryAdapter); + + if (keysetPredicate != null) { + predicateToUse = predicate instanceof BooleanExpression be ? be.and(keysetPredicate) + : keysetPredicate.and(predicate); + } + } + + AbstractJPAQuery select = (AbstractJPAQuery) createQuery(predicateToUse).select(path); + + select = (AbstractJPAQuery) querydsl.applySorting(sort, select); + + if (scrollPosition instanceof OffsetScrollPosition offset) { + select.offset(offset.getOffset()); + } + + return select.createQuery(); + }; + BiFunction> pagedFinder = (sort, pageable) -> { AbstractJPAQuery select = finder.apply(sort); @@ -175,6 +217,7 @@ public R findBy(Predicate predicate, Function(scroll, entityInformation), // pagedFinder, // this::count, // this::exists, // @@ -285,4 +328,34 @@ private List executeSorted(JPQLQuery query, OrderSpecifier... orders) { private List executeSorted(JPQLQuery query, Sort sort) { return querydsl.applySorting(sort, query).fetch(); } + + class QuerydslQueryStrategy implements QueryStrategy, BooleanExpression> { + + @Override + public Expression createExpression(String property) { + return querydsl.createExpression(property); + } + + @Override + public BooleanExpression compare(Order order, Expression propertyExpression, Object value) { + return Expressions.booleanOperation(order.isAscending() ? Ops.GT : Ops.LT, propertyExpression, + ConstantImpl.create(value)); + } + + @Override + public BooleanExpression compare(Expression propertyExpression, @Nullable Object value) { + return Expressions.booleanOperation(Ops.EQ, propertyExpression, + value == null ? NullExpression.DEFAULT : ConstantImpl.create(value)); + } + + @Override + public BooleanExpression and(List intermediate) { + return Expressions.allOf(intermediate.toArray(new BooleanExpression[0])); + } + + @Override + public BooleanExpression or(List intermediate) { + return Expressions.anyOf(intermediate.toArray(new BooleanExpression[0])); + } + } } 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 d83d5f50ff..788ea6fae3 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 @@ -43,6 +43,8 @@ import java.util.stream.StreamSupport; import org.springframework.data.domain.Example; +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.data.domain.OffsetScrollPosition; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; @@ -52,7 +54,10 @@ 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.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.repository.query.FluentQuery.FetchableFluentQuery; @@ -507,10 +512,40 @@ public R findBy(Specification spec, Function> finder = sort -> getQuery(spec, getDomainClass(), sort); + return doFindBy(spec, getDomainClass(), queryFunction); + } + + private R doFindBy(Specification spec, Class domainClass, + Function, R> queryFunction) { + + Assert.notNull(spec, "Specification must not be null"); + Assert.notNull(queryFunction, "Query function must not be null"); + + ScrollQueryFactory scrollFunction = (sort, scrollPosition) -> { + + Specification specToUse = spec; + + if (scrollPosition instanceof KeysetScrollPosition keyset) { + KeysetScrollSpecification keysetSpec = new KeysetScrollSpecification<>(keyset, sort, entityInformation); + sort = keysetSpec.sort(); + specToUse = specToUse.and(keysetSpec); + } + + TypedQuery query = getQuery(specToUse, domainClass, sort); + + if (scrollPosition instanceof OffsetScrollPosition offset) { + query.setFirstResult(Math.toIntExact(offset.getOffset())); + } - FetchableFluentQuery fluentQuery = new FetchableFluentQueryBySpecification(spec, getDomainClass(), - Sort.unsorted(), null, finder, this::count, this::exists, this.em); + return query; + }; + + Function> finder = sort -> getQuery(spec, domainClass, sort); + + SpecificationScrollDelegate scrollDelegate = new SpecificationScrollDelegate<>(scrollFunction, + entityInformation); + FetchableFluentQuery fluentQuery = new FetchableFluentQueryBySpecification<>(spec, domainClass, finder, + scrollDelegate, this::count, this::exists, this.em); return queryFunction.apply((FetchableFluentQuery) fluentQuery); } @@ -544,7 +579,6 @@ public boolean exists(Example example) { return query.setMaxResults(1).getResultList().size() == 1; } - @Override public List findAll(Example example) { return getQuery(new ExampleSpecification<>(example, escapeCharacter), example.getProbeType(), Sort.unsorted()) @@ -572,21 +606,12 @@ public R findBy(Example example, Function> finder = sort -> { - - ExampleSpecification spec = new ExampleSpecification<>(example, escapeCharacter); - Class probeType = example.getProbeType(); - - return getQuery(spec, probeType, sort); - }; - - FetchableFluentQuery fluentQuery = new FetchableFluentQueryByExample<>(example, finder, this::count, - this::exists, this.em, this.escapeCharacter); + ExampleSpecification spec = new ExampleSpecification<>(example, escapeCharacter); + Class probeType = example.getProbeType(); - return queryFunction.apply(fluentQuery); + return doFindBy((Specification) spec, (Class) probeType, queryFunction); } - @Override public long count() { diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Item.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Item.java index c2364a298b..6fce01ece1 100755 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Item.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Item.java @@ -21,6 +21,8 @@ import jakarta.persistence.IdClass; import jakarta.persistence.JoinColumn; import jakarta.persistence.Table; +import lombok.EqualsAndHashCode; +import lombok.ToString; /** * @author Mark Paluch @@ -30,12 +32,16 @@ @Entity @Table @IdClass(ItemId.class) +@EqualsAndHashCode +@ToString public class Item { @Id @Column(columnDefinition = "INT") private Integer id; @Id @JoinColumn(name = "manufacturer_id", columnDefinition = "INT") private Integer manufacturerId; + private String name; + public Item() {} public Item(Integer id, Integer manufacturerId) { @@ -43,6 +49,12 @@ public Item(Integer id, Integer manufacturerId) { this.manufacturerId = manufacturerId; } + public Item(Integer id, Integer manufacturerId, String name) { + this.id = id; + this.manufacturerId = manufacturerId; + this.name = name; + } + public Integer getId() { return id; } @@ -50,4 +62,13 @@ public Integer getId() { public Integer getManufacturerId() { return manufacturerId; } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/RepositoryWithIdClassKeyTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/RepositoryWithIdClassKeyTests.java index 314998dcb9..c530cb83ff 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/RepositoryWithIdClassKeyTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/RepositoryWithIdClassKeyTests.java @@ -15,8 +15,9 @@ */ package org.springframework.data.jpa.repository; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.*; +import java.util.Arrays; import java.util.Optional; import org.junit.jupiter.api.Test; @@ -24,6 +25,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.ImportResource; +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Window; import org.springframework.data.jpa.domain.sample.Item; import org.springframework.data.jpa.domain.sample.ItemId; import org.springframework.data.jpa.domain.sample.ItemSite; @@ -75,6 +79,28 @@ void shouldSaveAndLoadEntitiesWithDerivedIdentities() { assertThat(loaded).isPresent(); } + @Test // GH-2878 + void shouldScrollWithKeyset() { + + Item item1 = new Item(1, 2, "a"); + Item item2 = new Item(2, 3, "b"); + Item item3 = new Item(3, 4, "c"); + + itemRepository.saveAllAndFlush(Arrays.asList(item1, item2, item3)); + + Window first = itemRepository.findBy((root, query, criteriaBuilder) -> { + return criteriaBuilder.isNotNull(root.get("name")); + }, q -> q.limit(1).sortBy(Sort.by("name")).scroll(KeysetScrollPosition.initial())); + + assertThat(first).containsOnly(item1); + + Window next = itemRepository.findBy((root, query, criteriaBuilder) -> { + return criteriaBuilder.isNotNull(root.get("name")); + }, q -> q.limit(1).sortBy(Sort.by("name")).scroll(first.positionAt(0))); + + assertThat(next).containsOnly(item2); + } + @Configuration @EnableJpaRepositories(basePackageClasses = SampleConfig.class) static abstract class Config { 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 61fc82a88e..026598b868 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 @@ -34,6 +34,7 @@ import lombok.Data; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashSet; @@ -54,14 +55,7 @@ import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.dao.InvalidDataAccessApiUsageException; -import org.springframework.data.domain.Example; -import org.springframework.data.domain.ExampleMatcher; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.data.domain.Sort; +import org.springframework.data.domain.*; import org.springframework.data.domain.Sort.Direction; import org.springframework.data.domain.Sort.Order; import org.springframework.data.jpa.domain.Specification; @@ -1235,6 +1229,185 @@ void findAllByTypedSpecialUserExampleShouldReturnSubTypesOfRepositoryEntity() { assertThat(result).hasSize(1); } + @Test // GH-2878 + void scrollByExampleOffset() { + + 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)); + + Example example = Example.of(new User("J", null, null), + matching().withMatcher("firstname", GenericPropertyMatcher::startsWith).withIgnorePaths("age", "createdAt", + "dateOfBirth")); + Window firstWindow = repository.findBy(example, + q -> q.limit(2).sortBy(Sort.by("firstname")).scroll(OffsetScrollPosition.initial())); + + assertThat(firstWindow).containsExactly(jane1, jane2); + assertThat(firstWindow.hasNext()).isTrue(); + + Window nextWindow = repository.findBy(example, + q -> q.limit(2).sortBy(Sort.by("firstname")).scroll(firstWindow.positionAt(1))); + + assertThat(nextWindow).containsExactly(john1, john2); + assertThat(nextWindow.hasNext()).isFalse(); + } + + @Test // GH-2878 + void scrollByExampleKeyset() { + + 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)); + + Example example = Example.of(new User("J", null, null), + matching().withMatcher("firstname", GenericPropertyMatcher::startsWith).withIgnorePaths("age", "createdAt", + "dateOfBirth")); + Window firstWindow = repository.findBy(example, + q -> q.limit(1).sortBy(Sort.by("firstname", "emailAddress")).scroll(KeysetScrollPosition.initial())); + + assertThat(firstWindow).containsOnly(jane1); + assertThat(firstWindow.hasNext()).isTrue(); + + Window nextWindow = repository.findBy(example, + q -> q.limit(2).sortBy(Sort.by("firstname", "emailAddress")).scroll(firstWindow.positionAt(0))); + + assertThat(nextWindow).containsExactly(jane2, john1); + assertThat(nextWindow.hasNext()).isTrue(); + } + + @Test // GH-2878 + void scrollByExampleKeysetBackward() { + + 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)); + + Example example = Example.of(new User("J", null, null), + matching().withMatcher("firstname", GenericPropertyMatcher::startsWith).withIgnorePaths("age", "createdAt", + "dateOfBirth")); + Window firstWindow = repository.findBy(example, + q -> q.limit(4).sortBy(Sort.by("firstname", "emailAddress")).scroll(KeysetScrollPosition.initial())); + + KeysetScrollPosition scrollPosition = (KeysetScrollPosition) firstWindow.positionAt(2); + Window previousWindow = repository.findBy(example, + q -> q.limit(1).sortBy(Sort.by("firstname", "emailAddress")) + .scroll(KeysetScrollPosition.of(scrollPosition.getKeys(), KeysetScrollPosition.Direction.Backward))); + + assertThat(previousWindow).containsOnly(jane2); + assertThat(previousWindow.hasNext()).isTrue(); + } + + @Test // GH-2878 + void scrollByPredicateOffset() { + + 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(2).sortBy(Sort.by("firstname")).scroll(OffsetScrollPosition.initial())); + + assertThat(firstWindow).containsExactly(jane1, jane2); + assertThat(firstWindow.hasNext()).isTrue(); + + Window nextWindow = repository.findBy(QUser.user.firstname.startsWith("J"), + q -> q.limit(2).sortBy(Sort.by("firstname")).scroll(firstWindow.positionAt(1))); + + assertThat(nextWindow).containsExactly(john1, john2); + assertThat(nextWindow.hasNext()).isFalse(); + } + + @Test // GH-2878 + void scrollByPredicateKeyset() { + + 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")).scroll(KeysetScrollPosition.initial())); + + assertThat(firstWindow).containsOnly(jane1); + assertThat(firstWindow.hasNext()).isTrue(); + + Window nextWindow = repository.findBy(QUser.user.firstname.startsWith("J"), + q -> q.limit(2).sortBy(Sort.by("firstname", "emailAddress")).scroll(firstWindow.positionAt(0))); + + assertThat(nextWindow).containsExactly(jane2, john1); + assertThat(nextWindow.hasNext()).isTrue(); + } + + @Test // GH-2878 + void scrollByPredicateKeysetBackward() { + + 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(3).sortBy(Sort.by("firstname", "emailAddress")).scroll(KeysetScrollPosition.initial())); + + assertThat(firstWindow).containsExactly(jane1, jane2, john1); + assertThat(firstWindow.hasNext()).isTrue(); + + KeysetScrollPosition scrollPosition = (KeysetScrollPosition) firstWindow.positionAt(2); + KeysetScrollPosition backward = KeysetScrollPosition.of(scrollPosition.getKeys(), + KeysetScrollPosition.Direction.Backward); + Window previousWindow = repository.findBy(QUser.user.firstname.startsWith("J"), + q -> q.limit(3).sortBy(Sort.by("firstname", "emailAddress")).scroll(backward)); + + assertThat(previousWindow).containsExactly(jane1, jane2); + + // no more items before this window + assertThat(previousWindow.hasNext()).isFalse(); + } + + @Test // GH-2878 + void scrollByPartTreeKeysetBackward() { + + 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.findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc("J", + KeysetScrollPosition.initial()); + + assertThat(firstWindow).containsExactly(jane1, jane2, john1); + assertThat(firstWindow.hasNext()).isTrue(); + + KeysetScrollPosition scrollPosition = (KeysetScrollPosition) firstWindow.positionAt(2); + KeysetScrollPosition backward = KeysetScrollPosition.of(scrollPosition.getKeys(), + KeysetScrollPosition.Direction.Backward); + Window previousWindow = repository.findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc("J", + backward); + + assertThat(previousWindow).containsExactly(jane1, jane2); + + // no more items before this window + assertThat(previousWindow.hasNext()).isFalse(); + } + @Test // DATAJPA-491 void sortByNestedAssociationPropertyWithSortInPageable() { diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/CollectionUtilsUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/CollectionUtilsUnitTests.java new file mode 100644 index 0000000000..6deb691d6e --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/CollectionUtilsUnitTests.java @@ -0,0 +1,46 @@ +/* + * Copyright 2023 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.util.List; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link CollectionUtils}. + * + * @author Mark Paluch + */ +class CollectionUtilsUnitTests { + + @Test // GH-2878 + void shouldReturnFirstItems() { + + assertThat(CollectionUtils.getFirst(2, List.of(1, 2, 3))).containsExactly(1, 2); + assertThat(CollectionUtils.getFirst(2, List.of(1, 2))).containsExactly(1, 2); + assertThat(CollectionUtils.getFirst(2, List.of(1))).containsExactly(1); + } + + @Test // GH-2878 + void shouldReturnLastItems() { + + assertThat(CollectionUtils.getLast(2, List.of(1, 2, 3))).containsExactly(2, 3); + assertThat(CollectionUtils.getLast(2, List.of(1, 2))).containsExactly(1, 2); + assertThat(CollectionUtils.getLast(2, List.of(1))).containsExactly(1); + } +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ItemRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ItemRepository.java index bbf051e231..b4ed4f38af 100755 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ItemRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ItemRepository.java @@ -18,10 +18,11 @@ import org.springframework.data.jpa.domain.sample.Item; import org.springframework.data.jpa.domain.sample.ItemId; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; /** * @author Mark Paluch * @see Final JPA 2.1 * Specification 2.4.1.3 Derived Identities Example 2 */ -public interface ItemRepository extends JpaRepository {} +public interface ItemRepository extends JpaRepository, JpaSpecificationExecutor {} 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 83e77e5be2..24362a2a77 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 @@ -29,8 +29,10 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Window; import org.springframework.data.jpa.domain.sample.Role; import org.springframework.data.jpa.domain.sample.SpecialUser; import org.springframework.data.jpa.domain.sample.User; @@ -150,6 +152,9 @@ public interface UserRepository extends JpaRepository, JpaSpecifi Page findByFirstnameIn(Pageable pageable, String... firstnames); + Window findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc(String firstname, + ScrollPosition position); + List findByFirstnameNotIn(Collection firstnames); // DATAJPA-292 diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByExampleUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByExampleUnitTests.java deleted file mode 100644 index ca6c80f2fe..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByExampleUnitTests.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2022-2023 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 static org.assertj.core.api.Assertions.*; - -import org.junit.jupiter.api.Test; -import org.springframework.data.domain.Example; -import org.springframework.data.domain.Sort; -import org.springframework.data.domain.Sort.Order; -import org.springframework.data.jpa.repository.support.FetchableFluentQueryByExample; - -/** - * Unit tests for {@link FetchableFluentQueryByExample}. - * - * @author J.R. Onyschak - */ -class FetchableFluentQueryByExampleUnitTests { - - @Test // GH-2438 - @SuppressWarnings({ "rawtypes", "unchecked" }) - void multipleSortBy() { - - Sort s1 = Sort.by(Order.by("s1")); - Sort s2 = Sort.by(Order.by("s2")); - FetchableFluentQueryByExample f = new FetchableFluentQueryByExample(Example.of(""), null, null, null, null, null); - f = (FetchableFluentQueryByExample) 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/FetchableFluentQueryByPredicateUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByPredicateUnitTests.java index 0691c6e87a..82e9fa65d4 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,7 +20,6 @@ import org.junit.jupiter.api.Test; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Order; -import org.springframework.data.jpa.repository.support.FetchableFluentQueryByPredicate; /** * Unit tests for {@link FetchableFluentQueryByPredicate}. @@ -35,7 +34,8 @@ void multipleSortBy() { 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); + FetchableFluentQueryByPredicate f = new FetchableFluentQueryByPredicate(null, null, null, null, null, null, null, + null); 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/JpaEntityInformationSupportUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaEntityInformationSupportUnitTests.java index 78268f7b12..52df503449 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaEntityInformationSupportUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaEntityInformationSupportUnitTests.java @@ -18,9 +18,6 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; -import java.io.Serializable; -import java.util.Collections; - import jakarta.persistence.Entity; import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManagerFactory; @@ -28,6 +25,11 @@ import jakarta.persistence.metamodel.Metamodel; import jakarta.persistence.metamodel.SingularAttribute; +import java.io.Serializable; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -97,7 +99,7 @@ public Class getIdType() { } @Override - public Iterable getIdAttributeNames() { + public Collection getIdAttributeNames() { return Collections.emptySet(); } @@ -110,6 +112,11 @@ public boolean hasCompositeId() { public Object getCompositeIdAttributeValue(Object id, String idAttribute) { return null; } + + @Override + public Map getKeyset(Iterable propertyPaths, T entity) { + return null; + } } @Entity(name = "AnotherNamedUser") diff --git a/src/main/asciidoc/index.adoc b/src/main/asciidoc/index.adoc index ea94f95ac3..3e3d7ec919 100644 --- a/src/main/asciidoc/index.adoc +++ b/src/main/asciidoc/index.adoc @@ -5,8 +5,9 @@ Oliver Gierke; Thomas Darimont; Christoph Strobl; Mark Paluch; Jay Bryant; Greg ifdef::backend-epub3[:front-cover-image: image:epub-cover.png[Front Cover,1050,1600]] :spring-data-commons-docs: ../../../../spring-data-commons/src/main/asciidoc :spring-framework-docs: https://docs.spring.io/spring-framework/docs/{springVersion}/spring-framework-reference/ +:feature-scroll: true -(C) 2008-2022 The original authors. +(C) 2008-2023 The original authors. NOTE: Copies of this document may be made for your own use and for distribution to others, provided that you do not charge any fee for such copies and further provided that each copy contains this Copyright Notice, whether distributed in print or electronically. diff --git a/src/main/asciidoc/jpa.adoc b/src/main/asciidoc/jpa.adoc index d03f63d787..723989c40f 100644 --- a/src/main/asciidoc/jpa.adoc +++ b/src/main/asciidoc/jpa.adoc @@ -521,20 +521,45 @@ repo.findByAndSort("stark", Sort.by("LENGTH(firstname)")); <2> repo.findByAndSort("targaryen", JpaSort.unsafe("LENGTH(firstname)")); <3> repo.findByAsArrayAndSort("bolton", Sort.by("fn_len")); <4> ---- + <1> Valid `Sort` expression pointing to property in domain model. -<2> Invalid `Sort` containing function call. Throws Exception. +<2> Invalid `Sort` containing function call. +Throws Exception. <3> Valid `Sort` containing explicitly _unsafe_ `Order`. <4> Valid `Sort` expression pointing to aliased function. ==== +[[jpa.query-methods.scroll]] +=== Scrolling Large Query Results + +When working with large data sets, <> can help to process those results efficiently without loading all results into memory. + +You have multiple options to consume large query results: + +1. <>. +You have learned in the previous chapter about `Pageable` and `PageRequest`. +2. <>. +This is a lighter variant than paging because it does not require the total result count. +3. <>. +This method avoids https://use-the-index-luke.com/no-offset[the shortcomings of offset-based result retrieval by leveraging database indexes]. + +Read more on <> for your particular arrangement. + +You can use the Scroll API with query methods, <>, and <>. + +NOTE: Scrolling with String-based query methods is not yet supported. +Scrolling is also not supported using stored `@Procedure` query methods. + [[jpa.named-parameters]] === Using Named Parameters -By default, Spring Data JPA uses position-based parameter binding, as described in all the preceding examples. This makes query methods a little error-prone when refactoring regarding the parameter position. To solve this issue, you can use `@Param` annotation to give a method parameter a concrete name and bind the name in the query, as shown in the following example: +By default, Spring Data JPA uses position-based parameter binding, as described in all the preceding examples. +This makes query methods a little error-prone when refactoring regarding the parameter position. +To solve this issue, you can use `@Param` annotation to give a method parameter a concrete name and bind the name in the query, as shown in the following example: .Using named parameters ==== -[source, java] +[source,java] ---- public interface UserRepository extends JpaRepository {