From 3430acd591a096b243d2e4d092c0205e4bf5ceea Mon Sep 17 00:00:00 2001
From: Mark Paluch <mpaluch@vmware.com>
Date: Tue, 21 Mar 2023 14:18:02 +0100
Subject: [PATCH 1/7] Prepare issue branch.

---
 pom.xml                              | 2 +-
 spring-data-envers/pom.xml           | 4 ++--
 spring-data-jpa-distribution/pom.xml | 2 +-
 spring-data-jpa/pom.xml              | 4 ++--
 4 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/pom.xml b/pom.xml
index c77686453f..ef398792fa 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
 
 	<groupId>org.springframework.data</groupId>
 	<artifactId>spring-data-jpa-parent</artifactId>
-	<version>3.1.0-SNAPSHOT</version>
+	<version>3.1.0-GH-2878-SNAPSHOT</version>
 	<packaging>pom</packaging>
 
 	<name>Spring Data JPA Parent</name>
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 @@
 
 	<groupId>org.springframework.data</groupId>
 	<artifactId>spring-data-envers</artifactId>
-	<version>3.1.0-SNAPSHOT</version>
+	<version>3.1.0-GH-2878-SNAPSHOT</version>
 
 	<parent>
 		<groupId>org.springframework.data</groupId>
 		<artifactId>spring-data-jpa-parent</artifactId>
-		<version>3.1.0-SNAPSHOT</version>
+		<version>3.1.0-GH-2878-SNAPSHOT</version>
 		<relativePath>../pom.xml</relativePath>
 	</parent>
 
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 @@
 	<parent>
 		<groupId>org.springframework.data</groupId>
 		<artifactId>spring-data-jpa-parent</artifactId>
-		<version>3.1.0-SNAPSHOT</version>
+		<version>3.1.0-GH-2878-SNAPSHOT</version>
 		<relativePath>../pom.xml</relativePath>
 	</parent>
 
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 @@
 
 	<groupId>org.springframework.data</groupId>
 	<artifactId>spring-data-jpa</artifactId>
-	<version>3.1.0-SNAPSHOT</version>
+	<version>3.1.0-GH-2878-SNAPSHOT</version>
 
 	<name>Spring Data JPA</name>
 	<description>Spring Data module for JPA repositories.</description>
@@ -15,7 +15,7 @@
 	<parent>
 		<groupId>org.springframework.data</groupId>
 		<artifactId>spring-data-jpa-parent</artifactId>
-		<version>3.1.0-SNAPSHOT</version>
+		<version>3.1.0-GH-2878-SNAPSHOT</version>
 		<relativePath>../pom.xml</relativePath>
 	</parent>
 

From 96f57661000c4145d4fb40ad1b53d27f7b4264f8 Mon Sep 17 00:00:00 2001
From: Mark Paluch <mpaluch@vmware.com>
Date: Wed, 22 Mar 2023 10:39:42 +0100
Subject: [PATCH 2/7] Initial support for Keyset scrolling.

---
 .../data/jpa/repository/query/QueryUtils.java |   2 +-
 .../FetchableFluentQueryByExample.java        | 206 ------------------
 .../FetchableFluentQueryByPredicate.java      |   2 +-
 .../FetchableFluentQueryBySpecification.java  |  91 ++++++--
 .../support/FluentQuerySupport.java           |   7 +-
 .../support/JpaEntityInformation.java         |  15 +-
 .../JpaMetamodelEntityInformation.java        |  30 ++-
 .../support/KeysetScrollSpecification.java    | 194 +++++++++++++++++
 .../jpa/repository/support/ScrollUtil.java    |  70 ++++++
 .../support/SimpleJpaRepository.java          |  55 +++--
 .../data/jpa/domain/sample/Item.java          |  21 ++
 .../RepositoryWithIdClassKeyTests.java        |  28 ++-
 .../jpa/repository/UserRepositoryTests.java   |  62 +++++-
 .../jpa/repository/sample/ItemRepository.java |   3 +-
 ...etchableFluentQueryByExampleUnitTests.java |  43 ----
 .../JpaEntityInformationSupportUnitTests.java |  15 +-
 16 files changed, 545 insertions(+), 299 deletions(-)
 delete mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByExample.java
 create mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/KeysetScrollSpecification.java
 create mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/ScrollUtil.java
 delete mode 100644 spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByExampleUnitTests.java

diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java
index f581f191ff..54889bbc85 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java
@@ -747,7 +747,7 @@ private static jakarta.persistence.criteria.Order toJpaOrder(Order order, From<?
 		}
 	}
 
-	static <T> Expression<T> toExpressionRecursively(From<?, ?> from, PropertyPath property) {
+	public static <T> Expression<T> toExpressionRecursively(From<?, ?> from, PropertyPath property) {
 		return toExpressionRecursively(from, property, false);
 	}
 
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 <S> Domain type
- * @param <R> Result type
- * @author Greg Turnquist
- * @author Mark Paluch
- * @author Jens Schauder
- * @author J.R. Onyschak
- * @since 2.6
- */
-class FetchableFluentQueryByExample<S, R> extends FluentQuerySupport<S, R> implements FetchableFluentQuery<R> {
-
-	private final Example<S> example;
-	private final Function<Sort, TypedQuery<S>> finder;
-	private final Function<Example<S>, Long> countOperation;
-	private final Function<Example<S>, Boolean> existsOperation;
-	private final EntityManager entityManager;
-	private final EscapeCharacter escapeCharacter;
-
-	public FetchableFluentQueryByExample(Example<S> example, Function<Sort, TypedQuery<S>> finder,
-			Function<Example<S>, Long> countOperation, Function<Example<S>, Boolean> existsOperation,
-			EntityManager entityManager, EscapeCharacter escapeCharacter) {
-		this(example, example.getProbeType(), (Class<R>) example.getProbeType(), Sort.unsorted(), Collections.emptySet(),
-				finder, countOperation, existsOperation, entityManager, escapeCharacter);
-	}
-
-	private FetchableFluentQueryByExample(Example<S> example, Class<S> entityType, Class<R> returnType, Sort sort,
-			Collection<String> properties, Function<Sort, TypedQuery<S>> finder, Function<Example<S>, Long> countOperation,
-			Function<Example<S>, 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<R> 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 <NR> FetchableFluentQuery<NR> as(Class<NR> 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<R> project(Collection<String> properties) {
-
-		return new FetchableFluentQueryByExample<>(example, entityType, resultType, sort, mergeProperties(properties),
-				finder, countOperation, existsOperation, entityManager, escapeCharacter);
-	}
-
-	@Override
-	public R oneValue() {
-
-		TypedQuery<S> limitedQuery = createSortedAndProjectedQuery();
-		limitedQuery.setMaxResults(2); // Never need more than 2 values
-
-		List<S> 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<S> limitedQuery = createSortedAndProjectedQuery();
-		limitedQuery.setMaxResults(1); // Never need more than 1 value
-
-		List<S> results = limitedQuery.getResultList();
-
-		return results.isEmpty() ? null : getConversionFunction().apply(results.get(0));
-	}
-
-	@Override
-	public List<R> all() {
-
-		List<S> resultList = createSortedAndProjectedQuery().getResultList();
-
-		return convert(resultList);
-	}
-
-	@Override
-	public Page<R> page(Pageable pageable) {
-		return pageable.isUnpaged() ? new PageImpl<>(all()) : readPage(pageable);
-	}
-
-	@Override
-	public Stream<R> stream() {
-
-		return createSortedAndProjectedQuery() //
-				.getResultStream() //
-				.map(getConversionFunction());
-	}
-
-	@Override
-	public long count() {
-		return countOperation.apply(example);
-	}
-
-	@Override
-	public boolean exists() {
-		return existsOperation.apply(example);
-	}
-
-	private Page<R> readPage(Pageable pageable) {
-
-		TypedQuery<S> pagedQuery = createSortedAndProjectedQuery();
-
-		if (pageable.isPaged()) {
-			pagedQuery.setFirstResult(PageableUtils.getOffsetAsInteger(pageable));
-			pagedQuery.setMaxResults(pageable.getPageSize());
-		}
-
-		List<R> paginatedResults = convert(pagedQuery.getResultList());
-
-		return PageableExecutionUtils.getPage(paginatedResults, pageable, () -> countOperation.apply(example));
-	}
-
-	private TypedQuery<S> createSortedAndProjectedQuery() {
-
-		TypedQuery<S> query = finder.apply(sort);
-
-		if (!properties.isEmpty()) {
-			query.setHint(EntityGraphFactory.HINT, EntityGraphFactory.create(entityManager, entityType, properties));
-		}
-
-		return query;
-	}
-
-	private List<R> convert(List<S> resultList) {
-
-		Function<Object, R> conversionFunction = getConversionFunction();
-		List<R> mapped = new ArrayList<>(resultList.size());
-
-		for (S s : resultList) {
-			mapped.add(conversionFunction.apply(s));
-		}
-		return mapped;
-	}
-
-	private Function<Object, R> 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..7282fcb8f7 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
@@ -71,7 +71,7 @@ private FetchableFluentQueryByPredicate(Predicate predicate, Class<S> entityType
 			BiFunction<Sort, Pageable, AbstractJPAQuery<?, ?>> pagedFinder, Function<Predicate, Long> countOperation,
 			Function<Predicate, Boolean> existsOperation, EntityManager entityManager) {
 
-		super(resultType, sort, properties, entityType);
+		super(resultType, sort, 0, properties, entityType);
 		this.predicate = predicate;
 		this.finder = finder;
 		this.pagedFinder = pagedFinder;
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..9e4849b68b 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
@@ -26,10 +26,14 @@
 import java.util.stream.Stream;
 
 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.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.support.PageableUtils;
 import org.springframework.data.repository.query.FluentQuery;
@@ -50,26 +54,27 @@ class FetchableFluentQueryBySpecification<S, R> extends FluentQuerySupport<S, R>
 
 	private final Specification<S> spec;
 	private final Function<Sort, TypedQuery<S>> finder;
+	private final Scroller<S> scroll;
 	private final Function<Specification<S>, Long> countOperation;
 	private final Function<Specification<S>, Boolean> existsOperation;
 	private final EntityManager entityManager;
 
-	public FetchableFluentQueryBySpecification(Specification<S> spec, Class<S> entityType, Sort sort,
-			Collection<String> properties, Function<Sort, TypedQuery<S>> finder,
-			Function<Specification<S>, Long> countOperation, Function<Specification<S>, Boolean> existsOperation,
-			EntityManager entityManager) {
-		this(spec, entityType, (Class<R>) entityType, Sort.unsorted(), Collections.emptySet(), finder, countOperation,
-				existsOperation, entityManager);
+	public FetchableFluentQueryBySpecification(Specification<S> spec, Class<S> entityType,
+			Function<Sort, TypedQuery<S>> finder, Scroller<S> scroller, Function<Specification<S>, Long> countOperation,
+			Function<Specification<S>, Boolean> existsOperation, EntityManager entityManager) {
+		this(spec, entityType, (Class<R>) entityType, Sort.unsorted(), 0, Collections.emptySet(), finder, scroller,
+				countOperation, existsOperation, entityManager);
 	}
 
 	private FetchableFluentQueryBySpecification(Specification<S> spec, Class<S> entityType, Class<R> resultType,
-			Sort sort, Collection<String> properties, Function<Sort, TypedQuery<S>> finder,
+			Sort sort, int limit, Collection<String> properties, Function<Sort, TypedQuery<S>> finder, Scroller<S> scroller,
 			Function<Specification<S>, Long> countOperation, Function<Specification<S>, Boolean> existsOperation,
 			EntityManager entityManager) {
 
-		super(resultType, sort, properties, entityType);
+		super(resultType, sort, limit, properties, entityType);
 		this.spec = spec;
 		this.finder = finder;
+		this.scroll = scroller;
 		this.countOperation = countOperation;
 		this.existsOperation = existsOperation;
 		this.entityManager = entityManager;
@@ -80,8 +85,16 @@ public FetchableFluentQuery<R> 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<R> 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 +105,15 @@ public <NR> FetchableFluentQuery<NR> as(Class<NR> 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<R> project(Collection<String> 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 +145,14 @@ public List<R> all() {
 		return convert(createSortedAndProjectedQuery().getResultList());
 	}
 
+	@Override
+	public Window<R> scroll(ScrollPosition scrollPosition) {
+
+		Assert.notNull(scrollPosition, "ScrollPosition must not be null");
+
+		return scroll.scroll(sort, limit, scrollPosition).map(getConversionFunction());
+	}
+
 	@Override
 	public Page<R> page(Pageable pageable) {
 		return pageable.isUnpaged() ? new PageImpl<>(all()) : readPage(pageable);
@@ -163,6 +184,10 @@ private TypedQuery<S> createSortedAndProjectedQuery() {
 			query.setHint(EntityGraphFactory.HINT, EntityGraphFactory.create(entityManager, entityType, properties));
 		}
 
+		if (limit != 0) {
+			query.setMaxResults(limit);
+		}
+
 		return query;
 	}
 
@@ -194,4 +219,42 @@ private List<R> convert(List<S> resultList) {
 	private Function<Object, R> getConversionFunction() {
 		return getConversionFunction(entityType, resultType);
 	}
+
+	interface ScrollQueryFactory<T> {
+
+		TypedQuery<T> createQuery(Sort sort, ScrollPosition scrollPosition);
+
+	}
+
+	static class Scroller<T> {
+
+		private final ScrollQueryFactory<T> scrollFunction;
+		private final JpaEntityInformation<T, ?> entity;
+
+		Scroller(ScrollQueryFactory<T> scrollFunction, JpaEntityInformation<T, ?> entity) {
+			this.scrollFunction = scrollFunction;
+			this.entity = entity;
+		}
+
+		public Window<T> scroll(Sort sort, int limit, ScrollPosition scrollPosition) {
+
+			TypedQuery<T> query = scrollFunction.createQuery(sort, scrollPosition);
+
+			if (limit > 0) {
+				query.setMaxResults(limit + 1);
+			}
+
+			List<T> result = query.getResultList();
+
+			if (scrollPosition instanceof KeysetScrollPosition keyset) {
+				return ScrollUtil.createWindow(sort, limit, keyset.getDirection(), entity, result);
+			}
+
+			if (scrollPosition instanceof OffsetScrollPosition offset) {
+				return ScrollUtil.createWindow(result, limit, OffsetScrollPosition.positionFunction(offset.getOffset()));
+			}
+
+			throw new UnsupportedOperationException("ScrollPosition " + scrollPosition + " not supported");
+		}
+	}
 }
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..6f6a15eaae 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
@@ -32,21 +32,25 @@
  * @param <R> The resulting type of the query.
  * @author Greg Turnquist
  * @author Jens Schauder
+ * @author Mark Paluch
  * @since 2.6
  */
 abstract class FluentQuerySupport<S, R> {
 
 	protected final Class<R> resultType;
 	protected final Sort sort;
+	protected final int limit;
 	protected final Set<String> properties;
 	protected final Class<S> entityType;
 
 	private final SpelAwareProxyProjectionFactory projectionFactory = new SpelAwareProxyProjectionFactory();
 
-	FluentQuerySupport(Class<R> resultType, Sort sort, @Nullable Collection<String> properties, Class<S> entityType) {
+	FluentQuerySupport(Class<R> resultType, Sort sort, int limit, @Nullable Collection<String> properties,
+			Class<S> entityType) {
 
 		this.resultType = resultType;
 		this.sort = sort;
+		this.limit = limit;
 
 		if (properties != null) {
 			this.properties = new HashSet<>(properties);
@@ -78,4 +82,5 @@ final Function<Object, R> getConversionFunction(Class<S> inputType, Class<R> tar
 
 		return o -> DefaultConversionService.getSharedInstance().convert(o, targetType);
 	}
+
 }
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<T, ID> extends EntityInformation<T, ID>, J
 	 *
 	 * @return
 	 */
-	Iterable<String> getIdAttributeNames();
+	Collection<String> getIdAttributeNames();
 
 	/**
 	 * Extracts the value for the given id attribute from a composite id
@@ -81,4 +84,14 @@ public interface JpaEntityInformation<T, ID> extends EntityInformation<T, ID>, 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<String, Object> getKeyset(Iterable<String> 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..647d117a5e 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<T, ID> 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<String> getIdAttributeNames() {
+	public Collection<String> getIdAttributeNames() {
 
 		List<String> attributeNames = new ArrayList<>(idMetadata.attributes.size());
 
@@ -222,6 +225,29 @@ public boolean isNew(T entity) {
 		return versionAttribute.map(it -> wrapper.getPropertyValue(it.getName()) == null).orElse(true);
 	}
 
+	@Override
+	public Map<String, Object> getKeyset(Iterable<String> propertyPaths, T entity) {
+
+		// TODO: proxy business?
+		BeanWrapper entityWrapper = new DirectFieldAccessFallbackBeanWrapper(entity);
+
+		Map<String, Object> 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/KeysetScrollSpecification.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/KeysetScrollSpecification.java
new file mode 100644
index 0000000000..132632113b
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/KeysetScrollSpecification.java
@@ -0,0 +1,194 @@
+/*
+ * 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.support;
+
+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.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.jpa.domain.Specification;
+import org.springframework.data.jpa.repository.query.QueryUtils;
+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
+ */
+record KeysetScrollSpecification<T> (KeysetScrollPosition position, Sort sort,
+		JpaEntityInformation<?, ?> entity) implements Specification<T> {
+
+	KeysetScrollSpecification(KeysetScrollPosition position, Sort sort, JpaEntityInformation<?, ?> entity) {
+
+		this.position = position;
+		this.entity = entity;
+
+		KeysetScrollDirector director = KeysetScrollDirector.of(position.getDirection());
+
+		if (entity.hasCompositeId()) {
+			sort = sort.and(Sort.by(entity.getIdAttributeNames().toArray(new String[0])));
+		} else {
+			sort = sort.and(Sort.by(entity.getIdAttribute().getName()));
+		}
+
+		this.sort = director.getSortOrders(sort);
+	}
+
+	@Override
+	public Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) {
+
+		// TODO selection
+		// accept entities, make sure for the selection to include keyset
+
+		KeysetScrollDirector director = KeysetScrollDirector.of(position.getDirection());
+
+		return director.createQuery(position, sort, root, criteriaBuilder);
+	}
+
+	/**
+	 * Director for keyset scrolling.
+	 */
+	static class KeysetScrollDirector {
+
+		private static final KeysetScrollDirector forward = new KeysetScrollDirector();
+		private static final KeysetScrollDirector reverse = new ReverseKeysetScrollDirector();
+
+		/**
+		 * Factory method to obtain the right {@link KeysetScrollDirector}.
+		 *
+		 * @param direction
+		 * @return
+		 */
+		public static KeysetScrollDirector of(Direction direction) {
+			return direction == Direction.Forward ? forward : reverse;
+		}
+
+		public Sort getSortOrders(Sort sort) {
+			return sort;
+		}
+
+		@Nullable
+		public Predicate createQuery(KeysetScrollPosition keyset, Sort sort, From<?, ?> from, CriteriaBuilder cb) {
+
+			Map<String, Object> keysetValues = keyset.getKeys();
+
+			// first query doesn't come with a keyset
+			if (keysetValues.isEmpty()) {
+				return null;
+			}
+
+			List<String> sortKeys = sort.stream().map(Sort.Order::getProperty).toList();
+
+			if (!keysetValues.keySet().containsAll(sortKeys)) {
+				throw new IllegalStateException("KeysetScrollPosition does not contain all keyset values");
+			}
+
+			// build matrix query for keyset paging that contains sort^2 queries
+			// reflecting a query that follows sort order semantics starting from the last returned keyset
+			List<Predicate> or = new ArrayList<>();
+			for (int i = 0; i < sortKeys.size(); i++) {
+
+				List<Predicate> sortConstraint = new ArrayList<>();
+
+				for (int j = 0; j < sortKeys.size(); j++) {
+
+					String sortSegment = sortKeys.get(j);
+					PropertyPath property = PropertyPath.from(sortSegment, from.getJavaType());
+					Expression<Comparable> propertyExpression = QueryUtils.toExpressionRecursively(from, property);
+					Sort.Order sortOrder = sort.getOrderFor(sortSegment);
+					Comparable<?> o = (Comparable<?>) keysetValues.get(sortSegment);
+
+					if (j >= i) { // tail segment
+
+						sortConstraint.add(getComparator(sortOrder, propertyExpression, o, cb));
+						break;
+					}
+
+					sortConstraint.add(cb.equal(propertyExpression, o));
+				}
+
+				if (!sortConstraint.isEmpty()) {
+					or.add(cb.and(sortConstraint.toArray(new Predicate[0])));
+				}
+			}
+
+			if (or.isEmpty()) {
+				return null;
+			}
+
+			return cb.or(or.toArray(new Predicate[0]));
+		}
+
+		protected Predicate getComparator(Sort.Order sortOrder, Expression<Comparable> propertyExpression,
+				Comparable object, CriteriaBuilder cb) {
+
+			return sortOrder.isAscending() ? cb.greaterThan(propertyExpression, object)
+					: cb.lessThan(propertyExpression, object);
+		}
+
+		public <T> void postPostProcessResults(List<T> result) {
+
+		}
+
+	}
+
+	/**
+	 * Reverse scrolling director 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 ReverseKeysetScrollDirector extends KeysetScrollDirector {
+
+		public Sort getSortOrders(Sort sort) {
+
+			List<Sort.Order> orders = new ArrayList<>();
+			for (Sort.Order order : sort) {
+				orders.add(new Sort.Order(order.isAscending() ? Sort.Direction.DESC : Sort.Direction.ASC, order.getProperty()));
+			}
+
+			return Sort.by(orders);
+		}
+
+		@Override
+		protected Predicate getComparator(Sort.Order sortOrder, Expression<Comparable> propertyExpression,
+				Comparable object, CriteriaBuilder cb) {
+			return sortOrder.isAscending() ? cb.greaterThanOrEqualTo(propertyExpression, object)
+					: cb.lessThanOrEqualTo(propertyExpression, object);
+		}
+
+		@Override
+		public <T> void postPostProcessResults(List<T> result) {
+			// flip direction of the result list as we need to accomodate for the flipped sort order for proper offset
+			// querying.
+			Collections.reverse(result);
+		}
+	}
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/ScrollUtil.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/ScrollUtil.java
new file mode 100644
index 0000000000..17cd645bd0
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/ScrollUtil.java
@@ -0,0 +1,70 @@
+/*
+ * 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.support;
+
+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.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.KeysetScrollSpecification.KeysetScrollDirector;
+
+/**
+ * @author Mark Paluch
+ */
+class ScrollUtil {
+
+	static <T> Window<T> createWindow(Sort sort, int limit, Direction direction, JpaEntityInformation<T, ?> entity,
+			List<T> result) {
+
+		KeysetScrollDirector director = KeysetScrollDirector.of(direction);
+
+		director.postPostProcessResults(result);
+
+		IntFunction<KeysetScrollPosition> positionFunction = value -> {
+
+			T object = result.get(value);
+			Map<String, Object> keys = entity.getKeyset(sort.stream().map(Order::getProperty).toList(), object);
+
+			return KeysetScrollPosition.of(keys);
+		};
+
+		return createWindow(result, limit, positionFunction);
+	}
+
+	static <T> Window<T> createWindow(List<T> result, int limit, IntFunction<? extends ScrollPosition> positionFunction) {
+		return Window.from(getSubList(result, limit), positionFunction, hasMoreElements(result, limit));
+	}
+
+	private static boolean hasMoreElements(List<?> result, int limit) {
+		return !result.isEmpty() && result.size() > limit;
+	}
+
+	private static <T> List<T> getSubList(List<T> result, int limit) {
+
+		if (limit > 0 && result.size() > limit) {
+			return result.subList(0, limit);
+		}
+
+		return result;
+	}
+
+}
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..b983545188 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;
@@ -53,6 +55,8 @@
 import org.springframework.data.jpa.repository.EntityGraph;
 import org.springframework.data.jpa.repository.query.EscapeCharacter;
 import org.springframework.data.jpa.repository.query.QueryUtils;
+import org.springframework.data.jpa.repository.support.FetchableFluentQueryBySpecification.ScrollQueryFactory;
+import org.springframework.data.jpa.repository.support.FetchableFluentQueryBySpecification.Scroller;
 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 +511,39 @@ public <S extends T, R> R findBy(Specification<T> spec, Function<FetchableFluent
 		Assert.notNull(spec, "Specification must not be null");
 		Assert.notNull(queryFunction, "Query function must not be null");
 
-		Function<Sort, TypedQuery<T>> finder = sort -> getQuery(spec, getDomainClass(), sort);
+		return doFindBy(spec, getDomainClass(), queryFunction);
+	}
+
+	private <S extends T, R> R doFindBy(Specification<T> spec, Class<T> domainClass,
+			Function<FetchableFluentQuery<S>, R> queryFunction) {
+
+		Assert.notNull(spec, "Specification must not be null");
+		Assert.notNull(queryFunction, "Query function must not be null");
+
+		ScrollQueryFactory<T> scrollFunction = (sort, scrollPosition) -> {
+
+			Specification<T> specToUse = spec;
+
+			if (scrollPosition instanceof KeysetScrollPosition keyset) {
+				KeysetScrollSpecification<T> keysetSpec = new KeysetScrollSpecification<>(keyset, sort, entityInformation);
+				sort = keysetSpec.sort();
+				specToUse = specToUse.and(keysetSpec);
+			}
+
+			TypedQuery<T> query = getQuery(specToUse, domainClass, sort);
+
+			if (scrollPosition instanceof OffsetScrollPosition offset) {
+				query.setFirstResult(Math.toIntExact(offset.getOffset()));
+			}
 
-		FetchableFluentQuery<R> fluentQuery = new FetchableFluentQueryBySpecification<T, R>(spec, getDomainClass(),
-				Sort.unsorted(), null, finder, this::count, this::exists, this.em);
+			return query;
+		};
+
+		Function<Sort, TypedQuery<T>> finder = sort -> getQuery(spec, domainClass, sort);
+
+		Scroller<T> scroller = new Scroller<>(scrollFunction, entityInformation);
+		FetchableFluentQuery<T> fluentQuery = new FetchableFluentQueryBySpecification<>(spec, domainClass, finder, scroller,
+				this::count, this::exists, this.em);
 
 		return queryFunction.apply((FetchableFluentQuery<S>) fluentQuery);
 	}
@@ -544,7 +577,6 @@ public <S extends T> boolean exists(Example<S> example) {
 		return query.setMaxResults(1).getResultList().size() == 1;
 	}
 
-
 	@Override
 	public <S extends T> List<S> findAll(Example<S> example) {
 		return getQuery(new ExampleSpecification<>(example, escapeCharacter), example.getProbeType(), Sort.unsorted())
@@ -572,21 +604,12 @@ public <S extends T, R> R findBy(Example<S> example, Function<FetchableFluentQue
 		Assert.notNull(example, "Sample must not be null");
 		Assert.notNull(queryFunction, "Query function must not be null");
 
-		Function<Sort, TypedQuery<S>> finder = sort -> {
-
-			ExampleSpecification<S> spec = new ExampleSpecification<>(example, escapeCharacter);
-			Class<S> probeType = example.getProbeType();
-
-			return getQuery(spec, probeType, sort);
-		};
-
-		FetchableFluentQuery<S> fluentQuery = new FetchableFluentQueryByExample<>(example, finder, this::count,
-				this::exists, this.em, this.escapeCharacter);
+		ExampleSpecification<S> spec = new ExampleSpecification<>(example, escapeCharacter);
+		Class<S> probeType = example.getProbeType();
 
-		return queryFunction.apply(fluentQuery);
+		return doFindBy((Specification<T>) spec, (Class<T>) 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<Item> 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<Item> 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..999fe307bc 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,58 @@ void findAllByTypedSpecialUserExampleShouldReturnSubTypesOfRepositoryEntity() {
 		assertThat(result).hasSize(1);
 	}
 
+	@Test // GH-2878
+	void scrollByOffset() {
+
+		User john1 = new User("John", "Doe", "john@doe1.com");
+		User john2 = new User("John", "Doe", "john@doe2.com");
+		User jane1 = new User("Jane", "Doe", "jane@doe1.com");
+		User jane2 = new User("Jane", "Doe", "jane@doe2.com");
+
+		repository.saveAllAndFlush(Arrays.asList(john1, john2, jane1, jane2));
+
+		Example<User> example = Example.of(new User("J", null, null),
+				matching().withMatcher("firstname", GenericPropertyMatcher::startsWith).withIgnorePaths("age", "createdAt",
+						"dateOfBirth"));
+		Window<User> firstWindow = repository.findBy(example,
+				q -> q.limit(2).sortBy(Sort.by("firstname")).scroll(OffsetScrollPosition.initial()));
+
+		assertThat(firstWindow).hasSize(2).containsOnly(jane1, jane2);
+		assertThat(firstWindow.hasNext()).isTrue();
+
+		Window<User> nextWindow = repository.findBy(example,
+				q -> q.limit(2).sortBy(Sort.by("firstname")).scroll(firstWindow.positionAt(1)));
+
+		assertThat(nextWindow).hasSize(2).containsOnly(john1, john2);
+		assertThat(nextWindow.hasNext()).isFalse();
+	}
+
+	@Test // GH-2878
+	void scrollByKeyset() {
+
+		User john1 = new User("John", "Doe", "john@doe1.com");
+		User john2 = new User("John", "Doe", "john@doe2.com");
+		User jane1 = new User("Jane", "Doe", "jane@doe1.com");
+		User jane2 = new User("Jane", "Doe", "jane@doe2.com");
+
+		repository.saveAllAndFlush(Arrays.asList(john1, john2, jane1, jane2));
+
+		Example<User> example = Example.of(new User("J", null, null),
+				matching().withMatcher("firstname", GenericPropertyMatcher::startsWith).withIgnorePaths("age", "createdAt",
+						"dateOfBirth"));
+		Window<User> 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<User> nextWindow = repository.findBy(example,
+				q -> q.limit(2).sortBy(Sort.by("firstname", "emailAddress")).scroll(firstWindow.positionAt(0)));
+
+		assertThat(nextWindow).hasSize(2).containsOnly(jane2, john1);
+		assertThat(nextWindow.hasNext()).isTrue();
+	}
+
 	@Test // DATAJPA-491
 	void sortByNestedAssociationPropertyWithSortInPageable() {
 
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 <a href="download.oracle.com/otn-pub/jcp/persistence-2_1-fr-eval-spec/JavaPersistence.pdf">Final JPA 2.1
  *      Specification 2.4.1.3 Derived Identities Example 2</a>
  */
-public interface ItemRepository extends JpaRepository<Item, ItemId> {}
+public interface ItemRepository extends JpaRepository<Item, ItemId>, JpaSpecificationExecutor<Item> {}
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/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<ID> getIdType() {
 		}
 
 		@Override
-		public Iterable<String> getIdAttributeNames() {
+		public Collection<String> getIdAttributeNames() {
 			return Collections.emptySet();
 		}
 
@@ -110,6 +112,11 @@ public boolean hasCompositeId() {
 		public Object getCompositeIdAttributeValue(Object id, String idAttribute) {
 			return null;
 		}
+
+		@Override
+		public Map<String, Object> getKeyset(Iterable<String> propertyPaths, T entity) {
+			return null;
+		}
 	}
 
 	@Entity(name = "AnotherNamedUser")

From 0af99ddb15ccc049a8720ae18563c924d88c299c Mon Sep 17 00:00:00 2001
From: Mark Paluch <mpaluch@vmware.com>
Date: Wed, 22 Mar 2023 14:36:16 +0100
Subject: [PATCH 3/7] Hacking on PartTreeJpaQuery

---
 .../jpa/repository/query/CollectionUtils.java |  61 ++++++++++
 .../query/JpaKeysetScrollQueryCreator.java    |  82 ++++++++++++++
 .../jpa/repository/query/JpaQueryCreator.java |   8 +-
 .../repository/query/JpaQueryExecution.java   |  39 ++++++-
 .../jpa/repository/query/JpaQueryFactory.java |  10 ++
 .../KeysetScrollSpecification.java            | 105 +++++++++++------
 .../data/jpa/repository/query/NamedQuery.java |   4 +
 .../repository/query/PartTreeJpaQuery.java    |  39 +++++--
 .../jpa/repository/query/ScrollDelegate.java  | 106 ++++++++++++++++++
 .../FetchableFluentQueryBySpecification.java  |  45 +++-----
 .../jpa/repository/support/ScrollUtil.java    |  70 ------------
 .../support/SimpleJpaRepository.java          |   9 +-
 .../jpa/repository/UserRepositoryTests.java   |  65 ++++++++++-
 .../query/CollectionUtilsUnitTests.java       |  46 ++++++++
 .../jpa/repository/sample/UserRepository.java |   5 +
 15 files changed, 539 insertions(+), 155 deletions(-)
 create mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/CollectionUtils.java
 create mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java
 rename spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/{support => query}/KeysetScrollSpecification.java (63%)
 create mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ScrollDelegate.java
 delete mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/ScrollUtil.java
 create mode 100644 spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/CollectionUtilsUnitTests.java

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..6777fede97
--- /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
+	 * @param list
+	 * @return
+	 * @param <T>
+	 */
+	public static <T> List<T> getFirst(int count, List<T> 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
+	 * @param list
+	 * @return
+	 * @param <T>
+	 */
+	public static <T> List<T> getLast(int count, List<T> 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..06992b8694
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java
@@ -0,0 +1,82 @@
+/*
+ * 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;
+
+/**
+ * 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<? extends Object> complete(Predicate predicate, Sort sort, CriteriaQuery<?> query,
+			CriteriaBuilder builder, Root<?> root) {
+
+		KeysetScrollSpecification<Object> 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<String> getRequiredSelection(Sort sort, ReturnedType returnedType) {
+
+		Sort sortToUse = KeysetScrollSpecification.createSort(scrollPosition, sort, entityInformation);
+
+		Set<String> 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<ParameterMetadata<?>> getParameterExpressions() {
 
 	@Override
 	protected Predicate create(Part part, Iterator<Object> iterator) {
-
 		return toPredicate(part, root);
 	}
 
@@ -158,9 +157,10 @@ protected CriteriaQuery<? extends Object> complete(@Nullable Predicate predicate
 
 		if (returnedType.needsCustomConstruction()) {
 
+			Collection<String> requiredSelection = getRequiredSelection(sort, returnedType);
 			List<Selection<?>> 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<? extends Object> complete(@Nullable Predicate predicate
 		return predicate == null ? select : select.where(predicate);
 	}
 
+	Collection<String> 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/support/KeysetScrollSpecification.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java
similarity index 63%
rename from spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/KeysetScrollSpecification.java
rename to spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java
index 132632113b..2391566d1c 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/KeysetScrollSpecification.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java
@@ -13,7 +13,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.springframework.data.jpa.repository.support;
+package org.springframework.data.jpa.repository.query;
 
 import jakarta.persistence.criteria.CriteriaBuilder;
 import jakarta.persistence.criteria.CriteriaQuery;
@@ -30,8 +30,9 @@
 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.data.jpa.domain.Specification;
-import org.springframework.data.jpa.repository.query.QueryUtils;
+import org.springframework.data.jpa.repository.support.JpaEntityInformation;
 import org.springframework.data.mapping.PropertyPath;
 import org.springframework.lang.Nullable;
 
@@ -42,39 +43,54 @@
  * @author Christoph Strobl
  * @since 3.1
  */
-record KeysetScrollSpecification<T> (KeysetScrollPosition position, Sort sort,
+public record KeysetScrollSpecification<T> (KeysetScrollPosition position, Sort sort,
 		JpaEntityInformation<?, ?> entity) implements Specification<T> {
 
-	KeysetScrollSpecification(KeysetScrollPosition position, Sort sort, JpaEntityInformation<?, ?> entity) {
+	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
+	 * @param sort
+	 * @param entity
+	 * @return
+	 */
+	public static Sort createSort(KeysetScrollPosition position, Sort sort, JpaEntityInformation<?, ?> entity) {
 
 		KeysetScrollDirector director = KeysetScrollDirector.of(position.getDirection());
 
+		Sort sortToUse;
 		if (entity.hasCompositeId()) {
-			sort = sort.and(Sort.by(entity.getIdAttributeNames().toArray(new String[0])));
+			sortToUse = sort.and(Sort.by(entity.getIdAttributeNames().toArray(new String[0])));
 		} else {
-			sort = sort.and(Sort.by(entity.getIdAttribute().getName()));
+			sortToUse = sort.and(Sort.by(entity.getRequiredIdAttribute().getName()));
 		}
 
-		this.sort = director.getSortOrders(sort);
+		return director.getSortOrders(sortToUse);
 	}
 
 	@Override
 	public Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) {
+		return createPredicate(root, criteriaBuilder);
+	}
 
-		// TODO selection
-		// accept entities, make sure for the selection to include keyset
+	@Nullable
+	public Predicate createPredicate(Root<?> root, CriteriaBuilder criteriaBuilder) {
 
 		KeysetScrollDirector director = KeysetScrollDirector.of(position.getDirection());
-
 		return director.createQuery(position, sort, root, criteriaBuilder);
 	}
 
 	/**
 	 * Director for keyset scrolling.
 	 */
+	@SuppressWarnings("rawtypes")
 	static class KeysetScrollDirector {
 
 		private static final KeysetScrollDirector forward = new KeysetScrollDirector();
@@ -104,48 +120,58 @@ public Predicate createQuery(KeysetScrollPosition keyset, Sort sort, From<?, ?>
 				return null;
 			}
 
-			List<String> sortKeys = sort.stream().map(Sort.Order::getProperty).toList();
+			// build matrix query for keyset paging that contains sort^2 queries
+			// reflecting a query that follows sort order semantics starting from the last returned keyset
+			List<Predicate> or = createPredicates(sort, from, cb, keysetValues);
 
-			if (!keysetValues.keySet().containsAll(sortKeys)) {
-				throw new IllegalStateException("KeysetScrollPosition does not contain all keyset values");
+			if (or.isEmpty()) {
+				return null;
 			}
 
-			// build matrix query for keyset paging that contains sort^2 queries
-			// reflecting a query that follows sort order semantics starting from the last returned keyset
+			return cb.or(or.toArray(new Predicate[0]));
+		}
+
+		List<Predicate> createPredicates(Sort sort, From<?, ?> from, CriteriaBuilder cb, Map<String, Object> keysetValues) {
+
 			List<Predicate> or = new ArrayList<>();
-			for (int i = 0; i < sortKeys.size(); i++) {
+
+			int i = 0;
+			// progressive query building
+			for (Order order : sort) {
+
+				if (!keysetValues.containsKey(order.getProperty())) {
+					throw new IllegalStateException("KeysetScrollPosition does not contain all keyset values");
+				}
 
 				List<Predicate> sortConstraint = new ArrayList<>();
 
-				for (int j = 0; j < sortKeys.size(); j++) {
+				int j = 0;
+				for (Sort.Order inner : sort) {
 
-					String sortSegment = sortKeys.get(j);
-					PropertyPath property = PropertyPath.from(sortSegment, from.getJavaType());
-					Expression<Comparable> propertyExpression = QueryUtils.toExpressionRecursively(from, property);
-					Sort.Order sortOrder = sort.getOrderFor(sortSegment);
-					Comparable<?> o = (Comparable<?>) keysetValues.get(sortSegment);
+					Expression<Comparable> propertyExpression = getExpression(from, inner);
+					Comparable<?> o = (Comparable<?>) keysetValues.get(inner.getProperty());
 
 					if (j >= i) { // tail segment
 
-						sortConstraint.add(getComparator(sortOrder, propertyExpression, o, cb));
+						sortConstraint.add(getComparator(inner, propertyExpression, o, cb));
 						break;
 					}
 
 					sortConstraint.add(cb.equal(propertyExpression, o));
+					j++;
 				}
 
 				if (!sortConstraint.isEmpty()) {
 					or.add(cb.and(sortConstraint.toArray(new Predicate[0])));
 				}
-			}
 
-			if (or.isEmpty()) {
-				return null;
+				i++;
 			}
 
-			return cb.or(or.toArray(new Predicate[0]));
+			return or;
 		}
 
+		@SuppressWarnings("unchecked")
 		protected Predicate getComparator(Sort.Order sortOrder, Expression<Comparable> propertyExpression,
 				Comparable object, CriteriaBuilder cb) {
 
@@ -153,10 +179,18 @@ protected Predicate getComparator(Sort.Order sortOrder, Expression<Comparable> p
 					: cb.lessThan(propertyExpression, object);
 		}
 
-		public <T> void postPostProcessResults(List<T> result) {
+		protected static <T> Expression<T> getExpression(From<?, ?> from, Order order) {
+			PropertyPath property = PropertyPath.from(order.getProperty(), from.getJavaType());
+			return QueryUtils.toExpressionRecursively(from, property);
+		}
 
+		public <T> List<T> postProcessResults(List<T> result) {
+			return result;
 		}
 
+		public <T> List<T> getResultWindow(List<T> list, int limit) {
+			return CollectionUtils.getFirst(limit, list);
+		}
 	}
 
 	/**
@@ -176,18 +210,23 @@ public Sort getSortOrders(Sort sort) {
 			return Sort.by(orders);
 		}
 
+		@SuppressWarnings({ "rawtypes", "unchecked" })
 		@Override
 		protected Predicate getComparator(Sort.Order sortOrder, Expression<Comparable> propertyExpression,
 				Comparable object, CriteriaBuilder cb) {
-			return sortOrder.isAscending() ? cb.greaterThanOrEqualTo(propertyExpression, object)
-					: cb.lessThanOrEqualTo(propertyExpression, object);
+			return sortOrder.isAscending() ? cb.greaterThan(propertyExpression, object)
+					: cb.lessThan(propertyExpression, object);
 		}
 
 		@Override
-		public <T> void postPostProcessResults(List<T> result) {
-			// flip direction of the result list as we need to accomodate for the flipped sort order for proper offset
-			// querying.
+		public <T> List<T> postProcessResults(List<T> result) {
 			Collections.reverse(result);
+			return result;
+		}
+
+		@Override
+		public <T> List<T> getResultWindow(List<T> list, int limit) {
+			return CollectionUtils.getLast(limit, list);
 		}
 	}
 
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<?, Object> 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<Long> 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..105095b101
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ScrollDelegate.java
@@ -0,0 +1,106 @@
+/*
+ * 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.query.KeysetScrollSpecification.KeysetScrollDirector;
+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<T> {
+
+	private final JpaEntityInformation<T, ?> entity;
+
+	public ScrollDelegate(JpaEntityInformation<T, ?> 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<T> 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<T> 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 <T> Window<T> createWindow(Sort sort, int limit, Direction direction,
+			JpaEntityInformation<T, ?> entity, List<T> result) {
+
+		KeysetScrollDirector director = KeysetScrollDirector.of(direction);
+		List<T> resultsToUse = director.postProcessResults(result);
+
+		IntFunction<KeysetScrollPosition> positionFunction = value -> {
+
+			T object = result.get(value);
+			Map<String, Object> keys = entity.getKeyset(sort.stream().map(Order::getProperty).toList(), object);
+
+			return KeysetScrollPosition.of(keys);
+		};
+
+		return Window.from(director.getResultWindow(resultsToUse, limit), positionFunction, hasMoreElements(result, limit));
+	}
+
+	private static <T> Window<T> createWindow(List<T> result, int limit,
+			IntFunction<? extends ScrollPosition> 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/FetchableFluentQueryBySpecification.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryBySpecification.java
index 9e4849b68b..69a02fb5a2 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
@@ -26,8 +26,6 @@
 import java.util.stream.Stream;
 
 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.PageImpl;
 import org.springframework.data.domain.Pageable;
@@ -35,6 +33,7 @@
 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;
@@ -54,27 +53,28 @@ class FetchableFluentQueryBySpecification<S, R> extends FluentQuerySupport<S, R>
 
 	private final Specification<S> spec;
 	private final Function<Sort, TypedQuery<S>> finder;
-	private final Scroller<S> scroll;
+	private final SpecificationScrollDelegate<S> scroll;
 	private final Function<Specification<S>, Long> countOperation;
 	private final Function<Specification<S>, Boolean> existsOperation;
 	private final EntityManager entityManager;
 
 	public FetchableFluentQueryBySpecification(Specification<S> spec, Class<S> entityType,
-			Function<Sort, TypedQuery<S>> finder, Scroller<S> scroller, Function<Specification<S>, Long> countOperation,
-			Function<Specification<S>, Boolean> existsOperation, EntityManager entityManager) {
-		this(spec, entityType, (Class<R>) entityType, Sort.unsorted(), 0, Collections.emptySet(), finder, scroller,
+			Function<Sort, TypedQuery<S>> finder, SpecificationScrollDelegate<S> scrollDirector,
+			Function<Specification<S>, Long> countOperation, Function<Specification<S>, Boolean> existsOperation,
+			EntityManager entityManager) {
+		this(spec, entityType, (Class<R>) entityType, Sort.unsorted(), 0, Collections.emptySet(), finder, scrollDirector,
 				countOperation, existsOperation, entityManager);
 	}
 
 	private FetchableFluentQueryBySpecification(Specification<S> spec, Class<S> entityType, Class<R> resultType,
-			Sort sort, int limit, Collection<String> properties, Function<Sort, TypedQuery<S>> finder, Scroller<S> scroller,
-			Function<Specification<S>, Long> countOperation, Function<Specification<S>, Boolean> existsOperation,
-			EntityManager entityManager) {
+			Sort sort, int limit, Collection<String> properties, Function<Sort, TypedQuery<S>> finder,
+			SpecificationScrollDelegate<S> scrollDirector, Function<Specification<S>, Long> countOperation,
+			Function<Specification<S>, Boolean> existsOperation, EntityManager entityManager) {
 
 		super(resultType, sort, limit, properties, entityType);
 		this.spec = spec;
 		this.finder = finder;
-		this.scroll = scroller;
+		this.scroll = scrollDirector;
 		this.countOperation = countOperation;
 		this.existsOperation = existsOperation;
 		this.entityManager = entityManager;
@@ -226,35 +226,22 @@ interface ScrollQueryFactory<T> {
 
 	}
 
-	static class Scroller<T> {
+	static class SpecificationScrollDelegate<T> extends ScrollDelegate<T> {
 
 		private final ScrollQueryFactory<T> scrollFunction;
-		private final JpaEntityInformation<T, ?> entity;
 
-		Scroller(ScrollQueryFactory<T> scrollFunction, JpaEntityInformation<T, ?> entity) {
-			this.scrollFunction = scrollFunction;
-			this.entity = entity;
+		SpecificationScrollDelegate(ScrollQueryFactory<T> scrollQueryFactory, JpaEntityInformation<T, ?> entity) {
+			super(entity);
+			this.scrollFunction = scrollQueryFactory;
 		}
 
 		public Window<T> scroll(Sort sort, int limit, ScrollPosition scrollPosition) {
 
 			TypedQuery<T> query = scrollFunction.createQuery(sort, scrollPosition);
-
 			if (limit > 0) {
-				query.setMaxResults(limit + 1);
+				query = query.setMaxResults(limit);
 			}
-
-			List<T> result = query.getResultList();
-
-			if (scrollPosition instanceof KeysetScrollPosition keyset) {
-				return ScrollUtil.createWindow(sort, limit, keyset.getDirection(), entity, result);
-			}
-
-			if (scrollPosition instanceof OffsetScrollPosition offset) {
-				return ScrollUtil.createWindow(result, limit, OffsetScrollPosition.positionFunction(offset.getOffset()));
-			}
-
-			throw new UnsupportedOperationException("ScrollPosition " + scrollPosition + " not supported");
+			return scroll(query, sort, scrollPosition);
 		}
 	}
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/ScrollUtil.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/ScrollUtil.java
deleted file mode 100644
index 17cd645bd0..0000000000
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/ScrollUtil.java
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- * 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.support;
-
-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.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.KeysetScrollSpecification.KeysetScrollDirector;
-
-/**
- * @author Mark Paluch
- */
-class ScrollUtil {
-
-	static <T> Window<T> createWindow(Sort sort, int limit, Direction direction, JpaEntityInformation<T, ?> entity,
-			List<T> result) {
-
-		KeysetScrollDirector director = KeysetScrollDirector.of(direction);
-
-		director.postPostProcessResults(result);
-
-		IntFunction<KeysetScrollPosition> positionFunction = value -> {
-
-			T object = result.get(value);
-			Map<String, Object> keys = entity.getKeyset(sort.stream().map(Order::getProperty).toList(), object);
-
-			return KeysetScrollPosition.of(keys);
-		};
-
-		return createWindow(result, limit, positionFunction);
-	}
-
-	static <T> Window<T> createWindow(List<T> result, int limit, IntFunction<? extends ScrollPosition> positionFunction) {
-		return Window.from(getSubList(result, limit), positionFunction, hasMoreElements(result, limit));
-	}
-
-	private static boolean hasMoreElements(List<?> result, int limit) {
-		return !result.isEmpty() && result.size() > limit;
-	}
-
-	private static <T> List<T> getSubList(List<T> result, int limit) {
-
-		if (limit > 0 && result.size() > limit) {
-			return result.subList(0, limit);
-		}
-
-		return result;
-	}
-
-}
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 b983545188..1ea18385ed 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
@@ -54,9 +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.ScrollQueryFactory;
-import org.springframework.data.jpa.repository.support.FetchableFluentQueryBySpecification.Scroller;
+import org.springframework.data.jpa.repository.support.FetchableFluentQueryBySpecification.SpecificationScrollDelegate;
 import org.springframework.data.jpa.repository.support.QueryHints.NoHints;
 import org.springframework.data.jpa.support.PageableUtils;
 import org.springframework.data.repository.query.FluentQuery.FetchableFluentQuery;
@@ -541,8 +542,10 @@ private <S extends T, R> R doFindBy(Specification<T> spec, Class<T> domainClass,
 
 		Function<Sort, TypedQuery<T>> finder = sort -> getQuery(spec, domainClass, sort);
 
-		Scroller<T> scroller = new Scroller<>(scrollFunction, entityInformation);
-		FetchableFluentQuery<T> fluentQuery = new FetchableFluentQueryBySpecification<>(spec, domainClass, finder, scroller,
+		SpecificationScrollDelegate<T> scrollDirector = new SpecificationScrollDelegate<>(scrollFunction,
+				entityInformation);
+		FetchableFluentQuery<T> fluentQuery = new FetchableFluentQueryBySpecification<>(spec, domainClass, finder,
+				scrollDirector,
 				this::count, this::exists, this.em);
 
 		return queryFunction.apply((FetchableFluentQuery<S>) fluentQuery);
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 999fe307bc..af383fb3fc 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
@@ -1230,12 +1230,12 @@ void findAllByTypedSpecialUserExampleShouldReturnSubTypesOfRepositoryEntity() {
 	}
 
 	@Test // GH-2878
-	void scrollByOffset() {
+	void scrollByExampleOffset() {
 
-		User john1 = new User("John", "Doe", "john@doe1.com");
-		User john2 = new User("John", "Doe", "john@doe2.com");
 		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));
 
@@ -1256,12 +1256,12 @@ void scrollByOffset() {
 	}
 
 	@Test // GH-2878
-	void scrollByKeyset() {
+	void scrollByExampleKeyset() {
 
-		User john1 = new User("John", "Doe", "john@doe1.com");
-		User john2 = new User("John", "Doe", "john@doe2.com");
 		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));
 
@@ -1281,6 +1281,59 @@ void scrollByKeyset() {
 		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<User> example = Example.of(new User("J", null, null),
+				matching().withMatcher("firstname", GenericPropertyMatcher::startsWith).withIgnorePaths("age", "createdAt",
+						"dateOfBirth"));
+		Window<User> firstWindow = repository.findBy(example,
+				q -> q.limit(4).sortBy(Sort.by("firstname", "emailAddress")).scroll(KeysetScrollPosition.initial()));
+
+		KeysetScrollPosition scrollPosition = (KeysetScrollPosition) firstWindow.positionAt(2);
+		Window<User> 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 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<User> firstWindow = repository.findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc("J",
+				KeysetScrollPosition.initial());
+
+		assertThat(firstWindow).containsSequence(jane1, jane2, john1);
+		assertThat(firstWindow.hasNext()).isTrue();
+
+		KeysetScrollPosition scrollPosition = (KeysetScrollPosition) firstWindow.positionAt(2);
+		KeysetScrollPosition backward = KeysetScrollPosition.of(scrollPosition.getKeys(),
+				KeysetScrollPosition.Direction.Backward);
+		Window<User> previousWindow = repository.findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc("J",
+				backward);
+
+		assertThat(previousWindow).hasSize(2).containsSequence(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..f184bffd0d
--- /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))).hasSize(2).containsSequence(1, 2);
+		assertThat(CollectionUtils.getFirst(2, List.of(1, 2))).hasSize(2).containsSequence(1, 2);
+		assertThat(CollectionUtils.getFirst(2, List.of(1))).hasSize(1).containsSequence(1);
+	}
+
+	@Test // GH-2878
+	void shouldReturnLastItems() {
+
+		assertThat(CollectionUtils.getLast(2, List.of(1, 2, 3))).hasSize(2).containsSequence(2, 3);
+		assertThat(CollectionUtils.getLast(2, List.of(1, 2))).hasSize(2).containsSequence(1, 2);
+		assertThat(CollectionUtils.getLast(2, List.of(1))).hasSize(1).containsSequence(1);
+	}
+}
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<User, Integer>, JpaSpecifi
 
 	Page<User> findByFirstnameIn(Pageable pageable, String... firstnames);
 
+	Window<User> findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc(String firstname,
+			ScrollPosition position);
+
 	List<User> findByFirstnameNotIn(Collection<String> firstnames);
 
 	// DATAJPA-292

From 41cb75d51e68c0117a7f6e7d24194e222e120f02 Mon Sep 17 00:00:00 2001
From: Mark Paluch <mpaluch@vmware.com>
Date: Thu, 23 Mar 2023 15:47:17 +0100
Subject: [PATCH 4/7] Add support for Querydsl.

---
 .../query/KeysetScrollDelegate.java           | 199 ++++++++++++++++++
 .../query/KeysetScrollSpecification.java      | 156 +++-----------
 .../jpa/repository/query/ScrollDelegate.java  |   3 +-
 .../FetchableFluentQueryByPredicate.java      |  83 ++++++--
 .../FetchableFluentQueryBySpecification.java  |   1 +
 .../data/jpa/repository/support/Querydsl.java |  29 ++-
 .../support/QuerydslJpaPredicateExecutor.java |  77 ++++++-
 .../jpa/repository/UserRepositoryTests.java   |  74 +++++++
 ...chableFluentQueryByPredicateUnitTests.java |   4 +-
 9 files changed, 470 insertions(+), 156 deletions(-)
 create mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java

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..92b351b0f1
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java
@@ -0,0 +1,199 @@
+/*
+ * 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 <E, P> P createPredicate(KeysetScrollPosition keyset, Sort sort, QueryAdapter<E, P> queryAdapter) {
+
+		Map<String, Object> keysetValues = keyset.getKeys();
+
+		// first query doesn't come with a keyset
+		if (keysetValues.isEmpty()) {
+			return null;
+		}
+
+		// build matrix query for keyset paging that contains sort^2 queries
+		// reflecting a query that follows sort order semantics starting from the last returned keyset
+
+		List<P> or = new ArrayList<>();
+
+		int i = 0;
+		// progressive query building
+		for (Order order : sort) {
+
+			if (!keysetValues.containsKey(order.getProperty())) {
+				throw new IllegalStateException("KeysetScrollPosition does not contain all keyset values");
+			}
+
+			List<P> sortConstraint = new ArrayList<>();
+
+			int j = 0;
+			for (Order inner : sort) {
+
+				E propertyExpression = queryAdapter.createExpression(inner.getProperty());
+				Object o = keysetValues.get(inner.getProperty());
+
+				if (j >= i) { // tail segment
+
+					sortConstraint.add(queryAdapter.compare(inner, propertyExpression, o));
+					break;
+				}
+
+				sortConstraint.add(queryAdapter.compare(propertyExpression, o));
+				j++;
+			}
+
+			if (!sortConstraint.isEmpty()) {
+				or.add(queryAdapter.and(sortConstraint));
+			}
+
+			i++;
+		}
+
+		if (or.isEmpty()) {
+			return null;
+		}
+
+		return queryAdapter.or(or);
+	}
+
+	protected Sort getSortOrders(Sort sort) {
+		return sort;
+	}
+
+	@SuppressWarnings("unchecked")
+	protected <T> List<T> postProcessResults(List<T> result) {
+		return result;
+	}
+
+	protected <T> List<T> getResultWindow(List<T> list, int limit) {
+		return CollectionUtils.getFirst(limit, list);
+	}
+
+	/**
+	 * Reverse scrolling director 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<Order> 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 <T> List<T> postProcessResults(List<T> result) {
+			Collections.reverse(result);
+			return result;
+		}
+
+		@Override
+		protected <T> List<T> getResultWindow(List<T> list, int limit) {
+			return CollectionUtils.getLast(limit, list);
+		}
+	}
+
+	/**
+	 * Adapter to construct scroll queries.
+	 *
+	 * @param <E>
+	 * @param <P>
+	 */
+	public interface QueryAdapter<E, P> {
+
+		/**
+		 * Create an expression object from the given {@code property} path.
+		 *
+		 * @param property
+		 * @return
+		 */
+		E createExpression(String property);
+
+		/**
+		 * Create a comparison object according to the {@link Order}.
+		 *
+		 * @param order
+		 * @param propertyExpression
+		 * @param o
+		 * @return
+		 */
+		P compare(Order order, E propertyExpression, Object o);
+
+		/**
+		 * Create an equals-comparison object.
+		 *
+		 * @param propertyExpression
+		 * @param o
+		 * @return
+		 */
+		P compare(E propertyExpression, Object o);
+
+		/**
+		 * AND-combine the {@code intermediate} predicates.
+		 *
+		 * @param intermediate
+		 * @return
+		 */
+		P and(List<P> intermediate);
+
+		/**
+		 * OR-combine the {@code intermediate} predicates.
+		 *
+		 * @param intermediate
+		 * @return
+		 */
+		P or(List<P> 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
index 2391566d1c..70b235f825 100644
--- 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
@@ -22,16 +22,13 @@
 import jakarta.persistence.criteria.Predicate;
 import jakarta.persistence.criteria.Root;
 
-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.data.jpa.domain.Specification;
+import org.springframework.data.jpa.repository.query.KeysetScrollDelegate.QueryAdapter;
 import org.springframework.data.jpa.repository.support.JpaEntityInformation;
 import org.springframework.data.mapping.PropertyPath;
 import org.springframework.lang.Nullable;
@@ -63,7 +60,7 @@ public KeysetScrollSpecification(KeysetScrollPosition position, Sort sort, JpaEn
 	 */
 	public static Sort createSort(KeysetScrollPosition position, Sort sort, JpaEntityInformation<?, ?> entity) {
 
-		KeysetScrollDirector director = KeysetScrollDirector.of(position.getDirection());
+		KeysetScrollDelegate director = KeysetScrollDelegate.of(position.getDirection());
 
 		Sort sortToUse;
 		if (entity.hasCompositeId()) {
@@ -83,151 +80,46 @@ public Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuild
 	@Nullable
 	public Predicate createPredicate(Root<?> root, CriteriaBuilder criteriaBuilder) {
 
-		KeysetScrollDirector director = KeysetScrollDirector.of(position.getDirection());
-		return director.createQuery(position, sort, root, criteriaBuilder);
+		KeysetScrollDelegate director = KeysetScrollDelegate.of(position.getDirection());
+		return director.createPredicate(position, sort, new JpaQueryAdapter(root, criteriaBuilder));
 	}
 
-	/**
-	 * Director for keyset scrolling.
-	 */
 	@SuppressWarnings("rawtypes")
-	static class KeysetScrollDirector {
-
-		private static final KeysetScrollDirector forward = new KeysetScrollDirector();
-		private static final KeysetScrollDirector reverse = new ReverseKeysetScrollDirector();
-
-		/**
-		 * Factory method to obtain the right {@link KeysetScrollDirector}.
-		 *
-		 * @param direction
-		 * @return
-		 */
-		public static KeysetScrollDirector of(Direction direction) {
-			return direction == Direction.Forward ? forward : reverse;
-		}
-
-		public Sort getSortOrders(Sort sort) {
-			return sort;
-		}
+	private static class JpaQueryAdapter implements QueryAdapter<Expression<Comparable>, Predicate> {
 
-		@Nullable
-		public Predicate createQuery(KeysetScrollPosition keyset, Sort sort, From<?, ?> from, CriteriaBuilder cb) {
+		private final From<?, ?> from;
+		private final CriteriaBuilder cb;
 
-			Map<String, Object> keysetValues = keyset.getKeys();
-
-			// first query doesn't come with a keyset
-			if (keysetValues.isEmpty()) {
-				return null;
-			}
-
-			// build matrix query for keyset paging that contains sort^2 queries
-			// reflecting a query that follows sort order semantics starting from the last returned keyset
-			List<Predicate> or = createPredicates(sort, from, cb, keysetValues);
-
-			if (or.isEmpty()) {
-				return null;
-			}
-
-			return cb.or(or.toArray(new Predicate[0]));
+		public JpaQueryAdapter(From<?, ?> from, CriteriaBuilder cb) {
+			this.from = from;
+			this.cb = cb;
 		}
 
-		List<Predicate> createPredicates(Sort sort, From<?, ?> from, CriteriaBuilder cb, Map<String, Object> keysetValues) {
-
-			List<Predicate> or = new ArrayList<>();
-
-			int i = 0;
-			// progressive query building
-			for (Order order : sort) {
-
-				if (!keysetValues.containsKey(order.getProperty())) {
-					throw new IllegalStateException("KeysetScrollPosition does not contain all keyset values");
-				}
-
-				List<Predicate> sortConstraint = new ArrayList<>();
-
-				int j = 0;
-				for (Sort.Order inner : sort) {
-
-					Expression<Comparable> propertyExpression = getExpression(from, inner);
-					Comparable<?> o = (Comparable<?>) keysetValues.get(inner.getProperty());
-
-					if (j >= i) { // tail segment
-
-						sortConstraint.add(getComparator(inner, propertyExpression, o, cb));
-						break;
-					}
-
-					sortConstraint.add(cb.equal(propertyExpression, o));
-					j++;
-				}
-
-				if (!sortConstraint.isEmpty()) {
-					or.add(cb.and(sortConstraint.toArray(new Predicate[0])));
-				}
-
-				i++;
-			}
-
-			return or;
-		}
-
-		@SuppressWarnings("unchecked")
-		protected Predicate getComparator(Sort.Order sortOrder, Expression<Comparable> propertyExpression,
-				Comparable object, CriteriaBuilder cb) {
-
-			return sortOrder.isAscending() ? cb.greaterThan(propertyExpression, object)
-					: cb.lessThan(propertyExpression, object);
-		}
-
-		protected static <T> Expression<T> getExpression(From<?, ?> from, Order order) {
-			PropertyPath property = PropertyPath.from(order.getProperty(), from.getJavaType());
-			return QueryUtils.toExpressionRecursively(from, property);
-		}
-
-		public <T> List<T> postProcessResults(List<T> result) {
-			return result;
-		}
-
-		public <T> List<T> getResultWindow(List<T> list, int limit) {
-			return CollectionUtils.getFirst(limit, list);
+		@Override
+		public Expression<Comparable> createExpression(String property) {
+			PropertyPath path = PropertyPath.from(property, from.getJavaType());
+			return QueryUtils.toExpressionRecursively(from, path);
 		}
-	}
-
-	/**
-	 * Reverse scrolling director 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 ReverseKeysetScrollDirector extends KeysetScrollDirector {
 
-		public Sort getSortOrders(Sort sort) {
-
-			List<Sort.Order> orders = new ArrayList<>();
-			for (Sort.Order order : sort) {
-				orders.add(new Sort.Order(order.isAscending() ? Sort.Direction.DESC : Sort.Direction.ASC, order.getProperty()));
-			}
-
-			return Sort.by(orders);
+		@Override
+		public Predicate compare(Order order, Expression<Comparable> propertyExpression, Object o) {
+			return order.isAscending() ? cb.greaterThan(propertyExpression, (Comparable) o)
+					: cb.lessThan(propertyExpression, (Comparable) o);
 		}
 
-		@SuppressWarnings({ "rawtypes", "unchecked" })
 		@Override
-		protected Predicate getComparator(Sort.Order sortOrder, Expression<Comparable> propertyExpression,
-				Comparable object, CriteriaBuilder cb) {
-			return sortOrder.isAscending() ? cb.greaterThan(propertyExpression, object)
-					: cb.lessThan(propertyExpression, object);
+		public Predicate compare(Expression<Comparable> propertyExpression, Object o) {
+			return cb.equal(propertyExpression, o);
 		}
 
 		@Override
-		public <T> List<T> postProcessResults(List<T> result) {
-			Collections.reverse(result);
-			return result;
+		public Predicate and(List<Predicate> intermediate) {
+			return cb.and(intermediate.toArray(new Predicate[0]));
 		}
 
 		@Override
-		public <T> List<T> getResultWindow(List<T> list, int limit) {
-			return CollectionUtils.getLast(limit, list);
+		public Predicate or(List<Predicate> intermediate) {
+			return cb.or(intermediate.toArray(new Predicate[0]));
 		}
 	}
-
 }
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
index 105095b101..e93e342f52 100644
--- 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
@@ -28,7 +28,6 @@
 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.query.KeysetScrollSpecification.KeysetScrollDirector;
 import org.springframework.data.jpa.repository.support.JpaEntityInformation;
 import org.springframework.util.Assert;
 
@@ -80,7 +79,7 @@ public Window<T> scroll(Query query, Sort sort, ScrollPosition scrollPosition) {
 	private static <T> Window<T> createWindow(Sort sort, int limit, Direction direction,
 			JpaEntityInformation<T, ?> entity, List<T> result) {
 
-		KeysetScrollDirector director = KeysetScrollDirector.of(direction);
+		KeysetScrollDelegate director = KeysetScrollDelegate.of(direction);
 		List<T> resultsToUse = director.postProcessResults(result);
 
 		IntFunction<KeysetScrollPosition> positionFunction = value -> {
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 7282fcb8f7..c4340c76f1 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<S, R> extends FluentQuerySupport<S, R> imp
 
 	private final Predicate predicate;
 	private final Function<Sort, AbstractJPAQuery<?, ?>> finder;
+
+	private final PredicateScrollDelegate<S> scroll;
 	private final BiFunction<Sort, Pageable, AbstractJPAQuery<?, ?>> pagedFinder;
 	private final Function<Predicate, Long> countOperation;
 	private final Function<Predicate, Boolean> existsOperation;
 	private final EntityManager entityManager;
 
 	public FetchableFluentQueryByPredicate(Predicate predicate, Class<S> entityType,
-			Function<Sort, AbstractJPAQuery<?, ?>> finder, BiFunction<Sort, Pageable, AbstractJPAQuery<?, ?>> pagedFinder,
-			Function<Predicate, Long> countOperation, Function<Predicate, Boolean> existsOperation,
-			EntityManager entityManager) {
-		this(predicate, entityType, (Class<R>) entityType, Sort.unsorted(), Collections.emptySet(), finder, pagedFinder,
-				countOperation, existsOperation, entityManager);
+			Function<Sort, AbstractJPAQuery<?, ?>> finder, PredicateScrollDelegate<S> scroll,
+			BiFunction<Sort, Pageable, AbstractJPAQuery<?, ?>> pagedFinder, Function<Predicate, Long> countOperation,
+			Function<Predicate, Boolean> existsOperation, EntityManager entityManager) {
+		this(predicate, entityType, (Class<R>) entityType, Sort.unsorted(), 0, Collections.emptySet(), finder, scroll,
+				pagedFinder, countOperation, existsOperation, entityManager);
 	}
 
 	private FetchableFluentQueryByPredicate(Predicate predicate, Class<S> entityType, Class<R> resultType, Sort sort,
-			Collection<String> properties, Function<Sort, AbstractJPAQuery<?, ?>> finder,
-			BiFunction<Sort, Pageable, AbstractJPAQuery<?, ?>> pagedFinder, Function<Predicate, Long> countOperation,
-			Function<Predicate, Boolean> existsOperation, EntityManager entityManager) {
+			int limit, Collection<String> properties, Function<Sort, AbstractJPAQuery<?, ?>> finder,
+			PredicateScrollDelegate<S> scroll, BiFunction<Sort, Pageable, AbstractJPAQuery<?, ?>> pagedFinder,
+			Function<Predicate, Long> countOperation, Function<Predicate, Boolean> existsOperation,
+			EntityManager entityManager) {
 
-		super(resultType, sort, 0, 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<R> 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<R> 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 <NR> FetchableFluentQuery<NR> as(Class<NR> 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<R> project(Collection<String> 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<R> all() {
 		return convert(createSortedAndProjectedQuery().fetch());
 	}
 
+	@Override
+	public Window<R> scroll(ScrollPosition scrollPosition) {
+
+		Assert.notNull(scrollPosition, "ScrollPosition must not be null");
+
+		return scroll.scroll(sort, limit, scrollPosition).map(getConversionFunction());
+	}
+
 	@Override
 	public Page<R> 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,28 @@ private Function<Object, R> getConversionFunction() {
 		return getConversionFunction(entityType, resultType);
 	}
 
+	interface ScrollQueryFactory<T> {
+		Query createQuery(Sort sort, ScrollPosition scrollPosition);
+
+	}
+
+	static class PredicateScrollDelegate<T> extends ScrollDelegate<T> {
+
+		private final ScrollQueryFactory<T> scrollFunction;
+
+		PredicateScrollDelegate(ScrollQueryFactory<T> scrollQueryFactory, JpaEntityInformation<T, ?> entity) {
+			super(entity);
+			this.scrollFunction = scrollQueryFactory;
+		}
+
+		public Window<T> 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 69a02fb5a2..d41d3073e5 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
@@ -91,6 +91,7 @@ public FetchableFluentQuery<R> sortBy(Sort sort) {
 
 	@Override
 	public FetchableFluentQuery<R> limit(int limit) {
+
 		Assert.isTrue(limit >= 0, "Limit must not be negative");
 
 		return new FetchableFluentQueryBySpecification<>(spec, entityType, resultType, this.sort.and(sort), limit,
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..e37830ce45 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.QueryAdapter;
+import org.springframework.data.jpa.repository.query.KeysetScrollSpecification;
+import org.springframework.data.jpa.repository.support.FetchableFluentQueryByPredicate.PredicateScrollDelegate;
+import org.springframework.data.jpa.repository.support.FetchableFluentQueryByPredicate.ScrollQueryFactory;
 import org.springframework.data.querydsl.EntityPathResolver;
 import org.springframework.data.querydsl.QSort;
 import org.springframework.data.querydsl.QuerydslPredicateExecutor;
@@ -37,9 +45,13 @@
 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.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 +75,7 @@ public class QuerydslJpaPredicateExecutor<T> implements QuerydslPredicateExecuto
 	private final JpaEntityInformation<T, ?> entityInformation;
 	private final EntityPath<T> path;
 	private final Querydsl querydsl;
+	private final QuerydslQueryAdapter scrollQueryAdapter;
 	private final EntityManager entityManager;
 	private final CrudMethodMetadata metadata;
 
@@ -83,6 +96,7 @@ public QuerydslJpaPredicateExecutor(JpaEntityInformation<T, ?> entityInformation
 		this.path = resolver.createPath(entityInformation.getJavaType());
 		this.querydsl = new Querydsl(entityManager, new PathBuilder<T>(path.getType(), path.getMetadata()));
 		this.entityManager = entityManager;
+		this.scrollQueryAdapter = new QuerydslQueryAdapter();
 	}
 
 	@Override
@@ -160,6 +174,33 @@ public <S extends T, R> R findBy(Predicate predicate, Function<FetchableFluentQu
 			return select;
 		};
 
+		ScrollQueryFactory<T> scroll = (sort, scrollPosition) -> {
+
+			Predicate predicateToUse = predicate;
+
+			if (scrollPosition instanceof KeysetScrollPosition keyset) {
+
+				KeysetScrollDelegate director = KeysetScrollDelegate.of(keyset.getDirection());
+				sort = KeysetScrollSpecification.createSort(keyset, sort, entityInformation);
+				BooleanExpression keysetPredicate = director.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<Sort, Pageable, AbstractJPAQuery<?, ?>> pagedFinder = (sort, pageable) -> {
 
 			AbstractJPAQuery<?, ?> select = finder.apply(sort);
@@ -175,6 +216,7 @@ public <S extends T, R> R findBy(Predicate predicate, Function<FetchableFluentQu
 				predicate, //
 				this.entityInformation.getJavaType(), //
 				finder, //
+				new PredicateScrollDelegate<>(scroll, entityInformation), //
 				pagedFinder, //
 				this::count, //
 				this::exists, //
@@ -285,4 +327,33 @@ private List<T> executeSorted(JPQLQuery<T> query, OrderSpecifier<?>... orders) {
 	private List<T> executeSorted(JPQLQuery<T> query, Sort sort) {
 		return querydsl.applySorting(sort, query).fetch();
 	}
+
+	class QuerydslQueryAdapter implements QueryAdapter<Expression<?>, BooleanExpression> {
+
+		@Override
+		public Expression<?> createExpression(String property) {
+			return querydsl.createExpression(property);
+		}
+
+		@Override
+		public BooleanExpression compare(Order order, Expression<?> propertyExpression, Object o) {
+			return Expressions.booleanOperation(order.isAscending() ? Ops.GT : Ops.LT, propertyExpression,
+					ConstantImpl.create(o));
+		}
+
+		@Override
+		public BooleanExpression compare(Expression<?> propertyExpression, Object o) {
+			return Expressions.booleanOperation(Ops.EQ, propertyExpression, ConstantImpl.create(o));
+		}
+
+		@Override
+		public BooleanExpression and(List<BooleanExpression> intermediate) {
+			return Expressions.allOf(intermediate.toArray(new BooleanExpression[0]));
+		}
+
+		@Override
+		public BooleanExpression or(List<BooleanExpression> intermediate) {
+			return Expressions.anyOf(intermediate.toArray(new BooleanExpression[0]));
+		}
+	}
 }
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 af383fb3fc..436ba98100 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
@@ -1306,6 +1306,80 @@ void scrollByExampleKeysetBackward() {
 		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<User> firstWindow = repository.findBy(QUser.user.firstname.startsWith("J"),
+				q -> q.limit(2).sortBy(Sort.by("firstname")).scroll(OffsetScrollPosition.initial()));
+
+		assertThat(firstWindow).hasSize(2).containsOnly(jane1, jane2);
+		assertThat(firstWindow.hasNext()).isTrue();
+
+		Window<User> nextWindow = repository.findBy(QUser.user.firstname.startsWith("J"),
+				q -> q.limit(2).sortBy(Sort.by("firstname")).scroll(firstWindow.positionAt(1)));
+
+		assertThat(nextWindow).hasSize(2).containsOnly(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<User> 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<User> nextWindow = repository.findBy(QUser.user.firstname.startsWith("J"),
+				q -> q.limit(2).sortBy(Sort.by("firstname", "emailAddress")).scroll(firstWindow.positionAt(0)));
+
+		assertThat(nextWindow).hasSize(2).containsOnly(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<User> firstWindow = repository.findBy(QUser.user.firstname.startsWith("J"),
+				q -> q.limit(3).sortBy(Sort.by("firstname", "emailAddress")).scroll(KeysetScrollPosition.initial()));
+
+		assertThat(firstWindow).containsSequence(jane1, jane2, john1);
+		assertThat(firstWindow.hasNext()).isTrue();
+
+		KeysetScrollPosition scrollPosition = (KeysetScrollPosition) firstWindow.positionAt(2);
+		KeysetScrollPosition backward = KeysetScrollPosition.of(scrollPosition.getKeys(),
+				KeysetScrollPosition.Direction.Backward);
+		Window<User> previousWindow = repository.findBy(QUser.user.firstname.startsWith("J"),
+				q -> q.limit(3).sortBy(Sort.by("firstname", "emailAddress")).scroll(backward));
+
+		assertThat(previousWindow).hasSize(2).containsSequence(jane1, jane2);
+
+		// no more items before this window
+		assertThat(previousWindow.hasNext()).isFalse();
+	}
+
 	@Test // GH-2878
 	void scrollByPartTreeKeysetBackward() {
 
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));
 	}

From 60f24270c785f8278d9955dcf8ee8ec8bab197ff Mon Sep 17 00:00:00 2001
From: Mark Paluch <mpaluch@vmware.com>
Date: Thu, 23 Mar 2023 15:54:20 +0100
Subject: [PATCH 5/7] Polishing.

---
 .../jpa/repository/query/KeysetScrollDelegate.java    |  4 ++--
 .../data/jpa/repository/query/QueryUtils.java         |  2 +-
 .../support/FetchableFluentQueryByPredicate.java      |  4 ----
 .../support/FetchableFluentQueryBySpecification.java  | 11 ++++-------
 .../jpa/repository/support/FluentQuerySupport.java    |  7 +++++++
 .../support/QuerydslJpaPredicateExecutor.java         |  2 +-
 .../jpa/repository/support/SimpleJpaRepository.java   |  5 ++---
 7 files changed, 17 insertions(+), 18 deletions(-)

diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java
index 92b351b0f1..2a62ea4b0d 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java
@@ -147,8 +147,8 @@ protected <T> List<T> getResultWindow(List<T> list, int limit) {
 	/**
 	 * Adapter to construct scroll queries.
 	 *
-	 * @param <E>
-	 * @param <P>
+	 * @param <E> property path expression type.
+	 * @param <P> predicate type.
 	 */
 	public interface QueryAdapter<E, P> {
 
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java
index 54889bbc85..f581f191ff 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java
@@ -747,7 +747,7 @@ private static jakarta.persistence.criteria.Order toJpaOrder(Order order, From<?
 		}
 	}
 
-	public static <T> Expression<T> toExpressionRecursively(From<?, ?> from, PropertyPath property) {
+	static <T> Expression<T> toExpressionRecursively(From<?, ?> from, PropertyPath property) {
 		return toExpressionRecursively(from, property, false);
 	}
 
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByPredicate.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByPredicate.java
index c4340c76f1..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
@@ -230,10 +230,6 @@ private Function<Object, R> getConversionFunction() {
 		return getConversionFunction(entityType, resultType);
 	}
 
-	interface ScrollQueryFactory<T> {
-		Query createQuery(Sort sort, ScrollPosition scrollPosition);
-
-	}
 
 	static class PredicateScrollDelegate<T> extends ScrollDelegate<T> {
 
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 d41d3073e5..54796453a0 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;
@@ -221,12 +222,6 @@ private Function<Object, R> getConversionFunction() {
 		return getConversionFunction(entityType, resultType);
 	}
 
-	interface ScrollQueryFactory<T> {
-
-		TypedQuery<T> createQuery(Sort sort, ScrollPosition scrollPosition);
-
-	}
-
 	static class SpecificationScrollDelegate<T> extends ScrollDelegate<T> {
 
 		private final ScrollQueryFactory<T> scrollFunction;
@@ -238,10 +233,12 @@ static class SpecificationScrollDelegate<T> extends ScrollDelegate<T> {
 
 		public Window<T> scroll(Sort sort, int limit, ScrollPosition scrollPosition) {
 
-			TypedQuery<T> query = scrollFunction.createQuery(sort, 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 6f6a15eaae..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;
@@ -83,4 +86,8 @@ final Function<Object, R> getConversionFunction(Class<S> inputType, Class<R> tar
 		return o -> DefaultConversionService.getSharedInstance().convert(o, targetType);
 	}
 
+	interface ScrollQueryFactory<T> {
+		Query createQuery(Sort sort, ScrollPosition scrollPosition);
+	}
+
 }
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 e37830ce45..197b248dd6 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
@@ -35,7 +35,7 @@
 import org.springframework.data.jpa.repository.query.KeysetScrollDelegate.QueryAdapter;
 import org.springframework.data.jpa.repository.query.KeysetScrollSpecification;
 import org.springframework.data.jpa.repository.support.FetchableFluentQueryByPredicate.PredicateScrollDelegate;
-import org.springframework.data.jpa.repository.support.FetchableFluentQueryByPredicate.ScrollQueryFactory;
+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;
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 1ea18385ed..ebe54e9123 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
@@ -56,8 +56,8 @@
 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.ScrollQueryFactory;
 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;
@@ -545,8 +545,7 @@ private <S extends T, R> R doFindBy(Specification<T> spec, Class<T> domainClass,
 		SpecificationScrollDelegate<T> scrollDirector = new SpecificationScrollDelegate<>(scrollFunction,
 				entityInformation);
 		FetchableFluentQuery<T> fluentQuery = new FetchableFluentQueryBySpecification<>(spec, domainClass, finder,
-				scrollDirector,
-				this::count, this::exists, this.em);
+				scrollDirector, this::count, this::exists, this.em);
 
 		return queryFunction.apply((FetchableFluentQuery<S>) fluentQuery);
 	}

From 053cbef3f560e158c8fb9264cdc39d7e5c2bc35a Mon Sep 17 00:00:00 2001
From: Mark Paluch <mpaluch@vmware.com>
Date: Fri, 24 Mar 2023 09:27:52 +0100
Subject: [PATCH 6/7] Add documentation.

---
 src/main/asciidoc/index.adoc |  3 ++-
 src/main/asciidoc/jpa.adoc   | 28 +++++++++++++++++++++++++---
 2 files changed, 27 insertions(+), 4 deletions(-)

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..e6bc77680e 100644
--- a/src/main/asciidoc/jpa.adoc
+++ b/src/main/asciidoc/jpa.adoc
@@ -521,20 +521,42 @@ 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, <<repositories.scrolling,scrolling>> can help to process those results efficiently without loading all results into memory.
+
+Spring Data JPA supports offset- and keyset-based scrolling.
+A variant of offset scrolling provided through <<repositories.paging-and-sorting,Paging>>.
+
+Keyset-based scrolling avoids https://use-the-index-luke.com/no-offset[the shortcomings of offset-based result retrieval by leveraging database indexes].
+However, keyset-based results should avoid `null` values as these do not work well with sorting.
+
+In contrast to `Page<T>`, the Scroll API returning `Window<T>` is much more lightweight and flexible.
+
+You can use the Scroll API with query methods, <<query-by-example.running,Query-by-Example>>, and <<core.extensions.querydsl,Querydsl>>.
+
+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<User, Long> {
 

From ba37131d10bc35efd71437d060c6b2ffa3cf9573 Mon Sep 17 00:00:00 2001
From: Mark Paluch <mpaluch@vmware.com>
Date: Fri, 24 Mar 2023 09:39:15 +0100
Subject: [PATCH 7/7] Address review comments.

---
 .../jpa/repository/query/CollectionUtils.java | 12 ++---
 .../query/JpaKeysetScrollQueryCreator.java    |  3 +-
 .../query/KeysetScrollDelegate.java           | 46 +++++++++----------
 .../query/KeysetScrollSpecification.java      | 33 +++++++------
 .../jpa/repository/query/ScrollDelegate.java  |  8 ++--
 .../FetchableFluentQueryBySpecification.java  |  8 ++--
 .../JpaMetamodelEntityInformation.java        |  3 +-
 .../support/QuerydslJpaPredicateExecutor.java | 22 +++++----
 .../support/SimpleJpaRepository.java          |  4 +-
 .../jpa/repository/UserRepositoryTests.java   | 20 ++++----
 .../query/CollectionUtilsUnitTests.java       | 12 ++---
 src/main/asciidoc/jpa.adoc                    | 15 +++---
 12 files changed, 97 insertions(+), 89 deletions(-)

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
index 6777fede97..582d30eb1d 100644
--- 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
@@ -27,9 +27,9 @@ class CollectionUtils {
 	/**
 	 * Return the first {@code count} items from the list.
 	 *
-	 * @param count
-	 * @param list
-	 * @return
+	 * @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 <T>
 	 */
 	public static <T> List<T> getFirst(int count, List<T> list) {
@@ -44,9 +44,9 @@ public static <T> List<T> getFirst(int count, List<T> list) {
 	/**
 	 * Return the last {@code count} items from the list.
 	 *
-	 * @param count
-	 * @param list
-	 * @return
+	 * @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 <T>
 	 */
 	public static <T> List<T> getLast(int count, List<T> 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
index 06992b8694..5dbf036b4b 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java
@@ -29,6 +29,7 @@
 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}.
@@ -50,7 +51,7 @@ public JpaKeysetScrollQueryCreator(PartTree tree, ReturnedType type, CriteriaBui
 	}
 
 	@Override
-	protected CriteriaQuery<? extends Object> complete(Predicate predicate, Sort sort, CriteriaQuery<?> query,
+	protected CriteriaQuery<?> complete(@Nullable Predicate predicate, Sort sort, CriteriaQuery<?> query,
 			CriteriaBuilder builder, Root<?> root) {
 
 		KeysetScrollSpecification<Object> keysetSpec = new KeysetScrollSpecification<>(scrollPosition, sort,
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java
index 2a62ea4b0d..0ce89ff114 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java
@@ -48,7 +48,7 @@ public static KeysetScrollDelegate of(Direction direction) {
 	}
 
 	@Nullable
-	public <E, P> P createPredicate(KeysetScrollPosition keyset, Sort sort, QueryAdapter<E, P> queryAdapter) {
+	public <E, P> P createPredicate(KeysetScrollPosition keyset, Sort sort, QueryStrategy<E, P> strategy) {
 
 		Map<String, Object> keysetValues = keyset.getKeys();
 
@@ -57,17 +57,15 @@ public <E, P> P createPredicate(KeysetScrollPosition keyset, Sort sort, QueryAda
 			return null;
 		}
 
-		// build matrix query for keyset paging that contains sort^2 queries
-		// reflecting a query that follows sort order semantics starting from the last returned keyset
-
 		List<P> or = new ArrayList<>();
-
 		int i = 0;
-		// progressive query building
+
+		// progressive query building to reconstruct a query matching sorting rules
 		for (Order order : sort) {
 
 			if (!keysetValues.containsKey(order.getProperty())) {
-				throw new IllegalStateException("KeysetScrollPosition does not contain all keyset values");
+				throw new IllegalStateException(String
+						.format("KeysetScrollPosition does not contain all keyset values. Missing key: %s", order.getProperty()));
 			}
 
 			List<P> sortConstraint = new ArrayList<>();
@@ -75,21 +73,21 @@ public <E, P> P createPredicate(KeysetScrollPosition keyset, Sort sort, QueryAda
 			int j = 0;
 			for (Order inner : sort) {
 
-				E propertyExpression = queryAdapter.createExpression(inner.getProperty());
+				E propertyExpression = strategy.createExpression(inner.getProperty());
 				Object o = keysetValues.get(inner.getProperty());
 
 				if (j >= i) { // tail segment
 
-					sortConstraint.add(queryAdapter.compare(inner, propertyExpression, o));
+					sortConstraint.add(strategy.compare(inner, propertyExpression, o));
 					break;
 				}
 
-				sortConstraint.add(queryAdapter.compare(propertyExpression, o));
+				sortConstraint.add(strategy.compare(propertyExpression, o));
 				j++;
 			}
 
 			if (!sortConstraint.isEmpty()) {
-				or.add(queryAdapter.and(sortConstraint));
+				or.add(strategy.and(sortConstraint));
 			}
 
 			i++;
@@ -99,7 +97,7 @@ public <E, P> P createPredicate(KeysetScrollPosition keyset, Sort sort, QueryAda
 			return null;
 		}
 
-		return queryAdapter.or(or);
+		return strategy.or(or);
 	}
 
 	protected Sort getSortOrders(Sort sort) {
@@ -116,9 +114,9 @@ protected <T> List<T> getResultWindow(List<T> list, int limit) {
 	}
 
 	/**
-	 * Reverse scrolling director 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.
+	 * 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 {
 
@@ -150,12 +148,12 @@ protected <T> List<T> getResultWindow(List<T> list, int limit) {
 	 * @param <E> property path expression type.
 	 * @param <P> predicate type.
 	 */
-	public interface QueryAdapter<E, P> {
+	public interface QueryStrategy<E, P> {
 
 		/**
 		 * Create an expression object from the given {@code property} path.
 		 *
-		 * @param property
+		 * @param property must not be {@literal null}.
 		 * @return
 		 */
 		E createExpression(String property);
@@ -163,21 +161,21 @@ public interface QueryAdapter<E, P> {
 		/**
 		 * Create a comparison object according to the {@link Order}.
 		 *
-		 * @param order
-		 * @param propertyExpression
-		 * @param o
+		 * @param order must not be {@literal null}.
+		 * @param propertyExpression must not be {@literal null}.
+		 * @param value
 		 * @return
 		 */
-		P compare(Order order, E propertyExpression, Object o);
+		P compare(Order order, E propertyExpression, Object value);
 
 		/**
 		 * Create an equals-comparison object.
 		 *
-		 * @param propertyExpression
-		 * @param o
+		 * @param propertyExpression must not be {@literal null}.
+		 * @param value
 		 * @return
 		 */
-		P compare(E propertyExpression, Object o);
+		P compare(E propertyExpression, @Nullable Object value);
 
 		/**
 		 * AND-combine the {@code intermediate} predicates.
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
index 70b235f825..cfec4f06df 100644
--- 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
@@ -28,7 +28,7 @@
 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.QueryAdapter;
+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;
@@ -53,14 +53,14 @@ public KeysetScrollSpecification(KeysetScrollPosition position, Sort sort, JpaEn
 	/**
 	 * Create a {@link Sort} object to be used with the actual query.
 	 *
-	 * @param position
-	 * @param sort
-	 * @param entity
+	 * @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 director = KeysetScrollDelegate.of(position.getDirection());
+		KeysetScrollDelegate delegate = KeysetScrollDelegate.of(position.getDirection());
 
 		Sort sortToUse;
 		if (entity.hasCompositeId()) {
@@ -69,7 +69,7 @@ public static Sort createSort(KeysetScrollPosition position, Sort sort, JpaEntit
 			sortToUse = sort.and(Sort.by(entity.getRequiredIdAttribute().getName()));
 		}
 
-		return director.getSortOrders(sortToUse);
+		return delegate.getSortOrders(sortToUse);
 	}
 
 	@Override
@@ -80,36 +80,39 @@ public Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuild
 	@Nullable
 	public Predicate createPredicate(Root<?> root, CriteriaBuilder criteriaBuilder) {
 
-		KeysetScrollDelegate director = KeysetScrollDelegate.of(position.getDirection());
-		return director.createPredicate(position, sort, new JpaQueryAdapter(root, criteriaBuilder));
+		KeysetScrollDelegate delegate = KeysetScrollDelegate.of(position.getDirection());
+		return delegate.createPredicate(position, sort, new JpaQueryStrategy(root, criteriaBuilder));
 	}
 
 	@SuppressWarnings("rawtypes")
-	private static class JpaQueryAdapter implements QueryAdapter<Expression<Comparable>, Predicate> {
+	private static class JpaQueryStrategy implements QueryStrategy<Expression<Comparable>, Predicate> {
 
 		private final From<?, ?> from;
 		private final CriteriaBuilder cb;
 
-		public JpaQueryAdapter(From<?, ?> from, CriteriaBuilder cb) {
+		public JpaQueryStrategy(From<?, ?> from, CriteriaBuilder cb) {
+
 			this.from = from;
 			this.cb = cb;
 		}
 
 		@Override
 		public Expression<Comparable> createExpression(String property) {
+
 			PropertyPath path = PropertyPath.from(property, from.getJavaType());
 			return QueryUtils.toExpressionRecursively(from, path);
 		}
 
 		@Override
-		public Predicate compare(Order order, Expression<Comparable> propertyExpression, Object o) {
-			return order.isAscending() ? cb.greaterThan(propertyExpression, (Comparable) o)
-					: cb.lessThan(propertyExpression, (Comparable) o);
+		public Predicate compare(Order order, Expression<Comparable> propertyExpression, Object value) {
+
+			return order.isAscending() ? cb.greaterThan(propertyExpression, (Comparable) value)
+					: cb.lessThan(propertyExpression, (Comparable) value);
 		}
 
 		@Override
-		public Predicate compare(Expression<Comparable> propertyExpression, Object o) {
-			return cb.equal(propertyExpression, o);
+		public Predicate compare(Expression<Comparable> propertyExpression, @Nullable Object value) {
+			return value == null ? cb.isNull(propertyExpression) : cb.equal(propertyExpression, value);
 		}
 
 		@Override
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
index e93e342f52..94cc960a9b 100644
--- 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
@@ -41,7 +41,7 @@ public class ScrollDelegate<T> {
 
 	private final JpaEntityInformation<T, ?> entity;
 
-	public ScrollDelegate(JpaEntityInformation<T, ?> entity) {
+	protected ScrollDelegate(JpaEntityInformation<T, ?> entity) {
 		this.entity = entity;
 	}
 
@@ -79,8 +79,8 @@ public Window<T> scroll(Query query, Sort sort, ScrollPosition scrollPosition) {
 	private static <T> Window<T> createWindow(Sort sort, int limit, Direction direction,
 			JpaEntityInformation<T, ?> entity, List<T> result) {
 
-		KeysetScrollDelegate director = KeysetScrollDelegate.of(direction);
-		List<T> resultsToUse = director.postProcessResults(result);
+		KeysetScrollDelegate delegate = KeysetScrollDelegate.of(direction);
+		List<T> resultsToUse = delegate.postProcessResults(result);
 
 		IntFunction<KeysetScrollPosition> positionFunction = value -> {
 
@@ -90,7 +90,7 @@ private static <T> Window<T> createWindow(Sort sort, int limit, Direction direct
 			return KeysetScrollPosition.of(keys);
 		};
 
-		return Window.from(director.getResultWindow(resultsToUse, limit), positionFunction, hasMoreElements(result, limit));
+		return Window.from(delegate.getResultWindow(resultsToUse, limit), positionFunction, hasMoreElements(result, limit));
 	}
 
 	private static <T> Window<T> createWindow(List<T> result, int limit,
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 54796453a0..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
@@ -60,22 +60,22 @@ class FetchableFluentQueryBySpecification<S, R> extends FluentQuerySupport<S, R>
 	private final EntityManager entityManager;
 
 	public FetchableFluentQueryBySpecification(Specification<S> spec, Class<S> entityType,
-			Function<Sort, TypedQuery<S>> finder, SpecificationScrollDelegate<S> scrollDirector,
+			Function<Sort, TypedQuery<S>> finder, SpecificationScrollDelegate<S> scrollDelegate,
 			Function<Specification<S>, Long> countOperation, Function<Specification<S>, Boolean> existsOperation,
 			EntityManager entityManager) {
-		this(spec, entityType, (Class<R>) entityType, Sort.unsorted(), 0, Collections.emptySet(), finder, scrollDirector,
+		this(spec, entityType, (Class<R>) entityType, Sort.unsorted(), 0, Collections.emptySet(), finder, scrollDelegate,
 				countOperation, existsOperation, entityManager);
 	}
 
 	private FetchableFluentQueryBySpecification(Specification<S> spec, Class<S> entityType, Class<R> resultType,
 			Sort sort, int limit, Collection<String> properties, Function<Sort, TypedQuery<S>> finder,
-			SpecificationScrollDelegate<S> scrollDirector, Function<Specification<S>, Long> countOperation,
+			SpecificationScrollDelegate<S> scrollDelegate, Function<Specification<S>, Long> countOperation,
 			Function<Specification<S>, Boolean> existsOperation, EntityManager entityManager) {
 
 		super(resultType, sort, limit, properties, entityType);
 		this.spec = spec;
 		this.finder = finder;
-		this.scroll = scrollDirector;
+		this.scroll = scrollDelegate;
 		this.countOperation = countOperation;
 		this.existsOperation = existsOperation;
 		this.entityManager = entityManager;
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 647d117a5e..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
@@ -228,7 +228,8 @@ public boolean isNew(T entity) {
 	@Override
 	public Map<String, Object> getKeyset(Iterable<String> propertyPaths, T entity) {
 
-		// TODO: proxy business?
+		// TODO: Proxy handling requires more elaborate refactoring, see
+		// https://github.com/spring-projects/spring-data-jpa/issues/2784
 		BeanWrapper entityWrapper = new DirectFieldAccessFallbackBeanWrapper(entity);
 
 		Map<String, Object> keyset = new LinkedHashMap<>();
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 197b248dd6..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
@@ -32,7 +32,7 @@
 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.QueryAdapter;
+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;
@@ -48,6 +48,7 @@
 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;
@@ -75,7 +76,7 @@ public class QuerydslJpaPredicateExecutor<T> implements QuerydslPredicateExecuto
 	private final JpaEntityInformation<T, ?> entityInformation;
 	private final EntityPath<T> path;
 	private final Querydsl querydsl;
-	private final QuerydslQueryAdapter scrollQueryAdapter;
+	private final QuerydslQueryStrategy scrollQueryAdapter;
 	private final EntityManager entityManager;
 	private final CrudMethodMetadata metadata;
 
@@ -96,7 +97,7 @@ public QuerydslJpaPredicateExecutor(JpaEntityInformation<T, ?> entityInformation
 		this.path = resolver.createPath(entityInformation.getJavaType());
 		this.querydsl = new Querydsl(entityManager, new PathBuilder<T>(path.getType(), path.getMetadata()));
 		this.entityManager = entityManager;
-		this.scrollQueryAdapter = new QuerydslQueryAdapter();
+		this.scrollQueryAdapter = new QuerydslQueryStrategy();
 	}
 
 	@Override
@@ -180,9 +181,9 @@ public <S extends T, R> R findBy(Predicate predicate, Function<FetchableFluentQu
 
 			if (scrollPosition instanceof KeysetScrollPosition keyset) {
 
-				KeysetScrollDelegate director = KeysetScrollDelegate.of(keyset.getDirection());
+				KeysetScrollDelegate delegate = KeysetScrollDelegate.of(keyset.getDirection());
 				sort = KeysetScrollSpecification.createSort(keyset, sort, entityInformation);
-				BooleanExpression keysetPredicate = director.createPredicate(keyset, sort, scrollQueryAdapter);
+				BooleanExpression keysetPredicate = delegate.createPredicate(keyset, sort, scrollQueryAdapter);
 
 				if (keysetPredicate != null) {
 					predicateToUse = predicate instanceof BooleanExpression be ? be.and(keysetPredicate)
@@ -328,7 +329,7 @@ private List<T> executeSorted(JPQLQuery<T> query, Sort sort) {
 		return querydsl.applySorting(sort, query).fetch();
 	}
 
-	class QuerydslQueryAdapter implements QueryAdapter<Expression<?>, BooleanExpression> {
+	class QuerydslQueryStrategy implements QueryStrategy<Expression<?>, BooleanExpression> {
 
 		@Override
 		public Expression<?> createExpression(String property) {
@@ -336,14 +337,15 @@ public Expression<?> createExpression(String property) {
 		}
 
 		@Override
-		public BooleanExpression compare(Order order, Expression<?> propertyExpression, Object o) {
+		public BooleanExpression compare(Order order, Expression<?> propertyExpression, Object value) {
 			return Expressions.booleanOperation(order.isAscending() ? Ops.GT : Ops.LT, propertyExpression,
-					ConstantImpl.create(o));
+					ConstantImpl.create(value));
 		}
 
 		@Override
-		public BooleanExpression compare(Expression<?> propertyExpression, Object o) {
-			return Expressions.booleanOperation(Ops.EQ, propertyExpression, ConstantImpl.create(o));
+		public BooleanExpression compare(Expression<?> propertyExpression, @Nullable Object value) {
+			return Expressions.booleanOperation(Ops.EQ, propertyExpression,
+					value == null ? NullExpression.DEFAULT : ConstantImpl.create(value));
 		}
 
 		@Override
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 ebe54e9123..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
@@ -542,10 +542,10 @@ private <S extends T, R> R doFindBy(Specification<T> spec, Class<T> domainClass,
 
 		Function<Sort, TypedQuery<T>> finder = sort -> getQuery(spec, domainClass, sort);
 
-		SpecificationScrollDelegate<T> scrollDirector = new SpecificationScrollDelegate<>(scrollFunction,
+		SpecificationScrollDelegate<T> scrollDelegate = new SpecificationScrollDelegate<>(scrollFunction,
 				entityInformation);
 		FetchableFluentQuery<T> fluentQuery = new FetchableFluentQueryBySpecification<>(spec, domainClass, finder,
-				scrollDirector, this::count, this::exists, this.em);
+				scrollDelegate, this::count, this::exists, this.em);
 
 		return queryFunction.apply((FetchableFluentQuery<S>) fluentQuery);
 	}
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 436ba98100..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
@@ -1245,13 +1245,13 @@ void scrollByExampleOffset() {
 		Window<User> firstWindow = repository.findBy(example,
 				q -> q.limit(2).sortBy(Sort.by("firstname")).scroll(OffsetScrollPosition.initial()));
 
-		assertThat(firstWindow).hasSize(2).containsOnly(jane1, jane2);
+		assertThat(firstWindow).containsExactly(jane1, jane2);
 		assertThat(firstWindow.hasNext()).isTrue();
 
 		Window<User> nextWindow = repository.findBy(example,
 				q -> q.limit(2).sortBy(Sort.by("firstname")).scroll(firstWindow.positionAt(1)));
 
-		assertThat(nextWindow).hasSize(2).containsOnly(john1, john2);
+		assertThat(nextWindow).containsExactly(john1, john2);
 		assertThat(nextWindow.hasNext()).isFalse();
 	}
 
@@ -1277,7 +1277,7 @@ void scrollByExampleKeyset() {
 		Window<User> nextWindow = repository.findBy(example,
 				q -> q.limit(2).sortBy(Sort.by("firstname", "emailAddress")).scroll(firstWindow.positionAt(0)));
 
-		assertThat(nextWindow).hasSize(2).containsOnly(jane2, john1);
+		assertThat(nextWindow).containsExactly(jane2, john1);
 		assertThat(nextWindow.hasNext()).isTrue();
 	}
 
@@ -1319,13 +1319,13 @@ void scrollByPredicateOffset() {
 		Window<User> firstWindow = repository.findBy(QUser.user.firstname.startsWith("J"),
 				q -> q.limit(2).sortBy(Sort.by("firstname")).scroll(OffsetScrollPosition.initial()));
 
-		assertThat(firstWindow).hasSize(2).containsOnly(jane1, jane2);
+		assertThat(firstWindow).containsExactly(jane1, jane2);
 		assertThat(firstWindow.hasNext()).isTrue();
 
 		Window<User> nextWindow = repository.findBy(QUser.user.firstname.startsWith("J"),
 				q -> q.limit(2).sortBy(Sort.by("firstname")).scroll(firstWindow.positionAt(1)));
 
-		assertThat(nextWindow).hasSize(2).containsOnly(john1, john2);
+		assertThat(nextWindow).containsExactly(john1, john2);
 		assertThat(nextWindow.hasNext()).isFalse();
 	}
 
@@ -1348,7 +1348,7 @@ void scrollByPredicateKeyset() {
 		Window<User> nextWindow = repository.findBy(QUser.user.firstname.startsWith("J"),
 				q -> q.limit(2).sortBy(Sort.by("firstname", "emailAddress")).scroll(firstWindow.positionAt(0)));
 
-		assertThat(nextWindow).hasSize(2).containsOnly(jane2, john1);
+		assertThat(nextWindow).containsExactly(jane2, john1);
 		assertThat(nextWindow.hasNext()).isTrue();
 	}
 
@@ -1365,7 +1365,7 @@ void scrollByPredicateKeysetBackward() {
 		Window<User> firstWindow = repository.findBy(QUser.user.firstname.startsWith("J"),
 				q -> q.limit(3).sortBy(Sort.by("firstname", "emailAddress")).scroll(KeysetScrollPosition.initial()));
 
-		assertThat(firstWindow).containsSequence(jane1, jane2, john1);
+		assertThat(firstWindow).containsExactly(jane1, jane2, john1);
 		assertThat(firstWindow.hasNext()).isTrue();
 
 		KeysetScrollPosition scrollPosition = (KeysetScrollPosition) firstWindow.positionAt(2);
@@ -1374,7 +1374,7 @@ void scrollByPredicateKeysetBackward() {
 		Window<User> previousWindow = repository.findBy(QUser.user.firstname.startsWith("J"),
 				q -> q.limit(3).sortBy(Sort.by("firstname", "emailAddress")).scroll(backward));
 
-		assertThat(previousWindow).hasSize(2).containsSequence(jane1, jane2);
+		assertThat(previousWindow).containsExactly(jane1, jane2);
 
 		// no more items before this window
 		assertThat(previousWindow.hasNext()).isFalse();
@@ -1393,7 +1393,7 @@ void scrollByPartTreeKeysetBackward() {
 		Window<User> firstWindow = repository.findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc("J",
 				KeysetScrollPosition.initial());
 
-		assertThat(firstWindow).containsSequence(jane1, jane2, john1);
+		assertThat(firstWindow).containsExactly(jane1, jane2, john1);
 		assertThat(firstWindow.hasNext()).isTrue();
 
 		KeysetScrollPosition scrollPosition = (KeysetScrollPosition) firstWindow.positionAt(2);
@@ -1402,7 +1402,7 @@ void scrollByPartTreeKeysetBackward() {
 		Window<User> previousWindow = repository.findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc("J",
 				backward);
 
-		assertThat(previousWindow).hasSize(2).containsSequence(jane1, jane2);
+		assertThat(previousWindow).containsExactly(jane1, jane2);
 
 		// no more items before this window
 		assertThat(previousWindow.hasNext()).isFalse();
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
index f184bffd0d..6deb691d6e 100644
--- 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
@@ -31,16 +31,16 @@ class CollectionUtilsUnitTests {
 	@Test // GH-2878
 	void shouldReturnFirstItems() {
 
-		assertThat(CollectionUtils.getFirst(2, List.of(1, 2, 3))).hasSize(2).containsSequence(1, 2);
-		assertThat(CollectionUtils.getFirst(2, List.of(1, 2))).hasSize(2).containsSequence(1, 2);
-		assertThat(CollectionUtils.getFirst(2, List.of(1))).hasSize(1).containsSequence(1);
+		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))).hasSize(2).containsSequence(2, 3);
-		assertThat(CollectionUtils.getLast(2, List.of(1, 2))).hasSize(2).containsSequence(1, 2);
-		assertThat(CollectionUtils.getLast(2, List.of(1))).hasSize(1).containsSequence(1);
+		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/src/main/asciidoc/jpa.adoc b/src/main/asciidoc/jpa.adoc
index e6bc77680e..723989c40f 100644
--- a/src/main/asciidoc/jpa.adoc
+++ b/src/main/asciidoc/jpa.adoc
@@ -530,17 +530,20 @@ Throws Exception.
 ====
 
 [[jpa.query-methods.scroll]]
-=== Scrolling large Query Results
+=== Scrolling Large Query Results
 
 When working with large data sets, <<repositories.scrolling,scrolling>> can help to process those results efficiently without loading all results into memory.
 
-Spring Data JPA supports offset- and keyset-based scrolling.
-A variant of offset scrolling provided through <<repositories.paging-and-sorting,Paging>>.
+You have multiple options to consume large query results:
 
-Keyset-based scrolling avoids https://use-the-index-luke.com/no-offset[the shortcomings of offset-based result retrieval by leveraging database indexes].
-However, keyset-based results should avoid `null` values as these do not work well with sorting.
+1. <<repositories.paging-and-sorting,Paging>>.
+You have learned in the previous chapter about `Pageable` and `PageRequest`.
+2. <<repositories.scrolling.offset,Offset-based scrolling>>.
+This is a lighter variant than paging because it does not require the total result count.
+3. <<repositories.scrolling.keyset,Keyset-baset scrolling>>.
+This method avoids https://use-the-index-luke.com/no-offset[the shortcomings of offset-based result retrieval by leveraging database indexes].
 
-In contrast to `Page<T>`, the Scroll API returning `Window<T>` is much more lightweight and flexible.
+Read more on <<repositories.scrolling.guidance,which method to use best>> for your particular arrangement.
 
 You can use the Scroll API with query methods, <<query-by-example.running,Query-by-Example>>, and <<core.extensions.querydsl,Querydsl>>.