From 696cc25a2df563ec7036cb5a3595ca4ebe6de46e Mon Sep 17 00:00:00 2001
From: Andriy Dmytruk <80816836+andriy-dmytruk@users.noreply.github.com>
Date: Wed, 22 May 2024 05:38:10 -0400
Subject: [PATCH] Initial addition of cursored pagination for SQL (#2884)
* Initial addition of cursored pagination
- Create the CursoredPageable type.
- Modify the DefaultSqlPreparedQuery to support cursored pageable for SQL.
- Modify DefaultFindPageInterceptor to return correct pageable for further pagination in cursored case.
* Checkstyle
* Change base to 4.8.x
* Add hasNext and hasPrevious methods to Page
* Checkstyle fixes and disable PostgreSQL test
* Implement some review comments
* Add methods corresponding to the jakarta PageRequest API
* Add requestTotal property to the pageable
* Update Page to account for cases when total size is not queried
* Fix build
* Implement more review comments
* Add tests
* Fix checkstyle
* Throw UnsupportedOperationException where cursored pageable is not yet supported
* Add all cursors to the page implementation and remove nextPageable and previousPageable implementation from the CursoredPageable
* Slightly improve the test
* Fix for postgres r2dbc test
* Add CursoredPage and interceptor
* Add documentation for cursored pageable
* Fix interceptors
---
config/checkstyle/suppressions.xml | 2 +
.../query/builder/CosmosSqlQueryBuilder.java | 4 +
.../AbstractHibernateOperations.java | 7 +
...ctiveFindPageSpecificationInterceptor.java | 6 +-
.../DefaultJdbcRepositoryOperations.java | 2 +-
.../jdbc/h2/H2CursoredPaginationSpec.groovy | 50 ++++
.../mysql/MysqlCursoredPaginationSpec.groovy | 47 +++
.../OracleXECursoredPaginationSpec.groovy | 47 +++
.../PostgresCursoredPaginationSpec.groovy | 47 +++
.../SqlServerCursoredPaginationSpec.groovy | 43 +++
.../sqlserver/SqlServerPaginationSpec.groovy | 3 +
.../FindPageSpecificationInterceptor.java | 30 +-
.../annotation/RepositoryConfiguration.java | 4 +-
.../micronaut/data/annotation/TypeRole.java | 5 +
.../FindCursoredPageInterceptor.java | 27 ++
.../io/micronaut/data/model/CursoredPage.java | 182 ++++++++++++
.../data/model/CursoredPageable.java | 109 +++++++
.../data/model/DefaultCursoredPage.java | 102 +++++++
.../data/model/DefaultCursoredPageable.java | 172 +++++++++++
.../io/micronaut/data/model/DefaultPage.java | 13 +-
.../micronaut/data/model/DefaultPageable.java | 33 ++-
.../io/micronaut/data/model/DefaultSort.java | 5 +
.../java/io/micronaut/data/model/Page.java | 63 +++-
.../io/micronaut/data/model/Pageable.java | 256 +++++++++++++---
.../java/io/micronaut/data/model/Slice.java | 34 ++-
.../java/io/micronaut/data/model/Sort.java | 21 ++
.../builder/AbstractSqlLikeQueryBuilder.java | 133 +++++----
.../query/builder/sql/SqlQueryBuilder.java | 3 +-
.../data/model/runtime/PagedQuery.java | 1 +
.../model/runtime/QueryParameterBinding.java | 2 +-
.../io/micronaut/data/model/PageSpec.groovy | 38 ++-
.../operations/DefaultMongoPreparedQuery.java | 7 +
.../DefaultMongoRepositoryOperations.java | 7 +
.../RepositoryTypeElementVisitor.java | 7 +-
.../visitors/finders/FindersUtils.java | 13 +-
.../criteria/QueryCriteriaMethodMatch.java | 4 +-
.../data/processor/visitors/PageSpec.groovy | 38 +++
.../r2dbc/h2/H2CursoredPaginationSpec.groovy | 48 +++
.../mysql/MySqlCursoredPaginationSpec.groovy | 47 +++
.../OracleXECursoredPaginationSpec.groovy | 47 +++
.../PostgresCursoredPaginationSpec.groovy | 47 +++
.../DefaultAbstractFindPageInterceptor.java | 96 ++++++
.../DefaultFindCursoredPageInterceptor.java | 57 ++++
.../intercept/DefaultFindPageInterceptor.java | 40 +--
.../DefaultFindSliceInterceptor.java | 5 +-
.../AbstractSpecificationInterceptor.java | 6 +-
.../FindPageSpecificationInterceptor.java | 7 +-
...FindPageAsyncSpecificationInterceptor.java | 10 +-
...tractReactiveSpecificationInterceptor.java | 4 +
...dPageReactiveSpecificationInterceptor.java | 9 +-
.../internal/sql/DefaultSqlPreparedQuery.java | 167 +++++++++++
.../data/spring/runtime/PageableDelegate.java | 27 ++
.../tck/tests/AbstractCursoredPageSpec.groovy | 275 ++++++++++++++++++
.../data/tck/tests/AbstractPageSpec.groovy | 30 ++
.../tck/repositories/PersonRepository.java | 4 +
.../main/groovy/example/BookRepository.groovy | 8 +
.../groovy/example/BookRepositorySpec.groovy | 36 +++
.../src/main/java/example/BookRepository.java | 8 +
.../test/java/example/BookRepositorySpec.java | 45 +++
.../src/main/kotlin/example/BookRepository.kt | 12 +-
.../test/kotlin/example/BookRepositorySpec.kt | 50 +++-
.../shared/querying/cursored-pagination.adoc | 22 ++
src/main/docs/guide/toc.yml | 1 +
63 files changed, 2495 insertions(+), 180 deletions(-)
create mode 100644 data-jdbc/src/test/groovy/io/micronaut/data/jdbc/h2/H2CursoredPaginationSpec.groovy
create mode 100644 data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MysqlCursoredPaginationSpec.groovy
create mode 100644 data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/OracleXECursoredPaginationSpec.groovy
create mode 100644 data-jdbc/src/test/groovy/io/micronaut/data/jdbc/postgres/PostgresCursoredPaginationSpec.groovy
create mode 100644 data-jdbc/src/test/groovy/io/micronaut/data/jdbc/sqlserver/SqlServerCursoredPaginationSpec.groovy
create mode 100644 data-model/src/main/java/io/micronaut/data/intercept/FindCursoredPageInterceptor.java
create mode 100644 data-model/src/main/java/io/micronaut/data/model/CursoredPage.java
create mode 100644 data-model/src/main/java/io/micronaut/data/model/CursoredPageable.java
create mode 100644 data-model/src/main/java/io/micronaut/data/model/DefaultCursoredPage.java
create mode 100644 data-model/src/main/java/io/micronaut/data/model/DefaultCursoredPageable.java
create mode 100644 data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/h2/H2CursoredPaginationSpec.groovy
create mode 100644 data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mysql/MySqlCursoredPaginationSpec.groovy
create mode 100644 data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/oraclexe/OracleXECursoredPaginationSpec.groovy
create mode 100644 data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresCursoredPaginationSpec.groovy
create mode 100644 data-runtime/src/main/java/io/micronaut/data/runtime/intercept/DefaultAbstractFindPageInterceptor.java
create mode 100644 data-runtime/src/main/java/io/micronaut/data/runtime/intercept/DefaultFindCursoredPageInterceptor.java
create mode 100644 data-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractCursoredPageSpec.groovy
create mode 100644 src/main/docs/guide/shared/querying/cursored-pagination.adoc
diff --git a/config/checkstyle/suppressions.xml b/config/checkstyle/suppressions.xml
index 73f71b3a499..dd6354e9969 100644
--- a/config/checkstyle/suppressions.xml
+++ b/config/checkstyle/suppressions.xml
@@ -9,4 +9,6 @@
+
+
diff --git a/data-document-model/src/main/java/io/micronaut/data/document/model/query/builder/CosmosSqlQueryBuilder.java b/data-document-model/src/main/java/io/micronaut/data/document/model/query/builder/CosmosSqlQueryBuilder.java
index af3a45d39a6..f5dc067e7e8 100644
--- a/data-document-model/src/main/java/io/micronaut/data/document/model/query/builder/CosmosSqlQueryBuilder.java
+++ b/data-document-model/src/main/java/io/micronaut/data/document/model/query/builder/CosmosSqlQueryBuilder.java
@@ -26,6 +26,7 @@
import io.micronaut.data.model.Association;
import io.micronaut.data.model.Embedded;
import io.micronaut.data.model.Pageable;
+import io.micronaut.data.model.Pageable.Mode;
import io.micronaut.data.model.PersistentEntity;
import io.micronaut.data.model.PersistentProperty;
import io.micronaut.data.model.PersistentPropertyPath;
@@ -329,6 +330,9 @@ public Map getAdditionalRequiredParameters() {
@NonNull
@Override
public QueryResult buildPagination(@NonNull Pageable pageable) {
+ if (pageable.getMode() != Mode.OFFSET) {
+ throw new UnsupportedOperationException("Pageable mode " + pageable.getMode() + " is not supported by cosmos operations");
+ }
int size = pageable.getSize();
if (size > 0) {
StringBuilder builder = new StringBuilder(" ");
diff --git a/data-hibernate-jpa/src/main/java/io/micronaut/data/hibernate/operations/AbstractHibernateOperations.java b/data-hibernate-jpa/src/main/java/io/micronaut/data/hibernate/operations/AbstractHibernateOperations.java
index 988de90b027..2c937aa5556 100644
--- a/data-hibernate-jpa/src/main/java/io/micronaut/data/hibernate/operations/AbstractHibernateOperations.java
+++ b/data-hibernate-jpa/src/main/java/io/micronaut/data/hibernate/operations/AbstractHibernateOperations.java
@@ -30,6 +30,7 @@
import io.micronaut.data.annotation.QueryHint;
import io.micronaut.data.jpa.annotation.EntityGraph;
import io.micronaut.data.model.Pageable;
+import io.micronaut.data.model.Pageable.Mode;
import io.micronaut.data.model.Sort;
import io.micronaut.data.model.query.builder.jpa.JpaQueryBuilder;
import io.micronaut.data.model.runtime.PagedQuery;
@@ -336,6 +337,9 @@ protected void collectFindAll(S session, PreparedQuery, R> preparedQuery,
String queryStr = preparedQuery.getQuery();
Pageable pageable = preparedQuery.getPageable();
if (pageable != Pageable.UNPAGED) {
+ if (pageable.getMode() != Mode.OFFSET) {
+ throw new UnsupportedOperationException("Pageable mode " + pageable.getMode() + " is not supported by hibernate operations");
+ }
Sort sort = pageable.getSort();
if (sort.isSorted()) {
queryStr += QUERY_BUILDER.buildOrderBy(queryStr, getEntity(preparedQuery.getRootEntity()), AnnotationMetadata.EMPTY_METADATA, sort,
@@ -600,6 +604,9 @@ private void bindPageable(P q, @NonNull Pageable pageable) {
// no pagination
return;
}
+ if (pageable.getMode() != Mode.OFFSET) {
+ throw new UnsupportedOperationException("Pageable mode " + pageable.getMode() + " is not supported by hibernate operations");
+ }
int max = pageable.getSize();
if (max > 0) {
diff --git a/data-hibernate-reactive/src/main/java/io/micronaut/data/hibernate/reactive/repository/jpa/intercept/ReactiveFindPageSpecificationInterceptor.java b/data-hibernate-reactive/src/main/java/io/micronaut/data/hibernate/reactive/repository/jpa/intercept/ReactiveFindPageSpecificationInterceptor.java
index 4142a959c90..847516041c1 100644
--- a/data-hibernate-reactive/src/main/java/io/micronaut/data/hibernate/reactive/repository/jpa/intercept/ReactiveFindPageSpecificationInterceptor.java
+++ b/data-hibernate-reactive/src/main/java/io/micronaut/data/hibernate/reactive/repository/jpa/intercept/ReactiveFindPageSpecificationInterceptor.java
@@ -84,7 +84,7 @@ protected Publisher> interceptPublisher(RepositoryMethodKey methodKey, MethodI
return operations.withSession(session -> {
if (pageable.isUnpaged()) {
return Mono.fromCompletionStage(() -> session.createQuery(query).getResultList())
- .map(resultList -> Page.of(resultList, pageable, resultList.size()));
+ .map(resultList -> Page.of(resultList, pageable, (long) resultList.size()));
}
return Mono.fromCompletionStage(() -> {
Stage.SelectionQuery
*
* @param The generic type
* @author graemerocher
@@ -47,21 +50,42 @@
@DefaultImplementation(DefaultPage.class)
public interface Page extends Slice {
- Page> EMPTY = new DefaultPage<>(Collections.emptyList(), Pageable.unpaged(), 0);
+ Page> EMPTY = new DefaultPage<>(Collections.emptyList(), Pageable.unpaged(), null);
/**
+ * @return Whether this {@link Page} contains the total count of the records
+ * @since 4.8.0
+ */
+ boolean hasTotalSize();
+
+ /**
+ * Get the total count of all the records that can be given by this query.
+ * The method may produce a {@link IllegalStateException} if the {@link Pageable} request
+ * did not ask for total size.
+ *
* @return The total size of the all records.
*/
long getTotalSize();
/**
- * @return The total number of pages
+ * Get the total count of pages that can be given by this query.
+ * The method may produce a {@link IllegalStateException} if the {@link Pageable} request
+ * did not ask for total size.
+ *
+ * @return The total page of pages
*/
default int getTotalPages() {
int size = getSize();
return size == 0 ? 1 : (int) Math.ceil((double) getTotalSize() / (double) size);
}
+ @Override
+ default boolean hasNext() {
+ return hasTotalSize()
+ ? getOffset() + getSize() < getTotalSize()
+ : getContent().size() == getSize();
+ }
+
/**
* Maps the content with the given function.
*
@@ -76,22 +100,49 @@ default int getTotalPages() {
}
/**
- * Creates a slice from the given content and pageable.
+ * Creates a page from the given content, pageable and totalSize.
+ *
* @param content The content
* @param pageable The pageable
* @param totalSize The total size
* @param The generic type
* @return The slice
*/
- @JsonCreator
@ReflectiveAccess
static @NonNull Page of(
@JsonProperty("content") @NonNull List content,
@JsonProperty("pageable") @NonNull Pageable pageable,
- @JsonProperty("totalSize") long totalSize) {
+ @JsonProperty("totalSize") @Nullable Long totalSize
+ ) {
return new DefaultPage<>(content, pageable, totalSize);
}
+ /**
+ * Creates a page from the given content, pageable, cursors and totalSize.
+ * This method is for JSON deserialization. Please use {@link CursoredPage#of} instead.
+ *
+ * @param content The content
+ * @param pageable The pageable
+ * @param cursors The cursors for cursored pagination
+ * @param totalSize The total size
+ * @param The generic type
+ * @return The slice
+ */
+ @JsonCreator
+ @Internal
+ @ReflectiveAccess
+ static @NonNull Page ofCursors(
+ @JsonProperty("content") @NonNull List content,
+ @JsonProperty("pageable") @NonNull Pageable pageable,
+ @JsonProperty("cursors") @Nullable List cursors,
+ @JsonProperty("totalSize") @Nullable Long totalSize
+ ) {
+ if (cursors == null) {
+ return new DefaultPage<>(content, pageable, totalSize);
+ }
+ return new DefaultCursoredPage<>(content, pageable, cursors, totalSize);
+ }
+
/**
* Creates an empty page object.
* @param The generic type
diff --git a/data-model/src/main/java/io/micronaut/data/model/Pageable.java b/data-model/src/main/java/io/micronaut/data/model/Pageable.java
index 185b0657d27..70ea4c294c6 100644
--- a/data-model/src/main/java/io/micronaut/data/model/Pageable.java
+++ b/data-model/src/main/java/io/micronaut/data/model/Pageable.java
@@ -19,12 +19,15 @@
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
+import io.micronaut.core.annotation.Internal;
import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.serde.annotation.Serdeable;
+import java.util.Arrays;
import java.util.List;
+import java.util.Optional;
/**
* Models pageable data. The {@link #from(int, int)} method can be used to construct a new instance to pass to Micronaut Data methods.
@@ -41,20 +44,10 @@ public interface Pageable extends Sort {
/**
* Constant for no pagination.
*/
- Pageable UNPAGED = new Pageable() {
- @Override
- public int getNumber() {
- return 0;
- }
-
- @Override
- public int getSize() {
- return -1;
- }
- };
+ Pageable UNPAGED = new DefaultPageable(0, -1, Sort.UNSORTED, true);
/**
- * @return The page number.
+ * @return The page page.
*/
int getNumber();
@@ -64,6 +57,40 @@ public int getSize() {
*/
int getSize();
+ /**
+ * The pagination mode that is either offset pagination, currentCursor forward or currentCursor backward
+ * pagination.
+ *
+ * @since 4.8.0
+ * @return The pagination mode
+ */
+ default Mode getMode() {
+ return Mode.OFFSET;
+ }
+
+ /**
+ * Get the currentCursor in case cursored pagination is used.
+ *
+ * @since 4.8.0
+ * @return The currentCursor
+ */
+ default Optional cursor() {
+ return Optional.empty();
+ }
+
+ /**
+ * Whether the returned page should contain information about total items that
+ * can be produced by this query. If the value is false, {@link Page#getTotalSize()} and
+ * {@link Page#getTotalPages()} methods will fail. By default, pageable will have this value
+ * set to true.
+ *
+ * @since 4.8.0
+ * @return Whether total size information is required.
+ */
+ default boolean requestTotal() {
+ return true;
+ }
+
/**
* Offset in the requested collection. Defaults to zero.
* @return offset in the requested collection
@@ -122,7 +149,7 @@ default Sort getSort() {
}
/**
- * @return Is unpaged
+ * @return Whether it is unpaged
*/
@JsonIgnore
default boolean isUnpaged() {
@@ -163,13 +190,41 @@ default List getOrderBy() {
return getSort().getOrderBy();
}
+ /**
+ * Specify that the {@link Page} response should have information about total size.
+ *
+ * @see #requestTotal() requestTotal() for more details.
+ * @since 4.8.0
+ * @return A pageable instance that will request the total size.
+ */
+ default Pageable withTotal() {
+ if (this.requestTotal()) {
+ return this;
+ }
+ throw new UnsupportedOperationException("Changing requestTotal is not supported");
+ }
+
+ /**
+ * Specify that the {@link Page} response should not have information about total size.
+ *
+ * @see #requestTotal() requestTotal() for more details.
+ * @since 4.8.0
+ * @return A pageable instance that won't request the total size.
+ */
+ default Pageable withoutTotal() {
+ if (!this.requestTotal()) {
+ return this;
+ }
+ throw new UnsupportedOperationException("Changing requestTotal is not supported");
+ }
+
/**
* Creates a new {@link Pageable} at the given offset with a default size of 10.
* @param page The page
* @return The pageable
*/
static @NonNull Pageable from(int page) {
- return new DefaultPageable(page, 10, null);
+ return new DefaultPageable(page, 10, null, true);
}
/**
@@ -179,22 +234,54 @@ default List getOrderBy() {
* @return The pageable
*/
static @NonNull Pageable from(int page, int size) {
- return new DefaultPageable(page, size, null);
+ return new DefaultPageable(page, size, null, true);
}
/**
- * Creates a new {@link Pageable} at the given offset.
+ * Creates a new {@link Pageable} with the given offset.
+ *
* @param page The page
* @param size the size
* @param sort the sort
* @return The pageable
*/
+ static @NonNull Pageable from(
+ int page,
+ int size,
+ @Nullable Sort sort
+ ) {
+ return new DefaultPageable(page, size, sort, true);
+ }
+
+ /**
+ * Creates a new {@link Pageable} with the given parameters.
+ * The method is used for deserialization and most likely should not be used as an API.
+ *
+ * @param page The page
+ * @param size The size
+ * @param mode The pagination mode
+ * @param cursor The current cursor
+ * @param sort The sort
+ * @param requestTotal Whether to query total count
+ * @return The pageable
+ */
+ @Internal
@JsonCreator
static @NonNull Pageable from(
- @JsonProperty("number") int page,
+ @JsonProperty("page") int page,
@JsonProperty("size") int size,
- @JsonProperty("sort") @Nullable Sort sort) {
- return new DefaultPageable(page, size, sort);
+ @JsonProperty("mode") @Nullable Mode mode,
+ @JsonProperty("cursor") @Nullable Cursor cursor,
+ @JsonProperty("sort") @Nullable Sort sort,
+ @JsonProperty(value = "requestTotal", defaultValue = "true") boolean requestTotal
+ ) {
+ if (mode == null || mode == Mode.OFFSET) {
+ return new DefaultPageable(page, size, sort, requestTotal);
+ } else {
+ return new DefaultCursoredPageable(
+ size, cursor, mode, page, sort == null ? UNSORTED : sort, requestTotal
+ );
+ }
}
/**
@@ -206,23 +293,7 @@ default List getOrderBy() {
if (sort == null) {
return UNPAGED;
} else {
- return new Pageable() {
- @Override
- public int getNumber() {
- return 0;
- }
-
- @Override
- public int getSize() {
- return -1;
- }
-
- @NonNull
- @Override
- public Sort getSort() {
- return sort;
- }
- };
+ return new DefaultPageable(0, -1, sort, true);
}
}
@@ -232,4 +303,117 @@ public Sort getSort() {
static @NonNull Pageable unpaged() {
return UNPAGED;
}
+
+ /**
+ * Create a new {@link Pageable} for forward pagination given the currentCursor after which to query.
+ *
+ * @since 4.8.0
+ * @param cursor The currentCursor
+ * @param page The page page
+ * @param size The page size
+ * @param sort The sorting
+ * @return The pageable
+ */
+ static @NonNull CursoredPageable afterCursor(@NonNull Cursor cursor, int page, int size, @Nullable Sort sort) {
+ if (sort == null) {
+ sort = UNSORTED;
+ }
+ return new DefaultCursoredPageable(size, cursor, Mode.CURSOR_NEXT, page, sort, true);
+ }
+
+ /**
+ * Create a new {@link Pageable} for backward pagination given the currentCursor after which to query.
+ *
+ * @since 4.8.0
+ * @param cursor The currentCursor
+ * @param page The page page
+ * @param size The page size
+ * @param sort The sorting
+ * @return The pageable
+ */
+ static @NonNull CursoredPageable beforeCursor(@NonNull Cursor cursor, int page, int size, @Nullable Sort sort) {
+ if (sort == null) {
+ sort = UNSORTED;
+ }
+ return new DefaultCursoredPageable(size, cursor, Mode.CURSOR_PREVIOUS, page, sort, true);
+ }
+
+ /**
+ * The type of pagination: offset-based or currentCursor-based, which includes
+ * a direction.
+ *
+ * @since 4.8.0
+ */
+ enum Mode {
+ /**
+ * Indicates forward currentCursor-based pagination, which follows the
+ * direction of the sort criteria, using a currentCursor that is
+ * formed from the key of the last entity on the current page.
+ */
+ CURSOR_NEXT,
+
+ /**
+ * Indicates a request for a page with currentCursor-based pagination
+ * in the previous page direction to the sort criteria, using a currentCursor
+ * that is formed from the key of first entity on the current page.
+ * The order of results on each page follows the sort criteria
+ * and is not reversed.
+ */
+ CURSOR_PREVIOUS,
+
+ /**
+ * Indicates a request for a page using offset pagination.
+ * The starting position for pages is computed as an offset from
+ * the first result based on the page page and maximum page size.
+ * Offset pagination is used when a currentCursor is not supplied.
+ */
+ OFFSET
+ }
+
+ /**
+ * An interface for defining pagination cursors.
+ * It is generally a list of elements which can be used to create a query for the next
+ * or previous page.
+ *
+ * @since 4.8.0
+ */
+ @Serdeable
+ interface Cursor {
+ /**
+ * Returns the currentCursor element at the specified position.
+ * @param index The index of the currentCursor value
+ * @return The currentCursor value
+ */
+ Object get(int index);
+
+ /**
+ * Returns all the currentCursor values in a list.
+ * @return The currentCursor values
+ */
+ List elements();
+
+ /**
+ * @return The page of elements in the currentCursor.
+ */
+ int size();
+
+ /**
+ * Create a currentCursor from elements.
+ * @param elements The currentCursor elements
+ * @return The currentCursor
+ */
+ static Cursor of(Object... elements) {
+ return new DefaultCursoredPageable.DefaultCursor(Arrays.asList(elements));
+ }
+
+ /**
+ * Create a currentCursor from elements.
+ * @param elements The currentCursor elements
+ * @return The currentCursor
+ */
+ @JsonCreator
+ static Cursor of(List elements) {
+ return new DefaultCursoredPageable.DefaultCursor(elements);
+ }
+ }
}
diff --git a/data-model/src/main/java/io/micronaut/data/model/Slice.java b/data-model/src/main/java/io/micronaut/data/model/Slice.java
index c7aa0e0fc29..218b3151a42 100644
--- a/data-model/src/main/java/io/micronaut/data/model/Slice.java
+++ b/data-model/src/main/java/io/micronaut/data/model/Slice.java
@@ -63,6 +63,31 @@ default int getPageNumber() {
}
/**
+ * Determine whether there is a next page.
+ *
+ * @since 4.8.0
+ * @return Whether there exist a next page.
+ */
+ default boolean hasNext() {
+ return getContent().size() == getSize();
+ }
+
+ /**
+ * Determine whether there is a previous page.
+ *
+ * @since 4.8.0
+ * @return Whether there exist a previous page.
+ */
+ default boolean hasPrevious() {
+ return getOffset() > 0;
+ }
+
+ /**
+ * Create a pageable for querying the next page of data.
+ *
A pageable may be created even if the end of data was reached to accommodate for
+ * cases when new data might be added to the repository. Use {@link #hasNext()} to
+ * verify if you have reached the end.
+ *
* @return The next pageable
*/
default @NonNull Pageable nextPageable() {
@@ -70,7 +95,12 @@ default int getPageNumber() {
}
/**
- * @return The previous pageable.
+ * Create a pageable for querying the previous page of data.
+ *
A pageable may be created even if the end of data was reached to accommodate for
+ * cases when new data might be added to the repository. Use {@link #hasPrevious()} to
+ * verify if you have reached the end.
+ *
+ * @return The previous pageable
*/
default @NonNull Pageable previousPageable() {
return getPageable().previous();
@@ -106,7 +136,7 @@ default boolean isEmpty() {
}
/**
- * @return The number of elements
+ * @return The page of elements
*/
default int getNumberOfElements() {
return getContent().size();
diff --git a/data-model/src/main/java/io/micronaut/data/model/Sort.java b/data-model/src/main/java/io/micronaut/data/model/Sort.java
index 9a4fa3870d1..f9087f0ab00 100644
--- a/data-model/src/main/java/io/micronaut/data/model/Sort.java
+++ b/data-model/src/main/java/io/micronaut/data/model/Sort.java
@@ -178,6 +178,19 @@ public String getProperty() {
return property;
}
+ /**
+ * Create an order that is reversed to current.
+ *
+ * @return A new instance of order that is reversed.
+ */
+ public Order reverse() {
+ return new Order(
+ property,
+ direction == Direction.ASC ? Direction.DESC : Direction.ASC,
+ ignoreCase
+ );
+ }
+
/**
* Creates a new order for the given property in descending order.
*
@@ -234,6 +247,14 @@ public enum Direction {
ASC, DESC
}
+ @Override
+ public String toString() {
+ return "SORT{" + property
+ + (direction == Direction.ASC ? ", ASC" : ", DESC")
+ + (ignoreCase ? ", ignoreCase" : "")
+ + ")";
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) {
diff --git a/data-model/src/main/java/io/micronaut/data/model/query/builder/AbstractSqlLikeQueryBuilder.java b/data-model/src/main/java/io/micronaut/data/model/query/builder/AbstractSqlLikeQueryBuilder.java
index 4a1fea9e69a..abeec356fcc 100644
--- a/data-model/src/main/java/io/micronaut/data/model/query/builder/AbstractSqlLikeQueryBuilder.java
+++ b/data-model/src/main/java/io/micronaut/data/model/query/builder/AbstractSqlLikeQueryBuilder.java
@@ -1976,8 +1976,6 @@ public QueryResult buildOrderBy(String query, @NonNull PersistentEntity entity,
StringBuilder buff = new StringBuilder(ORDER_BY_CLAUSE);
Iterator i = orders.iterator();
- String jsonEntityColumn = getJsonEntityColumn(annotationMetadata);
-
while (i.hasNext()) {
Sort.Order order = i.next();
String property = order.getProperty();
@@ -1985,59 +1983,9 @@ public QueryResult buildOrderBy(String query, @NonNull PersistentEntity entity,
if (ignoreCase) {
buff.append("LOWER(");
}
- if (nativeQuery) {
- buff.append(property);
- } else {
- PersistentPropertyPath path = entity.getPropertyPath(property);
- if (path == null) {
- throw new IllegalArgumentException("Cannot sort on non-existent property path: " + property);
- }
- List associations = new ArrayList<>(path.getAssociations());
- int assocCount = associations.size();
- // If last association is embedded, it does not need to be joined to the alias since it will be in the destination table
- // JPA/Hibernate is special case and in that case we leave association for specific handling below
- if (assocCount > 0 && computePropertyPaths() && associations.get(assocCount - 1) instanceof Embedded) {
- associations.remove(assocCount - 1);
- }
- if (associations.isEmpty()) {
- buff.append(getAliasName(entity));
- } else {
- StringJoiner joiner = new StringJoiner(".");
- for (Association association : associations) {
- joiner.add(association.getName());
- }
- String joinAlias = getAliasName(new JoinPath(joiner.toString(), associations.toArray(new Association[0]), Join.Type.DEFAULT, null));
- if (!computePropertyPaths()) {
- if (!query.contains(" " + joinAlias + " ") && !query.endsWith(" " + joinAlias)) {
- // Special hack case for JPA, Hibernate can join the relation with cross join automatically when referenced by the property path
- // This probably should be removed in the future major version
- buff.append(getAliasName(entity)).append(DOT);
- StringJoiner pathJoiner = new StringJoiner(".");
- for (Association association : associations) {
- pathJoiner.add(association.getName());
- }
- buff.append(pathJoiner);
- } else {
- buff.append(joinAlias);
- }
- } else {
- buff.append(joinAlias);
- }
- }
- buff.append(DOT);
-
- if (jsonEntityColumn != null) {
- buff.append(jsonEntityColumn).append(DOT);
- }
-
- if (!computePropertyPaths() || jsonEntityColumn != null) {
- buff.append(path.getProperty().getName());
- } else {
- buff.append(getColumnName(path.getProperty()));
- }
- if (ignoreCase) {
- buff.append(")");
- }
+ buff.append(buildPropertyByName(property, query, entity, annotationMetadata, nativeQuery));
+ if (ignoreCase) {
+ buff.append(")");
}
buff.append(SPACE).append(order.getDirection());
if (i.hasNext()) {
@@ -2053,6 +2001,81 @@ public QueryResult buildOrderBy(String query, @NonNull PersistentEntity entity,
);
}
+ /**
+ * Encode the given property retrieval into a query instance.
+ * For example, property name might be encoded as {@code `person_.name`} using
+ * its path and table's alias.
+ *
+ * @param propertyName The name of the property
+ * @param query The query
+ * @param entity The root entity
+ * @param annotationMetadata The annotation metadata
+ * @param nativeQuery Whether the query is native query, in which case the property name will be supplied by the user and not verified
+ * @return The encoded query
+ */
+ public String buildPropertyByName(
+ @NonNull String propertyName, @NonNull String query,
+ @NonNull PersistentEntity entity, @NonNull AnnotationMetadata annotationMetadata,
+ boolean nativeQuery
+ ) {
+ if (nativeQuery) {
+ return propertyName;
+ }
+
+ PersistentPropertyPath path = entity.getPropertyPath(propertyName);
+ if (path == null) {
+ throw new IllegalArgumentException("Cannot sort on non-existent property path: " + propertyName);
+ }
+ List associations = new ArrayList<>(path.getAssociations());
+ int assocCount = associations.size();
+ // If last association is embedded, it does not need to be joined to the alias since it will be in the destination table
+ // JPA/Hibernate is special case and in that case we leave association for specific handling below
+ if (assocCount > 0 && computePropertyPaths() && associations.get(assocCount - 1) instanceof Embedded) {
+ associations.remove(assocCount - 1);
+ }
+
+ StringBuilder buff = new StringBuilder();
+ if (associations.isEmpty()) {
+ buff.append(getAliasName(entity));
+ } else {
+ StringJoiner joiner = new StringJoiner(".");
+ for (Association association : associations) {
+ joiner.add(association.getName());
+ }
+ String joinAlias = getAliasName(new JoinPath(joiner.toString(), associations.toArray(new Association[0]), Join.Type.DEFAULT, null));
+ if (!computePropertyPaths()) {
+ if (!query.contains(" " + joinAlias + " ") && !query.endsWith(" " + joinAlias)) {
+ // Special hack case for JPA, Hibernate can join the relation with cross join automatically when referenced by the property path
+ // This probably should be removed in the future major version
+ buff.append(getAliasName(entity)).append(DOT);
+ StringJoiner pathJoiner = new StringJoiner(".");
+ for (Association association : associations) {
+ pathJoiner.add(association.getName());
+ }
+ buff.append(pathJoiner);
+ } else {
+ buff.append(joinAlias);
+ }
+ } else {
+ buff.append(joinAlias);
+ }
+ }
+ buff.append(DOT);
+
+ String jsonEntityColumn = getJsonEntityColumn(annotationMetadata);
+ if (jsonEntityColumn != null) {
+ buff.append(jsonEntityColumn).append(DOT);
+ }
+
+ if (!computePropertyPaths() || jsonEntityColumn != null) {
+ buff.append(path.getProperty().getName());
+ } else {
+ buff.append(getColumnName(path.getProperty()));
+ }
+
+ return buff.toString();
+ }
+
/**
* Join associations and property as path.
*
diff --git a/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/SqlQueryBuilder.java b/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/SqlQueryBuilder.java
index 013636f92d4..ffaa301b19f 100644
--- a/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/SqlQueryBuilder.java
+++ b/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/SqlQueryBuilder.java
@@ -43,6 +43,7 @@
import io.micronaut.data.model.Embedded;
import io.micronaut.data.model.JsonDataType;
import io.micronaut.data.model.Pageable;
+import io.micronaut.data.model.Pageable.Mode;
import io.micronaut.data.model.PersistentEntity;
import io.micronaut.data.model.PersistentProperty;
import io.micronaut.data.model.PersistentPropertyPath;
@@ -1210,7 +1211,7 @@ public QueryResult buildPagination(@NonNull Pageable pageable) {
int size = pageable.getSize();
if (size > 0) {
StringBuilder builder = new StringBuilder(" ");
- long from = pageable.getOffset();
+ long from = pageable.getMode() == Mode.OFFSET ? pageable.getOffset() : 0;
switch (dialect) {
case H2:
case MYSQL:
diff --git a/data-model/src/main/java/io/micronaut/data/model/runtime/PagedQuery.java b/data-model/src/main/java/io/micronaut/data/model/runtime/PagedQuery.java
index 341beabe7a4..4674f7360cd 100644
--- a/data-model/src/main/java/io/micronaut/data/model/runtime/PagedQuery.java
+++ b/data-model/src/main/java/io/micronaut/data/model/runtime/PagedQuery.java
@@ -53,4 +53,5 @@ public interface PagedQuery extends Named, AnnotationMetadataProvider {
default Map getQueryHints() {
return Collections.emptyMap();
}
+
}
diff --git a/data-model/src/main/java/io/micronaut/data/model/runtime/QueryParameterBinding.java b/data-model/src/main/java/io/micronaut/data/model/runtime/QueryParameterBinding.java
index d36035fe5f4..84453683ef7 100644
--- a/data-model/src/main/java/io/micronaut/data/model/runtime/QueryParameterBinding.java
+++ b/data-model/src/main/java/io/micronaut/data/model/runtime/QueryParameterBinding.java
@@ -150,7 +150,7 @@ default Object getValue() {
/**
* @return Is expression value
- * @see 4.5.0
+ * @since 4.5.0
*/
default boolean isExpression() {
return false;
diff --git a/data-model/src/test/groovy/io/micronaut/data/model/PageSpec.groovy b/data-model/src/test/groovy/io/micronaut/data/model/PageSpec.groovy
index e7c2010c71c..aed6b0bdce3 100644
--- a/data-model/src/test/groovy/io/micronaut/data/model/PageSpec.groovy
+++ b/data-model/src/test/groovy/io/micronaut/data/model/PageSpec.groovy
@@ -122,9 +122,45 @@ class PageSpec extends Specification {
def json = serdeMapper.writeValueAsString(pageable)
then:
- json == '{"size":3,"number":0,"sort":{}}'
+ json == '{"size":3,"number":0,"sort":{},"mode":"OFFSET"}'
def deserializedPageable = serdeMapper.readValue(json, Pageable)
deserializedPageable == pageable
+
+ when:
+ def json2 = '{"size":3,"number":0,"sort":{}}'
+ def deserializedPageable2 = serdeMapper.readValue(json2, Pageable)
+
+ then:
+ deserializedPageable2 == pageable
+ }
+
+ void "test serialization and deserialization of a cursored pageable - serde"() {
+ def pageable = Pageable.afterCursor(
+ Pageable.Cursor.of("value1", 2),
+ 0, 3, Sort.UNSORTED
+ )
+
+ when:
+ def json = serdeMapper.writeValueAsString(pageable)
+
+ then:
+ json == '{"size":3,"cursor":{"elements":["value1",2]},"mode":"CURSOR_NEXT","number":0,"sort":{},"requestTotal":true}'
+ def deserializedPageable = serdeMapper.readValue(json, Pageable)
+ deserializedPageable == pageable
+ def deserializedPageable2 = serdeMapper.readValue(json, CursoredPageable)
+ deserializedPageable2 == pageable
+ }
+
+ void "test sort serialization"() {
+ def sort = Sort.of(Sort.Order.asc("property"))
+
+ when:
+ def json = serdeMapper.writeValueAsString(sort)
+
+ then:
+ json == '{"orderBy":[{"ignoreCase":false,"direction":"ASC","property":"property","ascending":true}]}'
+ def deserializedSort = serdeMapper.readValue(json, Sort)
+ deserializedSort == sort
}
@EqualsAndHashCode
diff --git a/data-mongodb/src/main/java/io/micronaut/data/mongodb/operations/DefaultMongoPreparedQuery.java b/data-mongodb/src/main/java/io/micronaut/data/mongodb/operations/DefaultMongoPreparedQuery.java
index d699ad986ab..a434d25bc67 100644
--- a/data-mongodb/src/main/java/io/micronaut/data/mongodb/operations/DefaultMongoPreparedQuery.java
+++ b/data-mongodb/src/main/java/io/micronaut/data/mongodb/operations/DefaultMongoPreparedQuery.java
@@ -18,6 +18,7 @@
import com.mongodb.client.model.Sorts;
import io.micronaut.core.annotation.Internal;
import io.micronaut.data.model.Pageable;
+import io.micronaut.data.model.Pageable.Mode;
import io.micronaut.data.model.Sort;
import io.micronaut.data.model.runtime.PreparedQuery;
import io.micronaut.data.model.runtime.RuntimePersistentEntity;
@@ -81,6 +82,9 @@ public MongoFind getFind() {
MongoFind find = mongoStoredQuery.getFind(defaultPreparedQuery.getContext());
Pageable pageable = defaultPreparedQuery.getPageable();
if (pageable != Pageable.UNPAGED) {
+ if (pageable.getMode() != Mode.OFFSET) {
+ throw new UnsupportedOperationException("Mode " + pageable.getMode() + " is not supported by the MongoDB implementation");
+ }
MongoFindOptions findOptions = find.getOptions();
MongoFindOptions options = findOptions == null ? new MongoFindOptions() : new MongoFindOptions(findOptions);
options.limit(pageable.getSize()).skip((int) pageable.getOffset());
@@ -113,6 +117,9 @@ public PreparedQuery getPreparedQueryDelegate() {
private int applyPageable(Pageable pageable, List pipeline) {
int limit = 0;
if (pageable != Pageable.UNPAGED) {
+ if (pageable.getMode() != Mode.OFFSET) {
+ throw new UnsupportedOperationException("Mode " + pageable.getMode() + " is not supported by the MongoDB implementation");
+ }
int skip = (int) pageable.getOffset();
limit = pageable.getSize();
Sort pageableSort = pageable.getSort();
diff --git a/data-mongodb/src/main/java/io/micronaut/data/mongodb/operations/DefaultMongoRepositoryOperations.java b/data-mongodb/src/main/java/io/micronaut/data/mongodb/operations/DefaultMongoRepositoryOperations.java
index 47d219045a1..0cae80a2c60 100644
--- a/data-mongodb/src/main/java/io/micronaut/data/mongodb/operations/DefaultMongoRepositoryOperations.java
+++ b/data-mongodb/src/main/java/io/micronaut/data/mongodb/operations/DefaultMongoRepositoryOperations.java
@@ -46,6 +46,7 @@
import io.micronaut.data.exceptions.DataAccessException;
import io.micronaut.data.model.Page;
import io.micronaut.data.model.Pageable;
+import io.micronaut.data.model.Pageable.Mode;
import io.micronaut.data.model.PersistentEntity;
import io.micronaut.data.model.PersistentProperty;
import io.micronaut.data.model.runtime.AttributeConverterRegistry;
@@ -316,6 +317,9 @@ private Iterable findAllAggregated(ClientSession clientSession,
MongoPreparedQuery preparedQuery,
boolean stream) {
Pageable pageable = preparedQuery.getPageable();
+ if (pageable.getMode() != Mode.OFFSET) {
+ throw new UnsupportedOperationException("Mode " + pageable.getMode() + " is not supported by the MongoDB implementation");
+ }
int limit = pageable == Pageable.UNPAGED ? -1 : pageable.getSize();
Class type = preparedQuery.getRootEntity();
Class resultType = preparedQuery.getResultType();
@@ -334,6 +338,9 @@ private Iterable findAllFiltered(ClientSession clientSession,
MongoPreparedQuery preparedQuery,
boolean stream) {
Pageable pageable = preparedQuery.getPageable();
+ if (pageable.getMode() != Mode.OFFSET) {
+ throw new UnsupportedOperationException("Mode " + pageable.getMode() + " is not supported by the MongoDB implementation");
+ }
int limit = pageable == Pageable.UNPAGED ? -1 : pageable.getSize();
Class type = preparedQuery.getRootEntity();
Class resultType = preparedQuery.getResultType();
diff --git a/data-processor/src/main/java/io/micronaut/data/processor/visitors/RepositoryTypeElementVisitor.java b/data-processor/src/main/java/io/micronaut/data/processor/visitors/RepositoryTypeElementVisitor.java
index 61d79e59638..9b489614165 100644
--- a/data-processor/src/main/java/io/micronaut/data/processor/visitors/RepositoryTypeElementVisitor.java
+++ b/data-processor/src/main/java/io/micronaut/data/processor/visitors/RepositoryTypeElementVisitor.java
@@ -40,6 +40,7 @@
import io.micronaut.data.annotation.sql.Procedure;
import io.micronaut.data.intercept.annotation.DataMethod;
import io.micronaut.data.intercept.annotation.DataMethodQueryParameter;
+import io.micronaut.data.model.CursoredPage;
import io.micronaut.data.model.DataType;
import io.micronaut.data.model.JsonDataType;
import io.micronaut.data.model.Page;
@@ -124,6 +125,7 @@ public class RepositoryTypeElementVisitor implements TypeElementVisitor count = cb.createQuery();
// count.select(cb.count(query.getRoots().iterator().next()));
// CommonAbstractCriteria countQueryCriteria = defineQuery(matchContext, matchContext.getRootEntity(), cb);
diff --git a/data-processor/src/test/groovy/io/micronaut/data/processor/visitors/PageSpec.groovy b/data-processor/src/test/groovy/io/micronaut/data/processor/visitors/PageSpec.groovy
index 173532f8913..2384b976c3c 100644
--- a/data-processor/src/test/groovy/io/micronaut/data/processor/visitors/PageSpec.groovy
+++ b/data-processor/src/test/groovy/io/micronaut/data/processor/visitors/PageSpec.groovy
@@ -19,8 +19,10 @@ import io.micronaut.annotation.processing.TypeElementVisitorProcessor
import io.micronaut.annotation.processing.test.JavaParser
import io.micronaut.data.annotation.Join
import io.micronaut.data.annotation.Query
+import io.micronaut.data.intercept.FindCursoredPageInterceptor
import io.micronaut.data.intercept.FindPageInterceptor
import io.micronaut.data.intercept.annotation.DataMethod
+import io.micronaut.data.model.CursoredPageable
import io.micronaut.data.model.Pageable
import io.micronaut.data.model.PersistentEntity
import io.micronaut.data.model.entities.Person
@@ -266,6 +268,42 @@ interface MyInterface extends GenericRepository {
e.message.contains('Query returns a Page and does not specify a \'countQuery\' member')
}
+ void "test cursored page method match"() {
+ given:
+ BeanDefinition beanDefinition = buildRepository('test.MyInterface' , """
+
+import io.micronaut.context.annotation.Executable;
+import io.micronaut.data.model.entities.Person;
+
+@Repository
+@Executable
+interface MyInterface extends GenericRepository {
+
+ CursoredPage list(Pageable pageable);
+
+ CursoredPage findByName(String title, Pageable pageable);
+
+}
+""")
+
+ def alias = new JpaQueryBuilder().getAliasName(PersistentEntity.of(Person))
+
+ when: "the list method is retrieved"
+ def listMethod = beanDefinition.getRequiredMethod("list", Pageable)
+ def listAnn = listMethod.synthesize(DataMethod)
+
+ def findMethod = beanDefinition.getRequiredMethod("findByName", String, Pageable)
+ def findAnn = findMethod.synthesize(DataMethod)
+
+
+ then:"it is configured correctly"
+ listAnn.interceptor() == FindCursoredPageInterceptor
+ findAnn.interceptor() == FindCursoredPageInterceptor
+ findMethod.hasAnnotation(Query.class)
+ findMethod.getValue(Query.class, "value", String).get() == "SELECT $alias FROM io.micronaut.data.model.entities.Person AS $alias WHERE (${alias}.name = :p1)"
+ findMethod.getValue(Query.class, "countQuery", String).get() == "SELECT COUNT($alias) FROM io.micronaut.data.model.entities.Person AS $alias WHERE (${alias}.name = :p1)"
+ }
+
@Override
protected JavaParser newJavaParser() {
return new JavaParser() {
diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/h2/H2CursoredPaginationSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/h2/H2CursoredPaginationSpec.groovy
new file mode 100644
index 00000000000..3bae50d78b5
--- /dev/null
+++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/h2/H2CursoredPaginationSpec.groovy
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2017-2020 original 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 io.micronaut.data.r2dbc.h2
+
+import groovy.transform.Memoized
+import io.micronaut.context.ApplicationContext
+import io.micronaut.data.tck.repositories.*
+import io.micronaut.data.tck.tests.AbstractCursoredPageSpec
+import io.micronaut.data.tck.tests.AbstractRepositorySpec
+import spock.lang.AutoCleanup
+import spock.lang.Shared
+
+class H2CursoredPaginationSpec extends AbstractCursoredPageSpec implements H2TestPropertyProvider {
+
+ @Shared
+ @AutoCleanup
+ ApplicationContext context
+
+ @Memoized
+ @Override
+ PersonRepository getPersonRepository() {
+ return context.getBean(H2PersonRepository)
+ }
+
+ @Memoized
+ @Override
+ BookRepository getBookRepository() {
+ return context.getBean(H2BookRepository)
+ }
+
+ @Override
+ void init() {
+ context = ApplicationContext.run(properties)
+ }
+}
diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mysql/MySqlCursoredPaginationSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mysql/MySqlCursoredPaginationSpec.groovy
new file mode 100644
index 00000000000..69849a7ce1d
--- /dev/null
+++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mysql/MySqlCursoredPaginationSpec.groovy
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2017-2020 original 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 io.micronaut.data.r2dbc.mysql
+
+import groovy.transform.Memoized
+import io.micronaut.context.ApplicationContext
+import io.micronaut.data.tck.repositories.BookRepository
+import io.micronaut.data.tck.repositories.PersonRepository
+import io.micronaut.data.tck.tests.AbstractCursoredPageSpec
+import spock.lang.AutoCleanup
+import spock.lang.Shared
+
+class MySqlCursoredPaginationSpec extends AbstractCursoredPageSpec implements MySqlTestPropertyProvider {
+
+ @Shared @AutoCleanup ApplicationContext context
+
+ @Memoized
+ @Override
+ PersonRepository getPersonRepository() {
+ return context.getBean(MySqlPersonRepository)
+ }
+
+ @Memoized
+ @Override
+ BookRepository getBookRepository() {
+ return context.getBean(MySqlBookRepository)
+ }
+
+ @Override
+ void init() {
+ context = ApplicationContext.run(properties)
+ }
+
+}
diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/oraclexe/OracleXECursoredPaginationSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/oraclexe/OracleXECursoredPaginationSpec.groovy
new file mode 100644
index 00000000000..d739f88256b
--- /dev/null
+++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/oraclexe/OracleXECursoredPaginationSpec.groovy
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2017-2020 original 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 io.micronaut.data.r2dbc.oraclexe
+
+import groovy.transform.Memoized
+import io.micronaut.context.ApplicationContext
+import io.micronaut.data.tck.repositories.BookRepository
+import io.micronaut.data.tck.repositories.PersonRepository
+import io.micronaut.data.tck.tests.AbstractCursoredPageSpec
+import spock.lang.AutoCleanup
+import spock.lang.Shared
+
+class OracleXECursoredPaginationSpec extends AbstractCursoredPageSpec implements OracleXETestPropertyProvider {
+
+ @Shared @AutoCleanup ApplicationContext context
+
+ @Override
+ @Memoized
+ PersonRepository getPersonRepository() {
+ return context.getBean(OracleXEPersonRepository)
+ }
+
+ @Override
+ @Memoized
+ BookRepository getBookRepository() {
+ return context.getBean(OracleXEBookRepository)
+ }
+
+ @Override
+ void init() {
+ context = ApplicationContext.run(properties)
+ }
+
+}
diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresCursoredPaginationSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresCursoredPaginationSpec.groovy
new file mode 100644
index 00000000000..5ef146b2e94
--- /dev/null
+++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresCursoredPaginationSpec.groovy
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2017-2020 original 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 io.micronaut.data.r2dbc.postgres
+
+import groovy.transform.Memoized
+import io.micronaut.context.ApplicationContext
+import io.micronaut.data.tck.repositories.BookRepository
+import io.micronaut.data.tck.repositories.PersonRepository
+import io.micronaut.data.tck.tests.AbstractCursoredPageSpec
+import spock.lang.AutoCleanup
+import spock.lang.Ignore
+import spock.lang.Shared
+
+//@Ignore("Causes error: 'FATAL: sorry, too many clients already'")
+class PostgresCursoredPaginationSpec extends AbstractCursoredPageSpec implements PostgresTestPropertyProvider {
+ @Shared @AutoCleanup ApplicationContext context
+
+ @Memoized
+ @Override
+ PersonRepository getPersonRepository() {
+ return context.getBean(PostgresPersonRepository)
+ }
+
+ @Memoized
+ @Override
+ BookRepository getBookRepository() {
+ return context.getBean(PostgresBookRepository)
+ }
+
+ @Override
+ void init() {
+ context = ApplicationContext.run(getProperties())
+ }
+}
diff --git a/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/DefaultAbstractFindPageInterceptor.java b/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/DefaultAbstractFindPageInterceptor.java
new file mode 100644
index 00000000000..5001a0aca40
--- /dev/null
+++ b/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/DefaultAbstractFindPageInterceptor.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2017-2020 original 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 io.micronaut.data.runtime.intercept;
+
+import io.micronaut.aop.MethodInvocationContext;
+import io.micronaut.core.annotation.NonNull;
+import io.micronaut.core.util.CollectionUtils;
+import io.micronaut.data.annotation.Query;
+import io.micronaut.data.intercept.RepositoryMethodKey;
+import io.micronaut.data.model.CursoredPage;
+import io.micronaut.data.model.Page;
+import io.micronaut.data.model.Pageable;
+import io.micronaut.data.model.Pageable.Cursor;
+import io.micronaut.data.model.Pageable.Mode;
+import io.micronaut.data.model.runtime.PreparedQuery;
+import io.micronaut.data.operations.RepositoryOperations;
+import io.micronaut.data.runtime.operations.internal.sql.DefaultSqlPreparedQuery;
+
+import java.util.List;
+
+/**
+ * An abstract base implementation of query interceptor for page interceptors
+ * implementing {@link io.micronaut.data.intercept.FindPageInterceptor} or
+ * {@link io.micronaut.data.intercept.FindCursoredPageInterceptor}.
+ *
+ * @param The declaring type
+ * @param The paged type.
+ * @author graemerocher
+ * @since 4.8.0
+ */
+public abstract class DefaultAbstractFindPageInterceptor extends AbstractQueryInterceptor {
+
+ /**
+ * Default constructor.
+ * @param datastore The operations
+ */
+ protected DefaultAbstractFindPageInterceptor(@NonNull RepositoryOperations datastore) {
+ super(datastore);
+ }
+
+ @Override
+ public R intercept(RepositoryMethodKey methodKey, MethodInvocationContext context) {
+ Class returnType = context.getReturnType().getType();
+ if (context.hasAnnotation(Query.class)) {
+ PreparedQuery, ?> preparedQuery = prepareQuery(methodKey, context);
+
+ Iterable> iterable = operations.findAll(preparedQuery);
+ List results = (List) CollectionUtils.iterableToList(iterable);
+ Pageable pageable = getPageable(context);
+ Long totalCount = null;
+ if (pageable.requestTotal()) {
+ PreparedQuery, Number> countQuery = prepareCountQuery(methodKey, context);
+ Number n = operations.findOne(countQuery);
+ totalCount = n != null ? n.longValue() : null;
+ }
+
+ Page page;
+ if (pageable.getMode() == Mode.OFFSET) {
+ page = Page.of(results, pageable, totalCount);
+ } else if (preparedQuery instanceof DefaultSqlPreparedQuery, ?> sqlPreparedQuery) {
+ List cursors = sqlPreparedQuery.createCursors((List) results, pageable);
+ page = CursoredPage.of(results, pageable, cursors, totalCount);
+ } else {
+ throw new UnsupportedOperationException("Only offset pageable mode is supported by this query implementation");
+ }
+ if (returnType.isInstance(page)) {
+ return (R) page;
+ } else {
+ return operations.getConversionService().convert(page, returnType)
+ .orElseThrow(() -> new IllegalStateException("Unsupported page interface type " + returnType));
+ }
+ } else {
+
+ Page page = operations.findPage(getPagedQuery(context));
+ if (returnType.isInstance(page)) {
+ return (R) page;
+ } else {
+ return operations.getConversionService().convert(page, returnType)
+ .orElseThrow(() -> new IllegalStateException("Unsupported page interface type " + returnType));
+ }
+ }
+ }
+}
diff --git a/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/DefaultFindCursoredPageInterceptor.java b/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/DefaultFindCursoredPageInterceptor.java
new file mode 100644
index 00000000000..f0309204523
--- /dev/null
+++ b/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/DefaultFindCursoredPageInterceptor.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2017-2020 original 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 io.micronaut.data.runtime.intercept;
+
+import io.micronaut.aop.MethodInvocationContext;
+import io.micronaut.core.annotation.NonNull;
+import io.micronaut.data.intercept.FindCursoredPageInterceptor;
+import io.micronaut.data.model.CursoredPageable;
+import io.micronaut.data.model.Pageable;
+import io.micronaut.data.model.Pageable.Mode;
+import io.micronaut.data.operations.RepositoryOperations;
+
+/**
+ * Default implementation of {@link FindCursoredPageInterceptor}.
+ *
+ * @param The declaring type
+ * @param The paged type.
+ * @author Andriy Dmytruk
+ * @since 4.8.0
+ */
+public class DefaultFindCursoredPageInterceptor extends DefaultAbstractFindPageInterceptor implements FindCursoredPageInterceptor {
+
+ /**
+ * Default constructor.
+ *
+ * @param datastore The operations
+ */
+ protected DefaultFindCursoredPageInterceptor(@NonNull RepositoryOperations datastore) {
+ super(datastore);
+ }
+
+ @Override
+ protected Pageable getPageable(MethodInvocationContext, ?> context) {
+ Pageable pageable = super.getPageable(context);
+ if (pageable.getMode() == Mode.OFFSET) {
+ if (pageable.getNumber() == 0) {
+ pageable = CursoredPageable.from(pageable.getSize(), pageable.getSort());
+ } else {
+ throw new IllegalArgumentException("Pageable with offset mode provided, but method must return a cursored page");
+ }
+ }
+ return pageable;
+ }
+}
diff --git a/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/DefaultFindPageInterceptor.java b/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/DefaultFindPageInterceptor.java
index 6aa7706bfa8..c4201330f47 100644
--- a/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/DefaultFindPageInterceptor.java
+++ b/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/DefaultFindPageInterceptor.java
@@ -15,18 +15,10 @@
*/
package io.micronaut.data.runtime.intercept;
-import io.micronaut.aop.MethodInvocationContext;
import io.micronaut.core.annotation.NonNull;
-import io.micronaut.core.util.CollectionUtils;
-import io.micronaut.data.annotation.Query;
import io.micronaut.data.intercept.FindPageInterceptor;
-import io.micronaut.data.intercept.RepositoryMethodKey;
-import io.micronaut.data.model.Page;
-import io.micronaut.data.model.runtime.PreparedQuery;
import io.micronaut.data.operations.RepositoryOperations;
-import java.util.List;
-
/**
* Default implementation of {@link FindPageInterceptor}.
*
@@ -35,7 +27,7 @@
* @author graemerocher
* @since 1.0.0
*/
-public class DefaultFindPageInterceptor extends AbstractQueryInterceptor implements FindPageInterceptor {
+public class DefaultFindPageInterceptor extends DefaultAbstractFindPageInterceptor implements FindPageInterceptor {
/**
* Default constructor.
@@ -44,34 +36,4 @@ public class DefaultFindPageInterceptor extends AbstractQueryInterceptor context) {
- Class returnType = context.getReturnType().getType();
- if (context.hasAnnotation(Query.class)) {
- PreparedQuery, ?> preparedQuery = prepareQuery(methodKey, context);
- PreparedQuery, Number> countQuery = prepareCountQuery(methodKey, context);
-
- Iterable> iterable = operations.findAll(preparedQuery);
- List resultList = (List) CollectionUtils.iterableToList(iterable);
- Number n = operations.findOne(countQuery);
- Long result = n != null ? n.longValue() : 0;
- Page page = Page.of(resultList, getPageable(context), result);
- if (returnType.isInstance(page)) {
- return (R) page;
- } else {
- return operations.getConversionService().convert(page, returnType)
- .orElseThrow(() -> new IllegalStateException("Unsupported page interface type " + returnType));
- }
- } else {
-
- Page page = operations.findPage(getPagedQuery(context));
- if (returnType.isInstance(page)) {
- return (R) page;
- } else {
- return operations.getConversionService().convert(page, returnType)
- .orElseThrow(() -> new IllegalStateException("Unsupported page interface type " + returnType));
- }
- }
- }
}
diff --git a/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/DefaultFindSliceInterceptor.java b/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/DefaultFindSliceInterceptor.java
index b387846f247..1620acf6aa2 100644
--- a/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/DefaultFindSliceInterceptor.java
+++ b/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/DefaultFindSliceInterceptor.java
@@ -28,6 +28,8 @@
import io.micronaut.data.model.runtime.PreparedQuery;
import io.micronaut.data.operations.RepositoryOperations;
+import java.util.List;
+
/**
* Default implementation of {@link FindSliceInterceptor}.
*
@@ -53,7 +55,8 @@ public R intercept(RepositoryMethodKey methodKey, MethodInvocationContext
PreparedQuery, ?> preparedQuery = prepareQuery(methodKey, context);
Pageable pageable = preparedQuery.getPageable();
Iterable iterable = (Iterable) operations.findAll(preparedQuery);
- Slice slice = Slice.of(CollectionUtils.iterableToList(iterable), pageable);
+ List results = CollectionUtils.iterableToList(iterable);
+ Slice slice = Slice.of(results, pageable);
return convertOrFail(context, slice);
} else {
PagedQuery pagedQuery = getPagedQuery(context);
diff --git a/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/criteria/AbstractSpecificationInterceptor.java b/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/criteria/AbstractSpecificationInterceptor.java
index 4a03e535bc5..8bff61c9541 100644
--- a/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/criteria/AbstractSpecificationInterceptor.java
+++ b/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/criteria/AbstractSpecificationInterceptor.java
@@ -28,6 +28,7 @@
import io.micronaut.data.intercept.RepositoryMethodKey;
import io.micronaut.data.model.AssociationUtils;
import io.micronaut.data.model.Pageable;
+import io.micronaut.data.model.Pageable.Mode;
import io.micronaut.data.model.Sort;
import io.micronaut.data.model.jpa.criteria.PersistentEntityFrom;
import io.micronaut.data.model.jpa.criteria.impl.QueryResultPersistentEntityCriteriaQuery;
@@ -142,6 +143,9 @@ protected final Iterable> findAll(RepositoryMethodKey methodKey, MethodInvocat
CriteriaQuery query = buildQuery(context, type, methodJoinPaths);
Pageable pageable = getPageable(context);
if (pageable != null) {
+ if (pageable.getMode() != Mode.OFFSET) {
+ throw new UnsupportedOperationException("Pageable mode " + pageable.getMode() + " is not supported with specifications");
+ }
return criteriaRepositoryOperations.findAll(query, (int) pageable.getOffset(), pageable.getSize());
}
return criteriaRepositoryOperations.findAll(query);
@@ -168,7 +172,7 @@ protected final Long count(RepositoryMethodKey methodKey, MethodInvocationContex
Set methodJoinPaths = getMethodJoinPaths(methodKey, context);
Long count;
if (criteriaRepositoryOperations != null) {
- count = criteriaRepositoryOperations.findOne(buildCountQuery(context));
+ count = criteriaRepositoryOperations.findOne(buildCountQuery(context));
} else {
count = operations.findOne(preparedQueryForCriteria(methodKey, context, Type.COUNT, methodJoinPaths));
}
diff --git a/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/criteria/FindPageSpecificationInterceptor.java b/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/criteria/FindPageSpecificationInterceptor.java
index a07ee0dbcb9..36419aa0c0b 100644
--- a/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/criteria/FindPageSpecificationInterceptor.java
+++ b/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/criteria/FindPageSpecificationInterceptor.java
@@ -56,14 +56,17 @@ public Object intercept(RepositoryMethodKey methodKey, MethodInvocationContext iterable = findAll(methodKey, context, Type.FIND_PAGE);
List resultList = (List) CollectionUtils.iterableToList(iterable);
- Long count = count(methodKey, context);
+ Long count = null;
+ if (pageable.requestTotal()) {
+ count = count(methodKey, context);
+ }
Page page = Page.of(resultList, getPageable(context), count);
Class rt = context.getReturnType().getType();
diff --git a/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/criteria/async/FindPageAsyncSpecificationInterceptor.java b/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/criteria/async/FindPageAsyncSpecificationInterceptor.java
index 38375370a7a..0b59ad33e7e 100644
--- a/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/criteria/async/FindPageAsyncSpecificationInterceptor.java
+++ b/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/criteria/async/FindPageAsyncSpecificationInterceptor.java
@@ -24,6 +24,7 @@
import io.micronaut.data.operations.RepositoryOperations;
import java.util.List;
+import java.util.concurrent.CompletableFuture;
/**
* Runtime implementation of {@code CompletableFuture find(Specification, Pageable)}.
@@ -57,11 +58,14 @@ public Object intercept(RepositoryMethodKey methodKey, MethodInvocationContext {
List> resultList = CollectionUtils.iterableToList(iterable);
- return Page.of(resultList, pageable, resultList.size());
+ return Page.of(resultList, pageable, (long) resultList.size());
});
}
- return findAllAsync(methodKey, context, Type.FIND_PAGE).thenCompose(iterable -> countAsync(methodKey, context)
- .thenApply(count -> Page.of(CollectionUtils.iterableToList(iterable), pageable, count.longValue())));
+ return findAllAsync(methodKey, context, Type.FIND_PAGE).thenCompose(iterable ->
+ pageable.requestTotal()
+ ? countAsync(methodKey, context).thenApply(count -> Page.of(CollectionUtils.iterableToList(iterable), pageable, count.longValue()))
+ : CompletableFuture.completedFuture(Page.of(CollectionUtils.iterableToList(iterable), pageable, null))
+ );
}
diff --git a/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/criteria/reactive/AbstractReactiveSpecificationInterceptor.java b/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/criteria/reactive/AbstractReactiveSpecificationInterceptor.java
index 0e50b10d84e..358de4a2e0b 100644
--- a/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/criteria/reactive/AbstractReactiveSpecificationInterceptor.java
+++ b/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/criteria/reactive/AbstractReactiveSpecificationInterceptor.java
@@ -20,6 +20,7 @@
import io.micronaut.data.exceptions.DataAccessException;
import io.micronaut.data.intercept.RepositoryMethodKey;
import io.micronaut.data.model.Pageable;
+import io.micronaut.data.model.Pageable.Mode;
import io.micronaut.data.model.query.JoinPath;
import io.micronaut.data.operations.RepositoryOperations;
import io.micronaut.data.operations.reactive.ReactiveCapableRepository;
@@ -76,6 +77,9 @@ protected final Publisher findAllReactive(RepositoryMethodKey methodKey,
CriteriaQuery criteriaQuery = buildQuery(context, type, methodJoinPaths);
Pageable pageable = getPageable(context);
if (pageable != null) {
+ if (pageable.getMode() != Mode.OFFSET) {
+ throw new UnsupportedOperationException("Pageable mode " + pageable.getMode() + " is not supported by hibernate operations");
+ }
return reactiveCriteriaOperations.findAll(criteriaQuery, (int) pageable.getOffset(), pageable.getSize());
}
return reactiveCriteriaOperations.findAll(criteriaQuery);
diff --git a/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/criteria/reactive/FindPageReactiveSpecificationInterceptor.java b/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/criteria/reactive/FindPageReactiveSpecificationInterceptor.java
index aeb7de6682e..c2667321db3 100644
--- a/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/criteria/reactive/FindPageReactiveSpecificationInterceptor.java
+++ b/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/criteria/reactive/FindPageReactiveSpecificationInterceptor.java
@@ -55,12 +55,15 @@ public Object intercept(RepositoryMethodKey methodKey, MethodInvocationContext results = Flux.from(findAllReactive(methodKey, context, Type.FIND_PAGE));
- result = results.collectList().map(resultList -> Page.of(resultList, pageable, resultList.size()));
+ result = results.collectList().map(resultList -> Page.of(resultList, pageable, (long) resultList.size()));
} else {
result = Flux.from(findAllReactive(methodKey, context, Type.FIND_PAGE))
.collectList()
- .flatMap(list -> Mono.from(countReactive(methodKey, context))
- .map(count -> Page.of(list, getPageable(context), count.longValue())));
+ .flatMap(
+ list -> pageable.requestTotal()
+ ? Mono.from(countReactive(methodKey, context)).map(count -> Page.of(list, getPageable(context), count))
+ : Mono.just(Page.of(list, getPageable(context), null))
+ );
}
return Publishers.convertPublisher(conversionService, result, context.getReturnType().getType());
diff --git a/data-runtime/src/main/java/io/micronaut/data/runtime/operations/internal/sql/DefaultSqlPreparedQuery.java b/data-runtime/src/main/java/io/micronaut/data/runtime/operations/internal/sql/DefaultSqlPreparedQuery.java
index 64d0d0aae5c..c170079d383 100644
--- a/data-runtime/src/main/java/io/micronaut/data/runtime/operations/internal/sql/DefaultSqlPreparedQuery.java
+++ b/data-runtime/src/main/java/io/micronaut/data/runtime/operations/internal/sql/DefaultSqlPreparedQuery.java
@@ -17,9 +17,16 @@
import io.micronaut.core.annotation.Internal;
import io.micronaut.core.annotation.NonNull;
+import io.micronaut.core.annotation.Nullable;
import io.micronaut.data.exceptions.DataAccessException;
+import io.micronaut.data.model.CursoredPageable;
+import io.micronaut.data.model.DataType;
import io.micronaut.data.model.Pageable;
+import io.micronaut.data.model.Pageable.Cursor;
+import io.micronaut.data.model.Pageable.Mode;
+import io.micronaut.data.model.PersistentProperty;
import io.micronaut.data.model.Sort;
+import io.micronaut.data.model.Sort.Order;
import io.micronaut.data.model.query.builder.AbstractSqlLikeQueryBuilder;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.model.query.builder.sql.SqlQueryBuilder;
@@ -34,7 +41,10 @@
import io.micronaut.data.runtime.query.internal.DelegateStoredQuery;
import java.lang.reflect.Array;
+import java.util.ArrayList;
import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
import java.util.Map;
/**
@@ -48,6 +58,8 @@
@Internal
public class DefaultSqlPreparedQuery extends DefaultBindableParametersPreparedQuery implements SqlPreparedQuery, DelegatePreparedQuery {
+ protected List cursorQueryBindings;
+ protected List> cursorProperties;
protected final SqlStoredQuery sqlStoredQuery;
protected String query;
@@ -157,6 +169,22 @@ public void attachPageable(Pageable pageable, boolean isSingleResult) {
SqlQueryBuilder queryBuilder = sqlStoredQuery.getQueryBuilder();
StringBuilder added = new StringBuilder();
Sort sort = pageable.getSort();
+ if (pageable instanceof CursoredPageable cursored) {
+ // Create a sort for the cursored pagination. The sort must produce a unique
+ // sorting on the rows. Therefore, we make sure id is present in it.
+ List orders = new ArrayList<>(sort.getOrderBy());
+ for (PersistentProperty idProperty: persistentEntity.getIdentityProperties()) {
+ String name = idProperty.getName();
+ if (orders.stream().noneMatch(o -> o.getProperty().equals(name))) {
+ orders.add(Order.asc(name));
+ }
+ }
+ sort = Sort.of(orders);
+ if (cursored.isBackward()) {
+ sort = reverseSort(sort);
+ }
+ added.append(buildCursorPagination(cursored.cursor().orElse(null), sort));
+ }
if (sort.isSorted()) {
added.append(queryBuilder.buildOrderBy("", persistentEntity, sqlStoredQuery.getAnnotationMetadata(), sort, isNative()).getQuery());
} else if (isSqlServerWithoutOrderBy(query, sqlStoredQuery.getDialect())) {
@@ -180,6 +208,124 @@ public void attachPageable(Pageable pageable, boolean isSingleResult) {
}
}
+ /**
+ * A utility method for reversing the sort.
+ *
+ * @param sort The current sort
+ * @return reversed sort
+ */
+ private Sort reverseSort(Sort sort) {
+ if (!sort.isSorted()) {
+ return sort;
+ }
+ return Sort.of(sort.getOrderBy().stream().map(Order::reverse).toList());
+ }
+
+ /**
+ * Add relevant query clauses and query bindings to use cursored pagination.
+ *
+ * @param cursor The supplied cursor
+ * @param sort The sorting that will be used in the query
+ * @return The additional query part
+ */
+ @NonNull
+ private String buildCursorPagination(@Nullable Pageable.Cursor cursor, @NonNull Sort sort) {
+ List orders = sort.getOrderBy();
+ cursorProperties = new ArrayList<>(orders.size());
+ for (Order order: orders) {
+ cursorProperties.add(getPersistentEntity().getPropertyByName(order.getProperty()));
+ }
+ if (cursor == null) {
+ return "";
+ }
+ if (orders.size() != cursor.size()) {
+ throw new IllegalArgumentException("The cursor must match the sorting size");
+ }
+ if (orders.isEmpty()) {
+ throw new IllegalArgumentException("At least one sorting property must be supplied");
+ }
+
+ List cursorBindings = new ArrayList<>(orders.size());
+ cursorQueryBindings = new ArrayList<>(orders.size() * (orders.size() + 1) / 2);
+ for (int i = 0; i < orders.size(); ++i) {
+ cursorBindings.add(new CursoredQueryParameterBinder(
+ "cursor_" + i, cursorProperties.get(i).getDataType(), cursor.get(i)
+ ));
+ }
+
+ StringBuilder builder = new StringBuilder(" ");
+ if (query.contains("WHERE")) {
+ int i = query.indexOf("WHERE") + "WHERE".length();
+ query = query.substring(0, i) + "(" + query.substring(i) + ")";
+ builder.append(" AND (");
+ } else {
+ builder.append("WHERE (");
+ }
+ String positionalParameter = getQueryBuilder().positionalParameterFormat();
+ int paramIndex = storedQuery.getQueryBindings().size() + 1;
+ for (int i = 0; i < orders.size(); ++i) {
+ builder.append("(");
+ for (int j = 0; j <= i; ++j) {
+ String propertyName = orders.get(j).getProperty();
+ builder.append(sqlStoredQuery.getQueryBuilder().buildPropertyByName(propertyName, query, getPersistentEntity(), getAnnotationMetadata(), isNative()));
+ if (orders.get(i).isAscending()) {
+ builder.append(i == j ? " > " : " = ");
+ } else {
+ builder.append(i == j ? " < " : " = ");
+ }
+ cursorQueryBindings.add(cursorBindings.get(j));
+ builder.append(String.format(positionalParameter, paramIndex++));
+ if (i != j) {
+ builder.append(" AND ");
+ }
+ }
+ builder.append(")");
+ if (i < orders.size() - 1) {
+ builder.append(" OR ");
+ }
+ }
+ builder.append(")");
+ return builder.toString();
+ }
+
+ /**
+ * Modify pageable based on the scan results.
+ * This is required for cursored pageable, as cursor is created from the results.
+ *
+ * @param results The scanning results
+ * @param pageable The pageable sent by user
+ * @return The updated pageable
+ * @since 4.8.0
+ */
+ @Internal
+ public List createCursors(List results, Pageable pageable) {
+ if (pageable.getMode() != Mode.CURSOR_NEXT && pageable.getMode() != Mode.CURSOR_PREVIOUS) {
+ return null;
+ }
+ if (pageable.getMode() == Mode.CURSOR_PREVIOUS) {
+ Collections.reverse(results);
+ }
+ List cursors = new ArrayList<>(results.size());
+ for (Object result: results) {
+ List cursorElements = new ArrayList<>(cursorProperties.size());
+ for (RuntimePersistentProperty property : cursorProperties) {
+ cursorElements.add(property.getProperty().get((E) result));
+ }
+ cursors.add(Cursor.of(cursorElements));
+ }
+ return cursors;
+ }
+
+ @Override
+ public void bindParameters(Binder binder, E entity, Map previousValues) {
+ super.bindParameters(binder, entity, previousValues);
+ if (cursorQueryBindings != null) {
+ for (QueryParameterBinding queryParameterBinding : cursorQueryBindings) {
+ binder.bindOne(queryParameterBinding, queryParameterBinding.getValue());
+ }
+ }
+ }
+
@Override
public QueryResultInfo getQueryResultInfo() {
return sqlStoredQuery.getQueryResultInfo();
@@ -237,4 +383,25 @@ protected int sizeOf(Object value) {
}
return 1;
}
+
+ private record CursoredQueryParameterBinder(
+ String name,
+ DataType dataType,
+ Object value
+ ) implements QueryParameterBinding {
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public DataType getDataType() {
+ return dataType;
+ }
+
+ @Override
+ public Object getValue() {
+ return value;
+ }
+ }
}
diff --git a/data-spring/src/main/java/io/micronaut/data/spring/runtime/PageableDelegate.java b/data-spring/src/main/java/io/micronaut/data/spring/runtime/PageableDelegate.java
index 9a44d6799ab..f740970a5e8 100644
--- a/data-spring/src/main/java/io/micronaut/data/spring/runtime/PageableDelegate.java
+++ b/data-spring/src/main/java/io/micronaut/data/spring/runtime/PageableDelegate.java
@@ -20,6 +20,8 @@
import io.micronaut.data.model.Pageable;
import io.micronaut.data.model.Sort;
+import java.util.Optional;
+
/**
* Supports representing a Spring Pageable as a Micronaut {@link Pageable}.
*
@@ -49,6 +51,21 @@ public int getSize() {
return target.getPageSize();
}
+ @Override
+ public Mode getMode() {
+ return Mode.OFFSET;
+ }
+
+ @Override
+ public Optional cursor() {
+ return Optional.empty();
+ }
+
+ @Override
+ public boolean requestTotal() {
+ return true;
+ }
+
@Override
public long getOffset() {
return target.getOffset();
@@ -59,4 +76,14 @@ public long getOffset() {
public Sort getSort() {
return new SortDelegate(target.getSort());
}
+
+ @Override
+ public Pageable withTotal() {
+ return this;
+ }
+
+ @Override
+ public Pageable withoutTotal() {
+ throw new IllegalStateException("Disabling requesting total is not supported for current Pageable");
+ }
}
diff --git a/data-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractCursoredPageSpec.groovy b/data-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractCursoredPageSpec.groovy
new file mode 100644
index 00000000000..a8a967793f3
--- /dev/null
+++ b/data-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractCursoredPageSpec.groovy
@@ -0,0 +1,275 @@
+/*
+ * Copyright 2017-2020 original 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 io.micronaut.data.tck.tests
+
+import io.micronaut.data.model.CursoredPageable
+import io.micronaut.data.model.Page
+import io.micronaut.data.model.Pageable
+import io.micronaut.data.model.Sort
+import io.micronaut.data.tck.entities.Book
+import io.micronaut.data.tck.entities.Person
+import io.micronaut.data.tck.repositories.BookRepository
+import io.micronaut.data.tck.repositories.PersonRepository
+import spock.lang.Specification
+
+abstract class AbstractCursoredPageSpec extends Specification {
+
+ abstract PersonRepository getPersonRepository()
+
+ abstract BookRepository getBookRepository()
+
+ abstract void init()
+
+ def setup() {
+ init()
+
+ // Create a repository that will look something like this:
+ // id | name | age
+ // 1 | AAAAA00 | 1
+ // 2 | AAAAA01 | 2
+ // ...
+ // 10 | AAAAA09 | 10
+ // 11 | BBBBB00 | 1
+ // ..
+ // 260 | ZZZZZ09 | 10
+ // 261 | AAAAA00 | 11
+ // 262 | AAAAA01 | 12
+ // ...
+ List people = []
+ 3.times {
+ ('A'..'Z').each { letter ->
+ 10.times { num ->
+ people << new Person(name: letter * 5 + String.format("%02d", num), age: it * 10 + num + 1)
+ }
+ }
+ }
+
+ personRepository.saveAll(people)
+ }
+
+ def cleanup() {
+ personRepository.deleteAll()
+ }
+
+ void "test cursored pageable list for sorting #sorting"() {
+ when: "10 people are paged"
+ def pageable = CursoredPageable.from(10, sorting)
+ Page page = personRepository.findAll(pageable)
+
+ then: "The data is correct"
+ page.content.size() == 10
+ page.content.every() { it instanceof Person }
+ page.content[0].name == name1
+ page.content[1].name == name2
+ page.totalSize == 780
+ page.totalPages == 78
+ page.getCursor(0).isPresent()
+ page.getCursor(9).isPresent()
+ page.hasNext()
+
+ when: "The next page is selected"
+ page = personRepository.findAll(page.nextPageable())
+
+ then: "it is correct"
+ page.offset == 10
+ page.pageNumber == 1
+ page.content[0].name == name10
+ page.content[9].name == name19
+ page.content.size() == 10
+ page.hasNext()
+ page.hasPrevious()
+
+ when: "The previous page is selected"
+ pageable = page.previousPageable()
+ page = personRepository.findAll(pageable)
+
+ then: "it is correct"
+ page.offset == 0
+ page.pageNumber == 0
+ page.content[0].name == name1
+ page.content.size() == 10
+ page.hasNext()
+ page.hasPrevious()
+
+ where:
+ sorting | name1 | name2 | name10 | name19
+ null | "AAAAA00" | "AAAAA01" | "BBBBB00" | "BBBBB09"
+ Sort.of(Sort.Order.desc("id")) | "ZZZZZ09" | "ZZZZZ08" | "YYYYY09" | "YYYYY00"
+ Sort.of(Sort.Order.asc("name")) | "AAAAA00" | "AAAAA00" | "AAAAA03" | "AAAAA06"
+ Sort.of(Sort.Order.desc("name")) | "ZZZZZ09" | "ZZZZZ09" | "ZZZZZ06" | "ZZZZZ03"
+ Sort.of(Sort.Order.asc("age"), Sort.Order.asc("name")) | "AAAAA00" | "BBBBB00" | "KKKKK00" | "TTTTT00"
+ Sort.of(Sort.Order.desc("age"), Sort.Order.asc("name")) | "AAAAA09" | "BBBBB09" | "KKKKK09" | "TTTTT09"
+ }
+
+ void "test pageable list with row removal"() {
+ when: "10 people are paged"
+ def pageable = Pageable.from(0, 10, sorting) // The first pageable can be non-cursored
+ Page page = personRepository.retrieve(pageable) // The retrieve method explicitly returns CursoredPage
+
+ then: "The data is correct"
+ page.content.size() == 10
+ page.content[0].name == elem1
+ page.content[1].name == elem2
+ page.hasNext()
+
+ when: "The next page is selected after deletion"
+ personRepository.delete(page.content[1])
+ personRepository.delete(page.content[9])
+ page = personRepository.retrieve(page.nextPageable())
+
+ then: "it is correct"
+ page.offset == 10
+ page.pageNumber == 1
+ page.content[0].name == elem10
+ page.content[9].name == elem19
+ page.content.size() == 10
+ page.hasNext()
+ page.hasPrevious()
+
+ when: "The previous page is selected"
+ pageable = page.previousPageable()
+ page = personRepository.retrieve(pageable)
+
+ then: "it is correct"
+ page.offset == 0
+ page.pageNumber == 0
+ page.content[0].name == elem1
+ page.content.size() == 8
+ page.getCursor(7).isPresent()
+ page.getCursor(8).isEmpty()
+ !page.hasPrevious()
+ page.hasNext()
+
+ where:
+ sorting | elem1 | elem2 | elem10 | elem19
+ null | "AAAAA00" | "AAAAA01" | "BBBBB00" | "BBBBB09"
+ Sort.of(Sort.Order.desc("id")) | "ZZZZZ09" | "ZZZZZ08" | "YYYYY09" | "YYYYY00"
+ Sort.of(Sort.Order.asc("name")) | "AAAAA00" | "AAAAA00" | "AAAAA03" | "AAAAA06"
+ Sort.of(Sort.Order.desc("name")) | "ZZZZZ09" | "ZZZZZ09" | "ZZZZZ06" | "ZZZZZ03"
+ }
+
+ void "test pageable list with row addition"() {
+ when: "10 people are paged"
+ def pageable = CursoredPageable.from(10, sorting)
+ Page page = personRepository.retrieve(pageable)
+
+ then: "The data is correct"
+ page.content.size() == 10
+ page.content[0].name == elem1
+ page.content[1].name == elem2
+ page.hasNext()
+
+ when: "The next page is selected after deletion"
+ personRepository.saveAll([
+ new Person(name: "AAAAA00"), new Person(name: "AAAAA01"),
+ new Person(name: "ZZZZZ08"), new Person(name: "ZZZZZ07")
+ ])
+ page = personRepository.retrieve(page.nextPageable())
+
+ then: "it is correct"
+ page.offset == 10
+ page.pageNumber == 1
+ page.content[0].name == elem10
+ page.content[9].name == elem19
+ page.content.size() == 10
+ page.hasNext()
+ page.hasPrevious()
+
+ when: "The previous page is selected"
+ pageable = page.previousPageable()
+ page = personRepository.retrieve(pageable)
+
+ then: "it is correct"
+ page.offset == 0
+ page.pageNumber == 0
+ page.content[0].name == elem3
+ page.content.size() == 10
+ page.hasPrevious()
+
+ when: "The second previous page is selected"
+ page = personRepository.retrieve(page.previousPageable())
+
+ then:
+ page.offset == 0
+ page.pageNumber == 0
+ page.content[0].name == elem1
+ page.content[1].name == elem2
+ page.getCursor(1).isPresent()
+ page.getCursor(2).isEmpty()
+ page.content.size() == 2
+ !page.hasPrevious()
+
+ where:
+ sorting | elem1 | elem2 | elem3 | elem10 | elem19
+ Sort.of(Sort.Order.asc("name")) | "AAAAA00" | "AAAAA00" | "AAAAA00" | "AAAAA03" | "AAAAA06"
+ Sort.of(Sort.Order.desc("name")) | "ZZZZZ09" | "ZZZZZ09" | "ZZZZZ09" | "ZZZZZ06" | "ZZZZZ03"
+ }
+
+ void "test pageable findBy"() {
+ when: "People are searched for"
+ def pageable = CursoredPageable.from(10, null)
+ Page page = personRepository.findByNameLike("A%", pageable)
+ Page page2 = personRepository.findPeople("A%", pageable)
+
+ then: "The page is correct"
+ page.offset == 0
+ page.pageNumber == 0
+ page.totalSize == 30
+ page2.totalSize == page.totalSize
+ var firstContent = page.content
+ page.content.name.every{ it.startsWith("A") }
+
+ when: "The next page is retrieved"
+ page = personRepository.findByNameLike("A%", page.nextPageable())
+
+ then: "it is correct"
+ page.offset == 10
+ page.pageNumber == 1
+ page.content.id != firstContent.id
+ page.content.name.every{ it.startsWith("A") }
+
+ when: "The previous page is selected"
+ pageable = page.previousPageable()
+ page = personRepository.findByNameLike("A%", pageable)
+
+ then: "it is correct"
+ page.offset == 0
+ page.pageNumber == 0
+ page.content.size() == 10
+ page.content.id == firstContent.id
+ page.content.name.every{ it.startsWith("A") }
+ }
+
+ void "test find with left join"() {
+ given:
+ def books = bookRepository.saveAll([
+ new Book(title: "Book 1", totalPages: 100),
+ new Book(title: "Book 2", totalPages: 100)
+ ])
+
+ when:
+ def page = bookRepository.findByTotalPagesGreaterThan(
+ 50, CursoredPageable.from(books.size(), null)
+ )
+
+ then:
+ page.getContent().size() == books.size()
+ page.getTotalSize() == books.size()
+
+ cleanup:
+ bookRepository.deleteAll()
+ }
+}
diff --git a/data-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractPageSpec.groovy b/data-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractPageSpec.groovy
index c1762444bc4..8cf2f8c588c 100644
--- a/data-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractPageSpec.groovy
+++ b/data-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractPageSpec.groovy
@@ -81,6 +81,9 @@ abstract class AbstractPageSpec extends Specification {
page.totalPages == 130
page.nextPageable().offset == 10
page.nextPageable().size == 10
+ page.hasNext()
+ page.hasTotalSize()
+ !page.hasPrevious()
when: "The next page is selected"
pageable = page.nextPageable()
@@ -91,6 +94,8 @@ abstract class AbstractPageSpec extends Specification {
page.pageNumber == 1
page.content[0].name.startsWith("K")
page.content.size() == 10
+ page.hasNext()
+ page.hasPrevious()
when: "The previous page is selected"
pageable = page.previousPageable()
@@ -101,6 +106,31 @@ abstract class AbstractPageSpec extends Specification {
page.pageNumber == 0
page.content[0].name.startsWith("A")
page.content.size() == 10
+ page.hasNext()
+ !page.hasPrevious()
+ }
+
+ void "test pageable list without total count"() {
+ when: "10 people are paged"
+ def pageable = Pageable.from(0, 10).withoutTotal()
+ Page page = personRepository.findAll(pageable)
+
+ then: "The data is correct"
+ page.content.size() == 10
+ page.content.every() { it instanceof Person }
+ !page.hasTotalSize()
+
+ when:
+ page.getTotalPages()
+
+ then:
+ thrown(IllegalStateException)
+
+ when:
+ page.getTotalSize()
+
+ then:
+ thrown(IllegalStateException)
}
void "test pageable sort"() {
diff --git a/data-tck/src/main/java/io/micronaut/data/tck/repositories/PersonRepository.java b/data-tck/src/main/java/io/micronaut/data/tck/repositories/PersonRepository.java
index f875d37e573..617f2bc48da 100644
--- a/data-tck/src/main/java/io/micronaut/data/tck/repositories/PersonRepository.java
+++ b/data-tck/src/main/java/io/micronaut/data/tck/repositories/PersonRepository.java
@@ -16,10 +16,12 @@
package io.micronaut.data.tck.repositories;
import io.micronaut.context.annotation.Parameter;
+import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.data.annotation.Id;
import io.micronaut.data.annotation.ParameterExpression;
import io.micronaut.data.annotation.Query;
+import io.micronaut.data.model.CursoredPage;
import io.micronaut.data.model.Page;
import io.micronaut.data.model.Pageable;
import io.micronaut.data.model.Slice;
@@ -173,6 +175,8 @@ public interface PersonRepository extends CrudRepository, Pageable
List findDistinctName();
+ CursoredPage retrieve(@NonNull Pageable pageable);
+
class Specifications {
public static PredicateSpecification nameEquals(String name) {
diff --git a/doc-examples/jdbc-example-groovy/src/main/groovy/example/BookRepository.groovy b/doc-examples/jdbc-example-groovy/src/main/groovy/example/BookRepository.groovy
index 6aebd45bf09..f01a1294715 100644
--- a/doc-examples/jdbc-example-groovy/src/main/groovy/example/BookRepository.groovy
+++ b/doc-examples/jdbc-example-groovy/src/main/groovy/example/BookRepository.groovy
@@ -46,6 +46,14 @@ interface BookRepository extends CrudRepository { // <2>
Slice list(Pageable pageable);
// end::pageable[]
+ // tag::cursored-pageable[]
+ CursoredPage find(CursoredPageable pageable) // <1>
+
+ CursoredPage findByPagesBetween(int minPageCount, int maxPageCount, Pageable pageable) // <2>
+
+ Page findByTitleStartingWith(String title, Pageable pageable) // <3>
+ // end::cursored-pageable[]
+
// tag::simple-projection[]
List findTitleByPagesGreaterThan(int pageCount);
// end::simple-projection[]
diff --git a/doc-examples/jdbc-example-groovy/src/test/groovy/example/BookRepositorySpec.groovy b/doc-examples/jdbc-example-groovy/src/test/groovy/example/BookRepositorySpec.groovy
index 722116101d2..c1699f5c4c9 100644
--- a/doc-examples/jdbc-example-groovy/src/test/groovy/example/BookRepositorySpec.groovy
+++ b/doc-examples/jdbc-example-groovy/src/test/groovy/example/BookRepositorySpec.groovy
@@ -1,5 +1,10 @@
package example
+import io.micronaut.data.model.CursoredPage
+import io.micronaut.data.model.CursoredPageable
+import io.micronaut.data.model.Page
+import io.micronaut.data.model.Pageable
+import io.micronaut.data.model.Sort
import io.micronaut.test.extensions.spock.annotation.MicronautTest
import spock.lang.Shared
import spock.lang.Specification
@@ -53,5 +58,36 @@ class BookRepositorySpec extends Specification {
bookRepository.count() == 0
}
+ void "test cursored pageable"() {
+ given:
+ bookRepository.saveAll(Arrays.asList(
+ new Book("The Stand", 1000),
+ new Book("The Shining", 600),
+ new Book("The Power of the Dog", 500),
+ new Book("The Border", 700),
+ new Book("Along Came a Spider", 300),
+ new Book("Pet Cemetery", 400),
+ new Book("A Game of Thrones", 900),
+ new Book("A Clash of Kings", 1100)
+ ))
+
+ when:
+ // tag::cursored-pageable[]
+ CursoredPage page = // <1>
+ bookRepository.find(CursoredPageable.from(5, Sort.of(Sort.Order.asc("title"))))
+ CursoredPage page2 = bookRepository.find(page.nextPageable()) // <2>
+ CursoredPage pageByPagesBetween = // <3>
+ bookRepository.findByPagesBetween(400, 700, Pageable.from(0, 3))
+ Page pageByTitleStarts = // <4>
+ bookRepository.findByTitleStartingWith("The", CursoredPageable.from( 3, Sort.unsorted()))
+ // end::cursored-pageable[]
+
+ then:
+ page.getNumberOfElements() == 5
+ page2.getNumberOfElements() == 3
+ pageByPagesBetween.getNumberOfElements() == 3
+ pageByTitleStarts.getNumberOfElements() == 3
+ }
+
}
diff --git a/doc-examples/jdbc-example-java/src/main/java/example/BookRepository.java b/doc-examples/jdbc-example-java/src/main/java/example/BookRepository.java
index 4a24052d3bc..d88b4c2130f 100644
--- a/doc-examples/jdbc-example-java/src/main/java/example/BookRepository.java
+++ b/doc-examples/jdbc-example-java/src/main/java/example/BookRepository.java
@@ -46,6 +46,14 @@ interface BookRepository extends CrudRepository { // <2>
Slice list(Pageable pageable);
// end::pageable[]
+ // tag::cursored-pageable[]
+ CursoredPage find(CursoredPageable pageable); // <1>
+
+ CursoredPage findByPagesBetween(int minPageCount, int maxPageCount, Pageable pageable); // <2>
+
+ Page findByTitleStartingWith(String title, Pageable pageable); // <3>
+ // end::cursored-pageable[]
+
// tag::simple-projection[]
List findTitleByPagesGreaterThan(int pageCount);
// end::simple-projection[]
diff --git a/doc-examples/jdbc-example-java/src/test/java/example/BookRepositorySpec.java b/doc-examples/jdbc-example-java/src/test/java/example/BookRepositorySpec.java
index 57751dcab6c..3d09ec45cba 100644
--- a/doc-examples/jdbc-example-java/src/test/java/example/BookRepositorySpec.java
+++ b/doc-examples/jdbc-example-java/src/test/java/example/BookRepositorySpec.java
@@ -2,6 +2,8 @@
import io.micronaut.context.BeanContext;
import io.micronaut.data.annotation.Query;
+import io.micronaut.data.model.CursoredPage;
+import io.micronaut.data.model.CursoredPageable;
import io.micronaut.data.model.Page;
import io.micronaut.data.model.Pageable;
import io.micronaut.data.model.Slice;
@@ -9,6 +11,8 @@
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import io.micronaut.data.model.Sort;
+import io.micronaut.data.model.Sort.Order;
import jakarta.inject.Inject;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
@@ -133,6 +137,47 @@ void testPageable() {
assertEquals(1, results.size());
}
+ @Test
+ void testCursoredPageable() {
+ bookRepository.saveAll(Arrays.asList(
+ new Book("The Stand", 1000),
+ new Book("The Shining", 600),
+ new Book("The Power of the Dog", 500),
+ new Book("The Border", 700),
+ new Book("Along Came a Spider", 300),
+ new Book("Pet Cemetery", 400),
+ new Book("A Game of Thrones", 900),
+ new Book("A Clash of Kings", 1100)
+ ));
+
+ // tag::cursored-pageable[]
+ CursoredPage page = // <1>
+ bookRepository.find(CursoredPageable.from(5, Sort.of(Order.asc("title"))));
+ CursoredPage page2 = bookRepository.find(page.nextPageable()); // <2>
+ CursoredPage pageByPagesBetween = // <3>
+ bookRepository.findByPagesBetween(400, 700, Pageable.from(0, 3));
+ Page pageByTitleStarts = // <4>
+ bookRepository.findByTitleStartingWith("The", CursoredPageable.from( 3, Sort.unsorted()));
+ // end::cursored-pageable[]
+
+ assertEquals(
+ 5,
+ page.getNumberOfElements()
+ );
+ assertEquals(
+ 3,
+ page2.getNumberOfElements()
+ );
+ assertEquals(
+ 3,
+ pageByPagesBetween.getNumberOfElements()
+ );
+ assertEquals(
+ 3,
+ pageByTitleStarts.getNumberOfElements()
+ );
+ }
+
@Test
void testDto() {
bookRepository.save(new Book("The Shining", 400));
diff --git a/doc-examples/jdbc-example-kotlin/src/main/kotlin/example/BookRepository.kt b/doc-examples/jdbc-example-kotlin/src/main/kotlin/example/BookRepository.kt
index 8e5e9f646d0..8437e37ce39 100644
--- a/doc-examples/jdbc-example-kotlin/src/main/kotlin/example/BookRepository.kt
+++ b/doc-examples/jdbc-example-kotlin/src/main/kotlin/example/BookRepository.kt
@@ -8,9 +8,7 @@ import io.micronaut.data.annotation.Id
import io.micronaut.data.annotation.Query
import io.micronaut.data.annotation.sql.Procedure
import io.micronaut.data.jdbc.annotation.JdbcRepository
-import io.micronaut.data.model.Page
-import io.micronaut.data.model.Pageable
-import io.micronaut.data.model.Slice
+import io.micronaut.data.model.*
import io.micronaut.data.model.query.builder.sql.Dialect
import io.micronaut.data.repository.CrudRepository
import jakarta.transaction.Transactional
@@ -50,6 +48,14 @@ interface BookRepository : CrudRepository { // <2>
fun list(pageable: Pageable): Slice
// end::pageable[]
+ // tag::cursored-pageable[]
+ fun find(pageable: CursoredPageable): CursoredPage // <1>
+
+ fun findByPagesBetween(minPageCount: Int, maxPageCount: Int, pageable: Pageable): CursoredPage // <2>
+
+ fun findByTitleStartingWith(title: String, pageable: Pageable): Page // <3>
+ // end::cursored-pageable[]
+
// tag::simple-projection[]
fun findTitleByPagesGreaterThan(pageCount: Int): List
// end::simple-projection[]
diff --git a/doc-examples/jdbc-example-kotlin/src/test/kotlin/example/BookRepositorySpec.kt b/doc-examples/jdbc-example-kotlin/src/test/kotlin/example/BookRepositorySpec.kt
index 5c66854cda2..ccc966bb4e4 100644
--- a/doc-examples/jdbc-example-kotlin/src/test/kotlin/example/BookRepositorySpec.kt
+++ b/doc-examples/jdbc-example-kotlin/src/test/kotlin/example/BookRepositorySpec.kt
@@ -2,14 +2,15 @@ package example
import io.micronaut.context.BeanContext
import io.micronaut.data.annotation.Query
+import io.micronaut.data.model.CursoredPageable
import io.micronaut.data.model.Pageable
+import io.micronaut.data.model.Sort
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
+import jakarta.inject.Inject
import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.util.*
-import jakarta.inject.Inject
-import org.junit.jupiter.api.BeforeAll
-import org.junit.jupiter.api.BeforeEach
@MicronautTest
class BookRepositorySpec {
@@ -129,6 +130,49 @@ class BookRepositorySpec {
assertEquals(1, results.size)
}
+ @Test
+ fun testCursoredPageable() {
+ bookRepository.saveAll(
+ Arrays.asList(
+ Book(0, "The Stand", 1000),
+ Book(0, "The Shining", 600),
+ Book(0, "The Power of the Dog", 500),
+ Book(0, "The Border", 700),
+ Book(0, "Along Came a Spider", 300),
+ Book(0, "Pet Cemetery", 400),
+ Book(0, "A Game of Thrones", 900),
+ Book(0, "A Clash of Kings", 1100)
+ )
+ )
+
+ // tag::cursored-pageable[]
+ val page = // <1>
+ bookRepository.find(CursoredPageable.from(5, Sort.of(Sort.Order.asc("title"))))
+ val page2 = bookRepository.find(page.nextPageable()) // <2>
+ val pageByPagesBetween = // <3>
+ bookRepository.findByPagesBetween(400, 700, Pageable.from(0, 3))
+ val pageByTitleStarts = // <4>
+ bookRepository.findByTitleStartingWith("The", CursoredPageable.from(3, Sort.unsorted()))
+ // end::cursored-pageable[]
+
+ assertEquals(
+ 5,
+ page.numberOfElements
+ )
+ assertEquals(
+ 3,
+ page2.numberOfElements
+ )
+ assertEquals(
+ 3,
+ pageByPagesBetween.numberOfElements
+ )
+ assertEquals(
+ 3,
+ pageByTitleStarts.numberOfElements
+ )
+ }
+
@Test
fun testDto() {
bookRepository.save(Book(0, "The Shining", 400))
diff --git a/src/main/docs/guide/shared/querying/cursored-pagination.adoc b/src/main/docs/guide/shared/querying/cursored-pagination.adoc
new file mode 100644
index 00000000000..fa370ac7ba9
--- /dev/null
+++ b/src/main/docs/guide/shared/querying/cursored-pagination.adoc
@@ -0,0 +1,22 @@
+Micronaut Data includes the ability to specify cursored pagination with the api:data.model.CursoredPageable[] type.
+For cursored page methods return a api:data.model.CursoredPage[] type (inspired by https://jakarta.ee/specifications/data/1.0/apidocs/jakarta.data/jakarta/data/page/cursoredpage[CursoredPage] in Jakarta Data).
+
+WARNING: Cursored pagination is currently only supported with Micronaut Data JDBC and R2DBC.
+
+The following are some example signatures:
+
+snippet::example.BookRepository[project-base="doc-examples/jdbc-example", source="main", tags="cursored-pageable", indent="0"]
+
+<1> The signature defines a api:data.model.CursoredPageable[] parameter and api:data.model.CursoredPage[] return type.
+<2> The signature of method defines a api:data.model.CursoredPage[] return type, therefore method will throw an error if the request is not for the first page or is not cursored.
+<3> The method will return a api:data.model.CursoredPage[] only whenever a api:data.model.CursoredPageable[] is supplied.
+
+Therefore, you can use the repository methods to retrieve data with cursored pagination using the following queries:
+
+snippet::example.BookRepositorySpec[project-base="doc-examples/jdbc-example", tags="cursored-pageable", indent="0"]
+<1> Create a cursored pageable with a desired size and sorting and get a cursored page.
+<2> Get the next cursored pageable by calling `CursoredPage.getNextPageable()`.
+<3> Request first cursored page.
+<4> Supply a `CursoredPageable` to the repository method and a `CursoredPage` will be returned.
+
+NOTE: The cursor of pagination is based on the supplied sorting. If the supplied api:data.model.Sort[] in pageable does not produce a unique sorting, Micronaut Data internally will additionally sort by the identity column and extend the cursor with the column value to make sure pagination works correctly.
diff --git a/src/main/docs/guide/toc.yml b/src/main/docs/guide/toc.yml
index 1442c54cef4..ff890a04d48 100644
--- a/src/main/docs/guide/toc.yml
+++ b/src/main/docs/guide/toc.yml
@@ -15,6 +15,7 @@ shared:
title: Writing Queries
criteria: Query Criteria
pagination: Pagination
+ cursored-pagination: Cursored Pagination
ordering: Ordering
projections: Query Projections
dto: DTO Projections