Repository: jakartaee/data
Pull request #52 (2022-10-20 20:22:40)

keyset pagination API and JavaDoc
Keyset pagination API and JavaDoc for #39

Signed-off-by: Nathan Rauh <nathan.rauh@us.ibm.com>

njr-11: Rebased onto #37 for Slice, Page, and updates to Pageable.
Updated per above.
Additional specification documentation added.
njr-11: Copying @beikov for additional review
njr-11: @beikov thanks for the excellent review comments. I definitely missed mentioning the performance benefit and the requirements for the collection of keyset values to uniquely identify each entity.  I've made updates to accommodate before/after with a Cursor and have made the provided Cursor class extensible.  It might be possible to make it into an interface as well.  I'll look into that some more.
njr-11: It ended up cleaner than I thought to switch Cursor to an interface. That is updated now as well.  All of the review comments should be addressed now.  Let me know if any additional changes are needed.
njr-11: > otaviojava requested review from mpredli01 and keilw 5 days ago

@keilw all of the other reviewers have completed their reviews. Let me know if you had any comments on this or if you were not interested in reviewing. Thanks!
njr-11: > This Built-in implementation of `Cursor` seems a little odd. What is the purpose and is it intended to be overwritten by implementations? If not, why is it an interface instead of just a class?

Originally I had only the built-in Cursor implementation as its own class (and no interface) as you are suggesting, with its purpose being to shield applications from having to deal with the Cursor at all. In many cases, the application only needs to supply the keyset values they are interested in and doesn't need to worry about getting a Cursor from somewhere just to hand the keyset values to the API.

It was brought up separately by one of the other reviewers that some Jakata Data providers will want to create their own custom Cursor implementations, so the interface was introduced, allowing for both patterns.

Another possibility could be having a Cursor class only and making it extensible, but @beikov would need to agree to that.
beikov: >This Built-in implementation of Cursor seems a little odd. What is the purpose and is it intended to be overwritten by implementations?
>If not, why is it an interface instead of just a class?

My intention is to allow jakarta-data implementers to provide their own `Cursor` implementations to e.g. provide extensions like exposing the order by item expression or simply provide a custom serialization implementation.

To me, having an interface here is the most flexible. Since users most probably won't create `Cursor` instances themselves, I also don't think that the interface here is a problem. Can you elaborate what you think is odd in particular?

>Another possibility could be having a Cursor class only and making it extensible, but @beikov would need to agree to that.

We could surely do that, but then the object layout would be fixed i.e. every subtype has to use an `Object[]` as storage or ignore that. Can you explain what kind of problems you think the interface approach could lead to?
njr-11: @keilw Would it be alright to merge this as it is now (with the interface) and then I'll open a second pull that would switch to an extensible class which can be discussed and decided separately?

Patch:
diff --git a/api/src/main/java/jakarta/data/repository/KeysetAwarePage.java b/api/src/main/java/jakarta/data/repository/KeysetAwarePage.java
new file mode 100644
index 00000000..cb08fa38
--- /dev/null
+++ b/api/src/main/java/jakarta/data/repository/KeysetAwarePage.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2022 Contributors to the Eclipse Foundation
+ *
+ * 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
+ *
+ *     http://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.
+ *
+ *  SPDX-License-Identifier: Apache-2.0
+ */
+package jakarta.data.repository;
+
+/**
+ * <p>A page of results from a repository query that performs
+ * {@link KeysetPageable keyset pagination}.</p>
+ */
+public interface KeysetAwarePage<T> extends KeysetAwareSlice<T>, Page<T> {
+}
\ No newline at end of file
diff --git a/api/src/main/java/jakarta/data/repository/KeysetAwareSlice.java b/api/src/main/java/jakarta/data/repository/KeysetAwareSlice.java
new file mode 100644
index 00000000..84aba106
--- /dev/null
+++ b/api/src/main/java/jakarta/data/repository/KeysetAwareSlice.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright (c) 2022 Contributors to the Eclipse Foundation
+ *
+ * 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
+ *
+ *     http://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.
+ *
+ *  SPDX-License-Identifier: Apache-2.0
+ */
+package jakarta.data.repository;
+
+/**
+ * <p>A page of results from a repository query that performs
+ * {@link KeysetPageable keyset pagination}.</p>
+ *
+ * <p>The concept of {@link Slice} differs from {@link Page} in that slices
+ * do not have awareness of a total number of pages or results. This fits well
+ * with keyset pagination, which allows entities to be added and removed
+ * in between traversal of slices or pages and makes estimates of a total
+ * inaccurate.</p>
+ */
+public interface KeysetAwareSlice<T> extends Slice<T> {
+    /**
+     * Returns a {@link KeysetPageable.Cursor Cursor} for keyset values at the
+     * specified position.
+     *
+     * @param index position (0 is first) of a result on the page.
+     * @return cursor for keyset values at the specified position.
+     */
+    KeysetPageable.Cursor getKeysetCursor(int index);
+
+    /**
+     * <p>Returns pagination information for requesting the next page
+     * in a forward direction from the current page. This method computes a
+     * keyset from the last entity of the current page and includes the
+     * keyset in the pagination information so that it can be used to
+     * obtain the next slice in a forward direction according to the
+     * sort criteria and relative to that entity.</p>
+     *
+     * @return pagination information for requesting the next page, or
+     *         <code>null</code> if the current page is empty
+     *         or if it is known that there is not a next page.
+     */
+    @Override
+    KeysetPageable nextPageable();
+
+    /**
+     * <p>Returns pagination information for requesting the previous page
+     * in a reverse direction from the current page. This method computes a
+     * keyset from the first entity of the current page and includes the
+     * keyset in the pagination information so that it can be used to
+     * obtain the previous slice in a reverse direction to the sort criteria
+     * and relative to that entity. Within a single page, results are not
+     * reversed and remain ordered according to the sort criteria.</p>
+     *
+     * <p>Page numbers are not accurate and should not be relied upon when
+     * using keyset pagination. Jakarta Data providers should aim to at least
+     * avoid returning negative or <code>0</code> as page numbers when
+     * traversing pages in the reverse direction (this might otherwise occur
+     * when matching entities are added prior to the first page and the
+     * previous page is requested) by assigning a page number of <code>1</code>
+     * to such pages. This means that there can be multiple consecutive pages
+     * numbered <code>1</code> and that
+     * <code>currentPage.previousPageable().next().getPage()</code>
+     * cannot be relied upon to return a page number that is equal to the
+     * current page number.</p>
+     *
+     * @return pagination information for requesting the previous page, or
+     *         <code>null</code> if the current page is empty
+     *         or if it is known that there is not a previous page.
+     */
+    KeysetPageable previousPageable();
+}
\ No newline at end of file
diff --git a/api/src/main/java/jakarta/data/repository/KeysetPageable.java b/api/src/main/java/jakarta/data/repository/KeysetPageable.java
new file mode 100644
index 00000000..90f3b847
--- /dev/null
+++ b/api/src/main/java/jakarta/data/repository/KeysetPageable.java
@@ -0,0 +1,328 @@
+/*
+ * Copyright (c) 2022 Contributors to the Eclipse Foundation
+ *
+ * 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
+ *
+ *     http://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.
+ *
+ *  SPDX-License-Identifier: Apache-2.0
+ */
+package jakarta.data.repository;
+
+import java.util.Arrays;
+import java.util.Objects;
+
+/**
+ * <p>Keyset pagination is a form of pagination that aims to reduce the
+ * possibility of missed or duplicate results by making the request for
+ * each subsequent page relative to the observed values of entity properties
+ * from the current page. This list of values is referred to as the keyset
+ * and only includes values of entity properties that are in the sort criteria
+ * of the repository method. The combination of sort criteria must uniquely
+ * identify each entity. The keyset values can be from the last entity
+ * (for pagination in a forward direction) or first entity on the page
+ * (if requesting pages in a reverse direction),
+ * or can be any other desired list of values which serve as a new starting
+ * point. Keyset pagination also has the potential to improve performance
+ * by avoiding the fetching and ordering of results from prior pages
+ * because these become non-matching.</p>
+ *
+ * <p>To use keyset pagination, define a repository method with return value of
+ * {@link KeysetAwareSlice} or {@link KeysetAwarePage} and which accepts a
+ * special parameter (after the normal query parameters) that is a
+ * {@link Pageable}. For example,</p>
+ *
+ * <pre>
+ * &#64;OrderBy("lastName")
+ * &#64;OrderBy("firstName")
+ * &#64;OrderBy("id")
+ * KeysetAwareSlice&lt;Employee&gt; findByHoursWorkedGreaterThan(int hours, Pageable pagination);
+ * </pre>
+ *
+ * <p>You can use a normal {@link Pageable} to request an initial page,</p>
+ *
+ * <pre>
+ * page = employees.findByHoursWorkedGreaterThan(1500, Pageable.size(50));
+ * </pre>
+ *
+ * <p>For subsequent pages, you can request pagination relative to the
+ * end of the current page as follows,</p>
+ *
+ * <pre>
+ * page = employees.findByHoursWorkedGreaterThan(1500, page.nextPageable());
+ * </pre>
+ *
+ * <p>Because the page is keyset aware, the <code>KeysetPageable</code>
+ * that it returns from the call to {@link KeysetAwareSlice#nextPageable}
+ * above is based upon a keyset from that page to use as a starting point
+ * after which the results for the next page are to be found.</p>
+ *
+ * <p>You can also construct a <code>KeysetPageable</code> directly, which
+ * allows you to make it relative to a specific list of values. The number and
+ * order of values must match that of the {@link OrderBy} annotations,
+ * <code>Pageable</code> {@link Pageable#of(long, long, Sort...) Sort} parameters,
+ * or <code>OrderBy</code> name pattern of the repository method.
+ * For example,</p>
+ *
+ * <pre>
+ * Employee emp = ...
+ * KeysetPageable pagination = Pageable.size(50).afterKeyset(emp.lastName, emp.firstName, emp.id);
+ * page = employees.findByHoursWorkedGreaterThan(1500, pagination);
+ * </pre>
+ *
+ * <p>By making the query for the next page relative to observed values,
+ * not a numerical position, keyset pagination is less vulnerable to changes
+ * that are made to data in between page requests. Adding or removing entities
+ * is possible without causing unexpected missed or duplicate results.
+ * Keyset pagination does not prevent misses and duplicates if the entity
+ * properties which are the sort criteria for existing entities are modified
+ * or if an entity is re-added with different sort criteria after having
+ * previously been removed.</p>
+ *
+ * <h2>Keyset Pagination with &#64;Query</h2>
+ *
+ * <p>Keyset pagination involves generating and appending to the query
+ * additional query conditions for the keyset properties. In order for that to
+ * be possible, a user-provided
+ * <a href="https://eclipse-ee4j.github.io/jakartaee-tutorial/#full-query-language-syntax">JPQL</a>
+ * query must end with a
+ * <code>WHERE</code> clause to which additional conditions can be appended.
+ * Enclose the entire conditional expression of the <code>WHERE</code> clause
+ * in parenthesis.
+ * Sort criteria must be specified independently from the user-provided query,
+ * either with the {@link OrderBy} annotation or
+ * <code>Pageable</code> {@link Pageable#of(long, long, Sort...) Sort} parameters.
+ * For example,</p>
+ *
+ * <pre>
+ * &#64;Query("SELECT o FROM Customer o WHERE (o.ordersPlaced &gt;= ?1 OR o.totalSpent &gt;= ?2)")
+ * &#64;OrderBy("zipcode")
+ * &#64;OrderBy("birthYear")
+ * &#64;OrderBy("id")
+ * KeysetAwareSlice&lt;Customer&gt; getTopBuyers(int minOrders, float minSpent, Pageable pagination);
+ * </pre>
+ *
+ * <h2>Page Numbers and Totals</h2>
+ *
+ * <p>Page numbers, total numbers of elements across all pages, and total
+ * count of pages are not accurate when keyset pagination is used and should
+ * not be relied upon.</p>
+ */
+public class KeysetPageable extends Pageable {
+    /**
+     * Direction of keyset pagination.
+     */
+    public static enum Mode {
+        /**
+         * Indicates forward keyset pagination, which follows the
+         * direction of the {@link OrderBy} annotations, <code>Pageable</code>
+         * {@link Pageable#of(long, long, Sort...) Sort} parameters,
+         * or <code>OrderBy</code> name pattern of the repository method.
+         */
+        NEXT,
+
+        /**
+         * Indicates a request for a page in the reverse direction of
+         * the {@link OrderBy} annotations, <code>Pageable</code>
+         * {@link Pageable#of(long, long, Sort...) Sort} parameters,
+         * or <code>OrderBy</code> name pattern of the repository method.
+         * The order of results on each page follows the sort criteria
+         * and is not reversed.
+         */
+        PREVIOUS
+    }
+
+    /**
+     * Represents keyset values, which can be a starting point for
+     * requesting a next or previous page.
+     */
+    public interface Cursor {
+        /**
+         * Returns whether or not the keyset values of this instance
+         * are equal to those of the supplied instance.
+         * Cursor implementation classes must also match to be equal.
+         *
+         * @return true or false.
+         */
+        @Override
+        public boolean equals(Object o);
+
+        /**
+         * Returns the keyset value at the specified position.
+         *
+         * @param  index position (0 is first) of the keyset value to obtain.
+         * @return the keyset value at the specified position.
+         * @throws IndexOutOfBoundsException if the index is negative
+         *         or greater than or equal to the {@link #size}.
+         */
+        public Object getKeysetElement(int index);
+
+        /**
+         * Returns a hash code based on the keyset values.
+         *
+         * @return a hash code based on the keyset values.
+         */
+        @Override
+        public int hashCode();
+
+        /**
+         * Returns the number of values in the keyset.
+         *
+         * @return the number of values in the keyset.
+         */
+        public int size();
+
+        /**
+         * String representation of the keyset cursor, including the number of
+         * key values in the cursor but not the values themselves.
+         *
+         * @return String representation of the keyset cursor.
+         */
+        @Override
+        public String toString();
+    }
+
+    /**
+     * Built-in implementation of Cursor.
+     */
+    static final class CursorImpl implements Cursor {
+        /**
+         * Keyset values.
+         */
+        private final Object[] keyset;
+
+        /**
+         * Constructs a keyset cursor with the specified values.
+         *
+         * @param keyset keyset values.
+         * @throws IllegalArgumentException if no keyset values are provided.
+         */
+        CursorImpl(Object... keyset) {
+            this.keyset = keyset;
+            if (keyset == null || keyset.length == 0)
+                throw new IllegalArgumentException("No keyset values were provided.");
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            return this == o || o != null
+                    && o.getClass() == getClass()
+                    && Arrays.equals(keyset, ((CursorImpl) o).keyset);
+        }
+
+        public Object getKeysetElement(int index) {
+            return keyset[index];
+        }
+
+        @Override
+        public int hashCode() {
+            return Arrays.hashCode(keyset);
+        }
+
+        public int size() {
+            return keyset.length;
+        }
+
+        @Override
+        public String toString() {
+            return new StringBuilder(27).append("Cursor@").append(Integer.toHexString(hashCode()))
+                            .append(" with ").append(keyset.length).append(" keys")
+                            .toString();
+        }
+    }
+
+    private final Cursor cursor;
+    private final Mode mode;
+
+    KeysetPageable(Pageable copyFrom, Mode mode, Cursor cursor) {
+        super(copyFrom.page, copyFrom.size, copyFrom.sorts);
+
+        if (cursor == null || cursor.size() == 0)
+            throw new IllegalArgumentException("No keyset values were provided.");
+
+        this.cursor = cursor;
+        this.mode = mode;
+    }
+
+
+    /**
+     * Indicates if this keyset pagination information is equal to other
+     * keyset pagination information. The comparison includes keyset cursor
+     * instances but does not do a deep comparison of keyset values within
+     * the cursor instance.
+     *
+     * @return true if equal. Otherwise false.
+     */
+    @Override
+    public boolean equals(Object o) {
+        KeysetPageable p;
+        return this == o || o != null
+                && o.getClass() == getClass()
+                && (p = (KeysetPageable) o).size == size
+                && p.page == page
+                && p.mode == mode
+                && p.cursor.equals(cursor)
+                && p.sorts.equals(sorts);
+    }
+
+    /**
+     * Returns the keyset values which are the starting point for
+     * keyset pagination.
+     *
+     * @return the keyset values.
+     */
+    public Cursor getCursor() {
+        return cursor;
+    }
+
+    /**
+     * Returns the direction of keyset pagination.
+     *
+     * @return the direction of keyset pagination.
+     */
+    public Mode getMode() {
+        return mode;
+    }
+
+    /**
+     * <p>Returns a hash code based upon:</p>
+     * <ul>
+     * <li>page number</li>
+     * <li>maximum page size</li>
+     * <li>mode</li>
+     * <li>keyset cursor</li>
+     * </ul>
+     *
+     * @return a hash code for the pagination information.
+     */
+    @Override
+    public int hashCode() {
+        return Objects.hash(size, page, sorts, mode, cursor);
+    }
+
+    /**
+     * Returns a string representation of the pagination information.
+     *
+     * @return a string representation of the pagination information.
+     */
+    @Override
+    public String toString() {
+        StringBuilder s = new StringBuilder(150)
+                .append("KeysetPageable{page=").append(page)
+                .append(", size=").append(size)
+                .append(", mode=").append(mode)
+                .append(", ").append(cursor.size()).append(" keys");
+        for (Sort sort : sorts) {
+            s.append(", ").append(sort.getProperty()).append(sort.isAscending() ? " ASC" : " DESC");
+        }
+        return s.append("}").toString();
+    }
+}
\ No newline at end of file
diff --git a/api/src/main/java/jakarta/data/repository/OrderBy.java b/api/src/main/java/jakarta/data/repository/OrderBy.java
index 655e3d61..259f4d6e 100644
--- a/api/src/main/java/jakarta/data/repository/OrderBy.java
+++ b/api/src/main/java/jakarta/data/repository/OrderBy.java
@@ -47,10 +47,13 @@ import java.lang.annotation.Target;
  * numeric values means smaller numbers before larger numbers and for
  * string values means <code>A</code> before <code>Z</code>.</p>
  *
- * <p>Do not specify this annotation on a repository method with the
- * <code>OrderBy</code> keyword in its name or that already includes a
- * sorting parameter (TODO add link once added).
- * This will cause an exception to be raised by the repository method.</p>
+ * <p>A repository method will fail if an <code>OrderBy</code> annotation is
+ * specified in combination with any of:</p>
+ * <ul>
+ * <li>an <code>OrderBy</code> keyword</li>
+ * <li>a <code>Sort</code> parameter</li>
+ * <li>a {@link Query} annotation that contains an <code>ORDER BY</code> clause.</li>
+ * </ul>
  */
 @Repeatable(OrderBy.List.class)
 @Retention(RetentionPolicy.RUNTIME)
diff --git a/api/src/main/java/jakarta/data/repository/Pageable.java b/api/src/main/java/jakarta/data/repository/Pageable.java
index 1805b890..4009fc88 100644
--- a/api/src/main/java/jakarta/data/repository/Pageable.java
+++ b/api/src/main/java/jakarta/data/repository/Pageable.java
@@ -23,6 +23,8 @@ import java.util.Objects;
 import java.util.stream.Collectors;
 import java.util.stream.StreamSupport;
 
+import jakarta.data.repository.KeysetPageable.Cursor;
+
 /**
  * <p>This class represents pagination information.</p>
  *
@@ -56,18 +58,78 @@ public class Pageable {
 
     private static final long DEFAULT_SIZE = 10;
 
-    private final long page;
+    final long page;
 
-    private final long size;
+    final long size;
 
-    private final List<Sort> sorts;
+    final List<Sort> sorts;
 
-    private Pageable(long page, long size, List<Sort> sorts) {
+    Pageable(long page, long size, List<Sort> sorts) {
         this.page = page;
         this.size = size;
         this.sorts = sorts;
     }
 
+    /**
+     * <p>Requests {@link KeysetPageable keyset pagination} in the forward direction,
+     * starting after the specified keyset values.</p>
+     *
+     * @param keyset keyset values, the order and number of which must match the
+     *        {@link OrderBy} annotations, {@link Sort} parameters, or
+     *        <code>OrderBy</code> name pattern of the repository method to which
+     *        this pagination will be supplied.
+     * @return forward keyset pagination.
+     * @throws IllegalArgumentException if no keyset values are provided.
+     */
+    public KeysetPageable afterKeyset(Object... keyset) {
+        return new KeysetPageable(this, KeysetPageable.Mode.NEXT, new KeysetPageable.CursorImpl(keyset));
+    }
+
+    /**
+     * <p>Requests {@link KeysetPageable keyset pagination} in the reverse direction,
+     * starting after the specified keyset values.</p>
+     *
+     * @param keyset keyset values, the order and number of which must match the
+     *        {@link OrderBy} annotations, {@link Sort} parameters, or
+     *        <code>OrderBy</code> name pattern of the repository method to which
+     *        this pagination will be supplied.
+     * @return reverse keyset pagination.
+     * @throws IllegalArgumentException if no keyset values are provided.
+     */
+    public KeysetPageable beforeKeyset(Object... keyset) {
+        return new KeysetPageable(this, KeysetPageable.Mode.PREVIOUS, new KeysetPageable.CursorImpl(keyset));
+    }
+
+    /**
+     * <p>Requests {@link KeysetPageable keyset pagination} in the forward direction,
+     * starting after the specified keyset values.</p>
+     *
+     * @param keysetCursor cursor with keyset values, the order and number of which must match the
+     *        {@link OrderBy} annotations, {@link Sort} parameters, or
+     *        <code>OrderBy</code> name pattern of the repository method to which
+     *        this pagination will be supplied.
+     * @return forward keyset pagination.
+     * @throws IllegalArgumentException if no keyset values are provided.
+     */
+    public KeysetPageable afterKeysetCursor(Cursor keysetCursor) {
+        return new KeysetPageable(this, KeysetPageable.Mode.NEXT, keysetCursor);
+    }
+
+    /**
+     * <p>Requests {@link KeysetPageable keyset pagination} in the reverse direction,
+     * starting after the specified keyset values.</p>
+     *
+     * @param keysetCursor cursor with keyset values, the order and number of which must match the
+     *        {@link OrderBy} annotations, {@link Sort} parameters, or
+     *        <code>OrderBy</code> name pattern of the repository method to which
+     *        this pagination will be supplied.
+     * @return reverse keyset pagination.
+     * @throws IllegalArgumentException if no keyset values are provided.
+     */
+    public KeysetPageable beforeKeysetCursor(Cursor keysetCursor) {
+        return new KeysetPageable(this, KeysetPageable.Mode.PREVIOUS, keysetCursor);
+    }
+
     /**
      * Returns the page to be returned.
      *
@@ -123,10 +185,13 @@ public class Pageable {
 
     @Override
     public String toString() {
-        return "Pageable{" +
-                "page=" + page +
-                ", size=" + size +
-                '}';
+        StringBuilder s = new StringBuilder(100)
+                .append("Pageable{page=").append(page)
+                .append(", size=").append(size);
+        for (Sort sort : sorts) {
+            s.append(", ").append(sort.getProperty()).append(sort.isAscending() ? " ASC" : " DESC");
+        }
+        return s.append("}").toString();
     }
 
     /**
diff --git a/api/src/main/java/jakarta/data/repository/Sort.java b/api/src/main/java/jakarta/data/repository/Sort.java
index 0db24a9e..f70e806a 100644
--- a/api/src/main/java/jakarta/data/repository/Sort.java
+++ b/api/src/main/java/jakarta/data/repository/Sort.java
@@ -41,10 +41,13 @@ import java.util.Objects;
  * (<code>OrderBy</code> keyword or {@link Query} or {@link OrderBy} annotation)
  * where possible to better allow for optimizations by the provider.</p>
  *
- * <p>A repository method will raise {@link IllegalArgumentException} if
- * a <code>Sort</code> parameter is specified in combination
- * with an <code>OrderBy</code> keyword or {@link Query} or
- * {@link OrderBy} annotation.</p>
+ * <p>A repository method will fail if a <code>Sort</code> parameter is
+ * specified in combination with any of:</p>
+ * <ul>
+ * <li>an <code>OrderBy</code> keyword</li>
+ * <li>an {@link OrderBy} annotation</li>
+ * <li>a {@link Query} annotation that contains an <code>ORDER BY</code> clause.</li>
+ * </ul>
  */
 public class Sort {
 
diff --git a/spec/src/main/asciidoc/repository.asciidoc b/spec/src/main/asciidoc/repository.asciidoc
index a329a519..e5dc574f 100644
--- a/spec/src/main/asciidoc/repository.asciidoc
+++ b/spec/src/main/asciidoc/repository.asciidoc
@@ -243,7 +243,7 @@ Jakarta Data implementations often support the following list of predicate keywo
 
 |===
 
-===== Special Parameter Handling
+=== Special Parameter Handling
 
 Jakarta Data also supports particular parameters to define pagination and sorting.
 
@@ -276,3 +276,111 @@ Pageable pageable = Pageable.of(1, 20);
 first20 = products.findByNameLike(name, pageable, Sort.desc("price"));
 
 ----
+
+=== Keyset Pagination
+
+Keyset pagination aims to reduce missed and duplicate results across pages by querying relative to the observed values of entity properties that constitute the sorting criteria. Keyset pagination can also offer an improvement in performance because it avoids fetching and ordering results from prior pages by causing those results to be non-matching. A Jakarta Data provider appends additional conditions to the query and tracks keyset values automatically when `KeysetAwareSlice` or `KeysetAwarePage` are used as the repository method return type. The application invokes `nextPageable` or `previousPageable` on the keyset aware slice or page to obtain a `KeysetPageable` which keeps track of the keyset values.
+
+For example,
+
+[source,java]
+----
+@Repository
+public interface CustomerRepository extends CrudRepository<Customer, Long> {
+  KeysetAwareSlice<Customer> findByZipcodeOrderByLastNameAscFirstNameAscIdAsc(
+                                 int zipcode, Pageable pageable);
+}
+----
+
+You can obtain the next page with,
+
+[source,java]
+----
+for (Pageable p = Pageable.size(50); p != null; ) {
+  page = customers.findByZipcodeOrderByLastNameAscFirstNameAscIdAsc(55901, p);
+  ...
+  p = page.nextPageable();
+}
+----
+
+Or you can obtain the next (or previous) page relative to a known entity,
+
+[source,java]
+----
+Customer c = ...
+KeysetPageable p = Pageable.size(50).afterKeyset(c.lastName, c.firstName, c.id);
+page = customers.findByZipcodeOrderByLastNameAscFirstNameAscIdAsc(55902, p);
+----
+
+The sort criteria for a repository method that performs keyset pagination must uniquely identify each entity and must be provided by one of:
+
+* `OrderBy` name pattern of the repository method (as in the examples above).
+* `@OrderBy` annotation(s) on the repository method.
+* `Sort` parameters of the `Pageable` that is supplied to the repository method.
+
+If either of the first are specified, the `Sort` list of the `Pageable` must be empty or unspecified.
+
+==== Example of Appending to Queries for Keyset Pagination
+
+Without keyset pagination, a Jakarta Data provider that is based on Jakarta Persistence might compose the following JPQL for the `findByZipcodeOrderByLastNameAscFirstNameAscIdAsc` repository method from the prior example:
+
+[source,jpql]
+----
+SELECT o FROM Customer o WHERE (o.zipCode = ?1)
+                         ORDER BY o.lastName ASC, o.firstName ASC, o.id ASC
+----
+
+When keyset pagination is used, the keyset values from the `KeysetPageable` are available as query parameters, allowing the Jakarta Data provider to append additional query conditions. For example,
+
+[source,jpql]
+----
+SELECT o FROM Customer o WHERE (o.zipCode = ?1)
+                           AND (   (o.lastName > ?2)
+                                OR (o.lastName = ?2 AND o.firstName > ?3)
+                                OR (o.lastName = ?2 AND o.firstName = ?3 AND o.id > ?4)
+                               )
+                         ORDER BY o.lastName ASC, o.firstName ASC, o.id ASC
+----
+
+==== Avoiding Missed and Duplicate Results
+
+Because searching for the next page of results is relative to a last known position, it is possible with keyset pagination to allow some types of updates to data while pages are being traversed without causing missed results or duplicates to appear. If you add entities to a prior position in the traversal of pages, the shift forward of numerical position of existing entities will not cause duplicates entities to appear in your continued traversal of subsequent pages because keyset pagination does not query based on a numerical position. If you remove entities from a prior position in the traversal of pages, the shift backward of numerical position of existing entities will not cause missed entities in your continued traversal of subsequent pages because keyset pagination does not query based on a numerical position.
+
+Other types of updates to data, however, will cause duplicate or missed results. If you modify entity properties which are used as the sort criteria, keyset pagination cannot prevent the same entity from appearing again or never appearing due to the altered values. If you add an entity that you previously removed, whether with different values or the same values, keyset pagination cannot prevent the entity from being missed or possibly appearing a second time due to its changed values.
+
+==== Restrictions on use of Keyset Pagination
+
+* The repository method signature must return `KeysetAwareSlice` or `KeysetAwarePage`.
+* The repository method signature must accept a `Pageable` or `KeysetPageable` parameter.
+* Sort criteria must be provided and should be minimal.
+* The combination of provided sort criteria must uniquely identify each entity.
+* Page numbers for keyset pagination are estimated relative to prior page requests or the observed absence of further results and are not accurate. Page numbers must not be relied upon when using keyset pagination.
+* Page totals and result totals are not accurate for keyset pagination and must not be relied upon.
+* A next or previous page can end up being empty. You cannot obtain a next or previous `KeysetPageable` from an empty page because there are no keyset values relative to which to query.
+* A repository method that is annotated with `@Query` and performs keyset pagination must omit the `ORDER BY` clause from the provided query and instead must supply the sort criteria via `@OrderBy` annotations or `Sort` parameters of `Pageable`. The provided query must end with a `WHERE` clause to which additional conditions can be appended by the Jakarta Data provider. The Jakarta Data provider is not expected to parse query text that is provided by the application.
+
+==== Keyset Pagination Example with Sorts
+
+Here is an example where an application uses `@Query` to provide a partial query to which the Jakarta Data provider can generate and append additional query conditions and an `ORDER BY` clause.
+
+[source,java]
+----
+@Repository
+public interface CustomerRepository extends CrudRepository<Customer, Long> {
+  @Query("SELECT o FROM Customer o WHERE (o.totalSpent / o.totalPurchases > ?1)")
+  KeysetAwareSlice<Customer> withAveragePurchaseAbove(float minimum, Pageable pagination);
+}
+----
+
+Example traversal of pages:
+
+[source,java]
+----
+for (Pageable p = Pageable.of(1, 25, Sort.desc("yearBorn"), Sort.asc("name"), Sort.asc("id"));
+     p != null; ) {
+  page = customers.withAveragePurchaseAbove(50.0f, p);
+  ...
+  p = page.nextPageable();
+}
+----
+


Test patch:
diff --git a/api/src/test/java/jakarta/data/repository/KeysetPageableTest.java b/api/src/test/java/jakarta/data/repository/KeysetPageableTest.java
new file mode 100644
index 00000000..e1e7cfa5
--- /dev/null
+++ b/api/src/test/java/jakarta/data/repository/KeysetPageableTest.java
@@ -0,0 +1,207 @@
+/*
+ * Copyright (c) 2022 Contributors to the Eclipse Foundation
+ *
+ * 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
+ *
+ *     http://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.
+ *
+ *  SPDX-License-Identifier: Apache-2.0
+ */
+package jakarta.data.repository;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.HashMap;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.SoftAssertions.assertSoftly;
+
+class KeysetPageableTest {
+
+    @Test
+    @DisplayName("Should include keyset values in next KeysetPageable")
+    void shouldCreateKeysetPageableAfterKeyset() {
+        KeysetPageable pageable = Pageable.size(20).afterKeyset("First", 2L, 3);
+
+        assertSoftly(softly -> {
+            softly.assertThat(pageable.getSize()).isEqualTo(20L);
+            softly.assertThat(pageable.getPage()).isEqualTo(1L);
+            softly.assertThat(pageable.getMode()).isEqualTo(KeysetPageable.Mode.NEXT);
+            softly.assertThat(pageable.getCursor().size()).isEqualTo(3);
+            softly.assertThat(pageable.getCursor().getKeysetElement(0)).isEqualTo("First");
+            softly.assertThat(pageable.getCursor().getKeysetElement(1)).isEqualTo(2L);
+            softly.assertThat(pageable.getCursor().getKeysetElement(2)).isEqualTo(3);
+        });
+    }
+
+    @Test
+    @DisplayName("Should include keyset values in next KeysetPageable from Cursor")
+    void shouldCreateKeysetPageableAfterKeysetCursor() {
+        KeysetPageable.Cursor cursor = new KeysetPageable.CursorImpl("me", 200);
+        KeysetPageable pageable = Pageable.of(1, 35, Sort.asc("name"), Sort.asc("id")).afterKeysetCursor(cursor);
+
+        assertSoftly(softly -> {
+            softly.assertThat(pageable.getSize()).isEqualTo(35L);
+            softly.assertThat(pageable.getPage()).isEqualTo(1L);
+            softly.assertThat(pageable.getSorts()).isEqualTo(List.of(Sort.asc("name"), Sort.asc("id")));
+            softly.assertThat(pageable.getMode()).isEqualTo(KeysetPageable.Mode.NEXT);
+            softly.assertThat(pageable.getCursor().size()).isEqualTo(2);
+            softly.assertThat(pageable.getCursor().getKeysetElement(0)).isEqualTo("me");
+            softly.assertThat(pageable.getCursor().getKeysetElement(1)).isEqualTo(200);
+        });
+    }
+
+    @Test
+    @DisplayName("Should include keyset values in previous KeysetPageable")
+    void shouldCreateKeysetPageableBeforeKeyset() {
+        KeysetPageable pageable = Pageable.of(10, 30, Sort.desc("yearBorn"), Sort.asc("ssn")).beforeKeyset(1991, "123-45-6789");
+
+        assertSoftly(softly -> {
+            softly.assertThat(pageable.getSize()).isEqualTo(30L);
+            softly.assertThat(pageable.getPage()).isEqualTo(10L);
+            softly.assertThat(pageable.getSorts()).isEqualTo(List.of(Sort.desc("yearBorn"), Sort.asc("ssn")));
+            softly.assertThat(pageable.getMode()).isEqualTo(KeysetPageable.Mode.PREVIOUS);
+            softly.assertThat(pageable.getCursor().size()).isEqualTo(2);
+            softly.assertThat(pageable.getCursor().getKeysetElement(0)).isEqualTo(1991);
+            softly.assertThat(pageable.getCursor().getKeysetElement(1)).isEqualTo("123-45-6789");
+        });
+    }
+
+    @Test
+    @DisplayName("Should include keyset values in previous KeysetPageable from Cursor")
+    void shouldCreateKeysetPageableBeforeKeysetCursor() {
+        KeysetPageable.Cursor cursor = new KeysetPageable.CursorImpl(900L, 300, "testing", 120, 'T');
+        KeysetPageable pageable = Pageable.page(8).beforeKeysetCursor(cursor);
+
+        assertSoftly(softly -> {
+            softly.assertThat(pageable.getSize()).isEqualTo(10);
+            softly.assertThat(pageable.getPage()).isEqualTo(8L);
+            softly.assertThat(pageable.getSorts()).isEqualTo(Collections.EMPTY_LIST);
+            softly.assertThat(pageable.getMode()).isEqualTo(KeysetPageable.Mode.PREVIOUS);
+            softly.assertThat(pageable.getCursor().size()).isEqualTo(5);
+            softly.assertThat(pageable.getCursor().getKeysetElement(0)).isEqualTo(900L);
+            softly.assertThat(pageable.getCursor().getKeysetElement(1)).isEqualTo(300);
+            softly.assertThat(pageable.getCursor().getKeysetElement(2)).isEqualTo("testing");
+            softly.assertThat(pageable.getCursor().getKeysetElement(3)).isEqualTo(120);
+            softly.assertThat(pageable.getCursor().getKeysetElement(4)).isEqualTo('T');
+        });
+    }
+
+    @Test
+    @DisplayName("Should be usable in a hashing structure")
+    void shouldHash() {
+        KeysetPageable pageable1 = Pageable.of(1, 15, Sort.desc("yearHired"), Sort.asc("lastName"), Sort.asc("id"))
+                                           .afterKeyset(1, '1', "1");
+        KeysetPageable pageable2a = Pageable.size(15).afterKeyset(2, '2', "2");
+        KeysetPageable pageable2b = Pageable.size(15).beforeKeyset(2, '2', "2");
+        KeysetPageable pageable2c = Pageable.size(15).beforeKeyset(2, '2', "2");
+        Map<KeysetPageable, Integer> map = new HashMap<>();
+
+        assertSoftly(softly -> {
+            softly.assertThat(pageable2b.hashCode()).isEqualTo(pageable2c.hashCode());
+            softly.assertThat(map.put(pageable1, 1)).isNull();
+            softly.assertThat(map.put(pageable1, 10)).isEqualTo(1);
+            softly.assertThat(map.put(pageable2a, 21)).isNull();
+            softly.assertThat(map.put(pageable2b, 22)).isNull();
+            softly.assertThat(map.get(pageable1)).isEqualTo(10);
+            softly.assertThat(map.get(pageable2a)).isEqualTo(21);
+            softly.assertThat(map.get(pageable2b)).isEqualTo(22);
+            softly.assertThat(map.put(pageable2c, 23)).isEqualTo(22);
+            softly.assertThat(map.get(pageable2b)).isEqualTo(23);
+        });
+    }
+
+    @Test
+    @DisplayName("Should be displayable as String with toString")
+    void shouldKeysetPageableDisplayAsString() {
+        KeysetPageable pageable = Pageable.size(200).afterKeyset("value1", 1);
+
+        assertSoftly(softly -> {
+            softly.assertThat(pageable.toString())
+                  .isEqualTo("KeysetPageable{page=1, size=200, mode=NEXT, 2 keys}");
+        });
+
+        KeysetPageable pageableWithSorts = Pageable.of(1, 100, Sort.desc("name"), Sort.asc("id"))
+                                                   .beforeKeyset("Item1", 3456);
+
+        assertSoftly(softly -> {
+            softly.assertThat(pageableWithSorts.toString())
+                  .isEqualTo("KeysetPageable{page=1, size=100, mode=PREVIOUS, 2 keys, name DESC, id ASC}");
+        });
+    }
+
+    @Test
+    @DisplayName("Should return true from equals if keyset values and other properties are equal")
+    void shouldBeEqualWithSameKeysetValues() {
+        KeysetPageable pageable25p1s0a1 = Pageable.size(25).afterKeyset("keyval1", '2', 3);
+        KeysetPageable pageable25p1s0b1 = Pageable.size(25).beforeKeyset("keyval1", '2', 3);
+        KeysetPageable pageable25p1s0a1match = Pageable.of(1, 25).afterKeysetCursor(new KeysetPageable.CursorImpl("keyval1", '2', 3));
+        KeysetPageable pageable25p2s0a1 = Pageable.of(2, 25).afterKeysetCursor(new KeysetPageable.CursorImpl("keyval1", '2', 3));
+        KeysetPageable pageable25p1s1a1 = Pageable.of(1, 25, Sort.desc("d"), Sort.asc("a"), Sort.asc("id")).afterKeyset("keyval1", '2', 3);
+        KeysetPageable pageable25p1s2a1 = Pageable.of(1, 25, Sort.desc("d"), Sort.asc("a"), Sort.desc("id")).afterKeyset("keyval1", '2', 3);
+        KeysetPageable pageable25p1s0a2 = Pageable.size(25).afterKeyset("keyval2", '2', 3);
+
+        KeysetPageable.Cursor cursor1 = new KeysetPageable.CursorImpl("keyval1", '2', 3);
+        KeysetPageable.Cursor cursor2 = new KeysetPageable.CursorImpl("keyval2", '2', 3);
+        KeysetPageable.Cursor cursor3 = new KeysetPageable.CursorImpl("keyval1", '2');
+        KeysetPageable.Cursor cursor4 = new KeysetPageable.Cursor() {
+            private final Object[] keyset = new Object[] { "keyval1", '2', 3 };
+
+            @Override
+            public Object getKeysetElement(int index) {
+                return keyset[index];
+            }
+
+            @Override
+            public int size() {
+                return keyset.length;
+            }
+        };
+
+        assertSoftly(softly -> {
+            softly.assertThat(cursor1.equals(cursor1)).isTrue();
+            softly.assertThat(cursor1.equals(null)).isFalse();
+            softly.assertThat(cursor1.equals(cursor2)).isFalse(); // different keyset values
+            softly.assertThat(cursor1.equals(cursor3)).isFalse(); // different number of keyset values
+            softly.assertThat(cursor1.equals(cursor4)).isFalse(); // different classes
+            softly.assertThat(cursor4.equals(cursor1)).isFalse(); // different classes
+
+            softly.assertThat(pageable25p1s0a1.getCursor()).isEqualTo(cursor1);
+            softly.assertThat(pageable25p1s0a1match.getCursor()).isEqualTo(cursor1);
+
+            softly.assertThat(pageable25p1s0a1.equals(pageable25p1s0a1)).isTrue();
+            softly.assertThat(pageable25p1s0a1.equals(null)).isFalse();
+            softly.assertThat(pageable25p1s0a1.equals(pageable25p1s0b1)).isFalse(); // after vs before
+            softly.assertThat(pageable25p1s0a1.equals(pageable25p1s0a1match)).isTrue();
+            softly.assertThat(pageable25p1s0a1.equals(pageable25p2s0a1)).isFalse(); // different page numbers
+            softly.assertThat(pageable25p1s0a1.equals(pageable25p1s1a1)).isFalse(); // with vs without sorting
+            softly.assertThat(pageable25p1s2a1.equals(pageable25p1s1a1)).isFalse(); // different sorting
+            softly.assertThat(pageable25p1s0a1.equals(pageable25p1s0a2)).isFalse(); // different keyset value
+            softly.assertThat(pageable25p1s0a1.equals(Pageable.of(1, 25))).isFalse(); // KeysetPageable vs Pageable
+            softly.assertThat(Pageable.of(1, 25).equals(pageable25p1s0a1)).isFalse(); // Pageable vs KeysetPageable
+        });
+    }
+
+    @Test
+    @DisplayName("Should raise IllegalArgumentException when keyset values are absent")
+    void shouldRaiseErrorForMissingKeysetValues() {
+        assertThatIllegalArgumentException().isThrownBy(() -> Pageable.size(60).afterKeyset(null));
+        assertThatIllegalArgumentException().isThrownBy(() -> Pageable.size(70).afterKeyset(new Object[0]));
+        assertThatIllegalArgumentException().isThrownBy(() -> Pageable.size(80).beforeKeyset(null));
+        assertThatIllegalArgumentException().isThrownBy(() -> Pageable.size(90).beforeKeyset(new Object[0]));
+    }
+
+}
+
diff --git a/api/src/test/java/jakarta/data/repository/PageableTest.java b/api/src/test/java/jakarta/data/repository/PageableTest.java
index 7726c58c..2f49897e 100644
--- a/api/src/test/java/jakarta/data/repository/PageableTest.java
+++ b/api/src/test/java/jakarta/data/repository/PageableTest.java
@@ -76,6 +76,21 @@ class PageableTest {
         });
     }
 
+    @Test
+    @DisplayName("Should be displayable as String with toString")
+    void shouldPageableDisplayAsString() {
+
+        assertSoftly(softly -> {
+            softly.assertThat(Pageable.size(60).toString())
+                  .isEqualTo("Pageable{page=1, size=60}");
+        });
+
+        assertSoftly(softly -> {
+            softly.assertThat(Pageable.of(1, 80, Sort.desc("yearBorn"), Sort.asc("monthBorn"), Sort.asc("id")).toString())
+                  .isEqualTo("Pageable{page=1, size=80, yearBorn DESC, monthBorn ASC, id ASC}");
+        });
+    }
+
     @Test
     @DisplayName("Should throw IllegalArgumentException when page is not present")
     void shouldReturnErrorWhenThereIsIllegalArgument() {