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 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 q = session.createQuery(query); @@ -92,6 +92,10 @@ protected Publisher interceptPublisher(RepositoryMethodKey methodKey, MethodI q.setMaxResults(pageable.getSize()); return q.getResultList(); }).flatMap(results -> { + if (!pageable.requestTotal()) { + return Mono.just(Page.of(results, pageable, null)); + } + final CriteriaQuery countQuery = criteriaBuilder.createQuery(Long.class); final Root countRoot = countQuery.from(rootEntity); final Predicate countPredicate = specification != null ? specification.toPredicate(countRoot, countQuery, criteriaBuilder) : null; diff --git a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java index 7bd6b050707..34a115a8b28 100644 --- a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java +++ b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java @@ -733,7 +733,7 @@ public Stream findStream(@NonNull PagedQuery query) { @Override public Page findPage(@NonNull PagedQuery query) { - throw new UnsupportedOperationException("The findPage method without an explicit query is not supported. Use findPage(PreparedQuery) instead"); + throw new UnsupportedOperationException("The findPage method without an explicit query is not supported. Use findAll(PreparedQuery) instead"); } @Override diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/h2/H2CursoredPaginationSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/h2/H2CursoredPaginationSpec.groovy new file mode 100644 index 00000000000..a27d646621f --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/h2/H2CursoredPaginationSpec.groovy @@ -0,0 +1,50 @@ +/* + * 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.jdbc.h2 + +import io.micronaut.data.tck.repositories.BookRepository +import io.micronaut.data.tck.repositories.PersonRepository +import io.micronaut.data.tck.tests.AbstractCursoredPageSpec +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import spock.lang.Shared + +@MicronautTest +@H2DBProperties +class H2CursoredPaginationSpec extends AbstractCursoredPageSpec { + @Inject + @Shared + H2PersonRepository pr + + @Inject + @Shared + H2BookRepository br + + @Override + PersonRepository getPersonRepository() { + return pr + } + + @Override + BookRepository getBookRepository() { + return br + } + + @Override + void init() { + pr.deleteAll() + } +} diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MysqlCursoredPaginationSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MysqlCursoredPaginationSpec.groovy new file mode 100644 index 00000000000..e65c34e7d1b --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/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.jdbc.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-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/OracleXECursoredPaginationSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/OracleXECursoredPaginationSpec.groovy new file mode 100644 index 00000000000..6bb64ef6a27 --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/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.jdbc.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 OracleTestPropertyProvider { + + @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-jdbc/src/test/groovy/io/micronaut/data/jdbc/postgres/PostgresCursoredPaginationSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/postgres/PostgresCursoredPaginationSpec.groovy new file mode 100644 index 00000000000..214b741c505 --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/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.jdbc.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-jdbc/src/test/groovy/io/micronaut/data/jdbc/sqlserver/SqlServerCursoredPaginationSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/sqlserver/SqlServerCursoredPaginationSpec.groovy new file mode 100644 index 00000000000..dc13c07c9cb --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/sqlserver/SqlServerCursoredPaginationSpec.groovy @@ -0,0 +1,43 @@ +/* + * 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.jdbc.sqlserver + +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 SqlServerCursoredPaginationSpec extends AbstractCursoredPageSpec implements MSSQLTestPropertyProvider { + + @Shared @AutoCleanup ApplicationContext context + + @Override + PersonRepository getPersonRepository() { + return context.getBean(MSSQLPersonRepository) + } + + @Override + BookRepository getBookRepository() { + return context.getBean(MSBookRepository) + } + + @Override + void init() { + context = ApplicationContext.run(properties) + } +} diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/sqlserver/SqlServerPaginationSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/sqlserver/SqlServerPaginationSpec.groovy index 30bc7f46789..faf4bb6f0f8 100644 --- a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/sqlserver/SqlServerPaginationSpec.groovy +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/sqlserver/SqlServerPaginationSpec.groovy @@ -15,6 +15,7 @@ */ package io.micronaut.data.jdbc.sqlserver +import groovy.transform.Memoized import io.micronaut.context.ApplicationContext import io.micronaut.data.tck.repositories.BookRepository import io.micronaut.data.tck.repositories.PersonRepository @@ -26,11 +27,13 @@ class SqlServerPaginationSpec extends AbstractPageSpec implements MSSQLTestPrope @Shared @AutoCleanup ApplicationContext context + @Memoized @Override PersonRepository getPersonRepository() { return context.getBean(MSSQLPersonRepository) } + @Memoized @Override BookRepository getBookRepository() { return context.getBean(MSBookRepository) diff --git a/data-jpa/src/main/java/io/micronaut/data/jpa/repository/intercept/FindPageSpecificationInterceptor.java b/data-jpa/src/main/java/io/micronaut/data/jpa/repository/intercept/FindPageSpecificationInterceptor.java index 505c2476045..ad6d1b4b0b6 100644 --- a/data-jpa/src/main/java/io/micronaut/data/jpa/repository/intercept/FindPageSpecificationInterceptor.java +++ b/data-jpa/src/main/java/io/micronaut/data/jpa/repository/intercept/FindPageSpecificationInterceptor.java @@ -96,29 +96,33 @@ public Page intercept(RepositoryMethodKey methodKey, MethodInvocationContext results = typedQuery.getResultList(); - final CriteriaQuery countQuery = criteriaBuilder.createQuery(Long.class); - final Root countRoot = countQuery.from(rootEntity); - final Predicate countPredicate = specification != null ? specification.toPredicate(countRoot, countQuery, criteriaBuilder) : null; - if (countPredicate != null) { - countQuery.where(countPredicate); - } - if (countQuery.isDistinct()) { - countQuery.select(criteriaBuilder.countDistinct(countRoot)); - } else { - countQuery.select(criteriaBuilder.count(countRoot)); + + Long totalCount = null; + if (pageable.requestTotal()) { + final CriteriaQuery countQuery = criteriaBuilder.createQuery(Long.class); + final Root countRoot = countQuery.from(rootEntity); + final Predicate countPredicate = specification != null ? specification.toPredicate(countRoot, countQuery, criteriaBuilder) : null; + if (countPredicate != null) { + countQuery.where(countPredicate); + } + if (countQuery.isDistinct()) { + countQuery.select(criteriaBuilder.countDistinct(countRoot)); + } else { + countQuery.select(criteriaBuilder.count(countRoot)); + } + totalCount = entityManager.createQuery(countQuery).getSingleResult(); } - Long singleResult = entityManager.createQuery(countQuery).getSingleResult(); return Page.of( results, pageable, - singleResult + totalCount ); } diff --git a/data-model/src/main/java/io/micronaut/data/annotation/RepositoryConfiguration.java b/data-model/src/main/java/io/micronaut/data/annotation/RepositoryConfiguration.java index 24bcb44b8d9..268a146319c 100644 --- a/data-model/src/main/java/io/micronaut/data/annotation/RepositoryConfiguration.java +++ b/data-model/src/main/java/io/micronaut/data/annotation/RepositoryConfiguration.java @@ -15,6 +15,7 @@ */ package io.micronaut.data.annotation; +import io.micronaut.data.model.CursoredPage; import io.micronaut.data.model.Page; import io.micronaut.data.model.Pageable; import io.micronaut.data.model.Slice; @@ -59,7 +60,8 @@ TypeRole[] typeRoles() default { @TypeRole(role = TypeRole.PAGEABLE, type = Pageable.class), @TypeRole(role = TypeRole.SORT, type = Sort.class), @TypeRole(role = TypeRole.SLICE, type = Slice.class), - @TypeRole(role = TypeRole.PAGE, type = Page.class) + @TypeRole(role = TypeRole.PAGE, type = Page.class), + @TypeRole(role = TypeRole.CURSORED_PAGE, type = CursoredPage.class) }; /** diff --git a/data-model/src/main/java/io/micronaut/data/annotation/TypeRole.java b/data-model/src/main/java/io/micronaut/data/annotation/TypeRole.java index efaa389a871..810510209a0 100644 --- a/data-model/src/main/java/io/micronaut/data/annotation/TypeRole.java +++ b/data-model/src/main/java/io/micronaut/data/annotation/TypeRole.java @@ -75,6 +75,11 @@ */ String PAGE = "page"; + /** + * The parameter that is used to represent a {@link io.micronaut.data.model.CursoredPage}. + */ + String CURSORED_PAGE = "cursoredPage"; + /** * The name of the role. * @return The role name diff --git a/data-model/src/main/java/io/micronaut/data/intercept/FindCursoredPageInterceptor.java b/data-model/src/main/java/io/micronaut/data/intercept/FindCursoredPageInterceptor.java new file mode 100644 index 00000000000..3f287398ab0 --- /dev/null +++ b/data-model/src/main/java/io/micronaut/data/intercept/FindCursoredPageInterceptor.java @@ -0,0 +1,27 @@ +/* + * 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.intercept; + +/** + * An interceptor that handles a return type of {@link io.micronaut.data.model.CursoredPage}. + * + * @author Andriy Dmytruk + * @param The declaring type + * @param The return type + * @since 4.8.0 + */ +public interface FindCursoredPageInterceptor extends DataInterceptor { +} diff --git a/data-model/src/main/java/io/micronaut/data/model/CursoredPage.java b/data-model/src/main/java/io/micronaut/data/model/CursoredPage.java new file mode 100644 index 00000000000..5d2b63df38c --- /dev/null +++ b/data-model/src/main/java/io/micronaut/data/model/CursoredPage.java @@ -0,0 +1,182 @@ +/* + * 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.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import io.micronaut.context.annotation.DefaultImplementation; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.annotation.ReflectiveAccess; +import io.micronaut.core.annotation.TypeHint; +import io.micronaut.data.model.Pageable.Cursor; +import io.micronaut.data.model.Pageable.Mode; +import io.micronaut.serde.annotation.Serdeable; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Inspired by the Jakarta's {@code CursoredPage}, this models a type that supports + * pagination operations with cursors. + * + *

A CursoredPage is a result set associated with a particular {@link Pageable} that includes + * a calculation of the total size of page of records.

+ * + * @param The generic type + * @author Andriy Dmytruk + * @since 4.8.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@TypeHint(CursoredPage.class) +@JsonDeserialize(as = DefaultCursoredPage.class) +@Serdeable +@DefaultImplementation(DefaultCursoredPage.class) +public interface CursoredPage extends Page { + + CursoredPage EMPTY = new DefaultCursoredPage<>(Collections.emptyList(), Pageable.unpaged(), Collections.emptyList(), null); + + /** + * @return Whether this {@link CursoredPage} 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(); + + /** + * 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() { + Pageable pageable = getPageable(); + if (pageable.getMode() == Mode.CURSOR_NEXT) { + return getContent().size() == pageable.getSize(); + } else { + return true; + } + } + + @Override + default boolean hasPrevious() { + Pageable pageable = getPageable(); + if (pageable.getMode() == Mode.CURSOR_PREVIOUS) { + return getContent().size() == pageable.getSize(); + } else { + return true; + } + } + + @Override + default CursoredPageable nextPageable() { + Pageable pageable = getPageable(); + Cursor cursor = getCursor(getCursors().size() - 1).orElse(pageable.cursor().orElse(null)); + return Pageable.afterCursor(cursor, pageable.getNumber() + 1, pageable.getSize(), pageable.getSort()); + } + + @Override + default CursoredPageable previousPageable() { + Pageable pageable = getPageable(); + Cursor cursor = getCursor(0).orElse(pageable.cursor().orElse(null)); + return Pageable.beforeCursor(cursor, Math.max(0, pageable.getNumber() - 1), pageable.getSize(), pageable.getSort()); + } + + + /** + * Maps the content with the given function. + * + * @param function The function to apply to each element in the content. + * @param The type returned by the function + * @return A new slice with the mapped content + */ + @Override + default @NonNull CursoredPage map(Function function) { + List content = getContent().stream().map(function).collect(Collectors.toList()); + return new DefaultCursoredPage<>(content, getPageable(), getCursors(), getTotalSize()); + } + + /** + * Creates a cursored page from the given content, pageable, cursors and totalSize. + * + * @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 + @ReflectiveAccess + static @NonNull CursoredPage of( + @JsonProperty("content") @NonNull List content, + @JsonProperty("pageable") @NonNull Pageable pageable, + @JsonProperty("cursors") @Nullable List cursors, + @JsonProperty("totalSize") @Nullable Long totalSize + ) { + return new DefaultCursoredPage<>(content, pageable, cursors, totalSize); + } + + /** + * Get cursor at the given position or empty if no such cursor exists. + * There must be a cursor for each of the data entities in the same order. + * To start pagination after or before a cursor create a pageable from it using the + * same sorting as before. + * + * @param i The index of cursor to retrieve. + * @return The cursor at the provided index. + */ + Optional getCursor(int i); + + /** + * Get all the cursors. + * + * @see #getCursor(int) getCursor(i) for more details. + * @return All the cursors + */ + List getCursors(); + + /** + * Creates an empty page object. + * @param The generic type + * @return The slice + */ + @SuppressWarnings("unchecked") + static @NonNull CursoredPage empty() { + return (CursoredPage) EMPTY; + } + +} diff --git a/data-model/src/main/java/io/micronaut/data/model/CursoredPageable.java b/data-model/src/main/java/io/micronaut/data/model/CursoredPageable.java new file mode 100644 index 00000000000..53bef1495bb --- /dev/null +++ b/data-model/src/main/java/io/micronaut/data/model/CursoredPageable.java @@ -0,0 +1,109 @@ +/* + * 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.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +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; + +/** + * Models a pageable request that uses a cursor. + * + * @author Andriy Dmytruk + * @since 4.8.0 + */ +@Serdeable +@Introspected +@JsonIgnoreProperties(ignoreUnknown = true) +public interface CursoredPageable extends Pageable { + + /** + * Whether the pageable is traversing backwards. + * + * @return Whether currentCursor is going in reverse direction. + */ + boolean isBackward(); + + @Override + default Mode getMode() { + return isBackward() ? Mode.CURSOR_PREVIOUS : Mode.CURSOR_NEXT; + } + + /** + * Creates a new {@link CursoredPageable} with the given sort. + * + * @param sort The sort + * @return The pageable + */ + static @NonNull CursoredPageable from(Sort sort) { + if (sort == null) { + sort = Sort.UNSORTED; + } + return new DefaultCursoredPageable( + -1, null, Mode.CURSOR_NEXT, 0, sort, true + ); + } + + /** + * Creates a new {@link CursoredPageable} with the given sort and page size. + * + * @param size The page size + * @param sort The sort + * @return The pageable + */ + static @NonNull CursoredPageable from( + @JsonProperty("size") int size, + @JsonProperty("sort") @Nullable Sort sort + ) { + if (sort == null) { + sort = UNSORTED; + } + return new DefaultCursoredPageable(size, null, Mode.CURSOR_NEXT, 0, sort, true); + } + + /** + * Creates a new {@link CursoredPageable} with the given currentCursor. + * + * @param page The page + * @param cursor The current currentCursor that will be used for querying data. + * @param mode The pagination mode. Must be either forward or backward currentCursor pagination. + * @param size The page size + * @param sort The sort + * @param requestTotal Whether to request total count + * @return The pageable + */ + @Internal + @JsonCreator + static @NonNull CursoredPageable from( + @JsonProperty("number") int page, + @Nullable Cursor cursor, + Pageable.Mode mode, + int size, + @Nullable Sort sort, + boolean requestTotal + ) { + if (sort == null) { + sort = UNSORTED; + } + return new DefaultCursoredPageable(size, cursor, mode, page, sort, requestTotal); + } + +} diff --git a/data-model/src/main/java/io/micronaut/data/model/DefaultCursoredPage.java b/data-model/src/main/java/io/micronaut/data/model/DefaultCursoredPage.java new file mode 100644 index 00000000000..423eca438e2 --- /dev/null +++ b/data-model/src/main/java/io/micronaut/data/model/DefaultCursoredPage.java @@ -0,0 +1,102 @@ +/* + * 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.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.micronaut.core.annotation.Creator; +import io.micronaut.core.annotation.ReflectiveAccess; +import io.micronaut.data.model.Pageable.Cursor; +import io.micronaut.serde.annotation.Serdeable; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Implementation of {@link Page} to return when {@link CursoredPageable} is requested. + * + * @author Andriy Dmytruk + * @since 4.8.0 + * @param The generic type + */ +@Serdeable +class DefaultCursoredPage extends DefaultPage implements CursoredPage { + + private final List cursors; + + /** + * Default constructor. + * @param content The content + * @param pageable The pageable + * @param totalSize The total size + */ + @JsonCreator + @Creator + @ReflectiveAccess + DefaultCursoredPage( + @JsonProperty("content") + List content, + @JsonProperty("pageable") + Pageable pageable, + @JsonProperty("cursors") + List cursors, + @JsonProperty("totalSize") + Long totalSize + ) { + super(content, pageable, totalSize); + if (content.size() != cursors.size()) { + throw new IllegalArgumentException("The number of cursors must match the number of content items for a page"); + } + this.cursors = cursors; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof DefaultCursoredPage that)) { + return false; + } + return Objects.equals(cursors, that.cursors) && super.equals(o); + } + + @Override + public Optional getCursor(int i) { + return i >= cursors.size() || i < 0 ? Optional.empty() : Optional.of(cursors.get(i)); + } + + @Override + public List getCursors() { + return cursors; + } + + @Override + public int hashCode() { + return Objects.hash(cursors, super.hashCode()); + } + + @Override + public String toString() { + return "DefaultPage{" + + "totalSize=" + getTotalSize() + + ",content=" + getContent() + + ",pageable=" + getPageable() + + ",cursors=" + cursors + + '}'; + } +} diff --git a/data-model/src/main/java/io/micronaut/data/model/DefaultCursoredPageable.java b/data-model/src/main/java/io/micronaut/data/model/DefaultCursoredPageable.java new file mode 100644 index 00000000000..c7f30f8bcde --- /dev/null +++ b/data-model/src/main/java/io/micronaut/data/model/DefaultCursoredPageable.java @@ -0,0 +1,172 @@ +/* + * 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.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.micronaut.core.annotation.Creator; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.serde.annotation.Serdeable; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * The default cursored pageable implementation. + * + * @param page The page. + * @param currentCursor The current currentCursor. This is the currentCursor that will be used for pagination + * in case this pageable is used in a query. + * @param mode The pagination mode. Could be one of {@link Mode#CURSOR_NEXT} or {@link Mode#CURSOR_PREVIOUS}. + * @param size The size of a page + * @param sort The sorting + * @param requestTotal Whether to request the total count + * + * @author Andriy Dmytruk + * @since 4.8.0 + */ +@Serdeable +record DefaultCursoredPageable( + int size, + @Nullable + @JsonProperty("cursor") + Cursor currentCursor, + Mode mode, + @JsonProperty("number") int page, + Sort sort, + boolean requestTotal +) implements CursoredPageable { + + /** + * Default constructor. + */ + @Creator + DefaultCursoredPageable { + if (page < 0) { + throw new IllegalArgumentException("Page index cannot be negative"); + } + if (size == 0) { + throw new IllegalArgumentException("Size cannot be 0"); + } + if (mode != Mode.CURSOR_NEXT && mode != Mode.CURSOR_PREVIOUS) { + throw new IllegalArgumentException("The pagination mode must be either currentCursor forward or currentCursor backward"); + } + } + + @Override + public int getSize() { + return size; + } + + @Override + public Optional cursor() { + return Optional.ofNullable(currentCursor); + } + + @Override + public int getNumber() { + return page; + } + + @NonNull + @Override + public Sort getSort() { + return sort; + } + + @Override + public boolean isBackward() { + return mode == Mode.CURSOR_PREVIOUS; + } + + @Override + public CursoredPageable next() { + throw new UnsupportedOperationException("To get next pageable results must be retrieved. Use page.nextPageable() to retrieve the next pageable."); + } + + @Override + public CursoredPageable previous() { + throw new UnsupportedOperationException("To get next pageable results must be retrieved. Use page.nextPageable() to retrieve the next pageable."); + + } + + @Override + public Pageable withTotal() { + if (requestTotal) { + return this; + } + return new DefaultCursoredPageable(size, currentCursor, mode, page, sort, true); + } + + @Override + public Pageable withoutTotal() { + if (!requestTotal) { + return this; + } + return new DefaultCursoredPageable(size, currentCursor, mode, page, sort, true); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof DefaultCursoredPageable that)) { + return false; + } + return size == that.size + && Objects.equals(currentCursor, that.currentCursor) + && Objects.equals(mode, that.mode) + && Objects.equals(sort, that.sort); + } + + @Override + public int hashCode() { + return Objects.hash(size, currentCursor, mode, sort); + } + + @Override + public String toString() { + return "DefaultCursoredPageable{" + + "size=" + size + + ", page=" + page + + ", currentCursor=" + currentCursor + + ", mode=" + mode + + ", sort=" + sort + + '}'; + } + + /** + * Default implementation of the {@link Cursor}. + * + * @param elements The currentCursor elements + */ + @Serdeable + record DefaultCursor( + List elements + ) implements Cursor { + @Override + public Object get(int index) { + return elements.get(index); + } + + @Override + public int size() { + return elements.size(); + } + } +} diff --git a/data-model/src/main/java/io/micronaut/data/model/DefaultPage.java b/data-model/src/main/java/io/micronaut/data/model/DefaultPage.java index a86f3757512..1845a899b6c 100644 --- a/data-model/src/main/java/io/micronaut/data/model/DefaultPage.java +++ b/data-model/src/main/java/io/micronaut/data/model/DefaultPage.java @@ -34,7 +34,7 @@ @Serdeable class DefaultPage extends DefaultSlice implements Page { - private final long totalSize; + private final Long totalSize; /** * Default constructor. @@ -51,14 +51,23 @@ class DefaultPage extends DefaultSlice implements Page { @JsonProperty("pageable") Pageable pageable, @JsonProperty("totalSize") - long totalSize) { + Long totalSize) { super(content, pageable); this.totalSize = totalSize; } + @Override + public boolean hasTotalSize() { + return totalSize != null; + } + @Override @ReflectiveAccess public long getTotalSize() { + if (totalSize == null) { + throw new IllegalStateException("Page does not contain total count. " + + "It is likely that the Pageable needs to be modified to request this information."); + } return totalSize; } diff --git a/data-model/src/main/java/io/micronaut/data/model/DefaultPageable.java b/data-model/src/main/java/io/micronaut/data/model/DefaultPageable.java index b4d8b877e3d..383335cf56c 100644 --- a/data-model/src/main/java/io/micronaut/data/model/DefaultPageable.java +++ b/data-model/src/main/java/io/micronaut/data/model/DefaultPageable.java @@ -21,6 +21,7 @@ import io.micronaut.core.annotation.NonNull; import java.util.Objects; +import java.util.Optional; /** * The default pageable implementation. @@ -31,6 +32,7 @@ @Introspected final class DefaultPageable implements Pageable { + private final boolean requestTotal; private final int max; private final int number; private final Sort sort; @@ -43,7 +45,7 @@ final class DefaultPageable implements Pageable { * @param sort The sort */ @Creator - DefaultPageable(int page, int size, @Nullable Sort sort) { + DefaultPageable(int page, int size, @Nullable Sort sort, @Nullable Boolean requestTotal) { if (page < 0) { throw new IllegalArgumentException("Page index cannot be negative"); } @@ -53,6 +55,7 @@ final class DefaultPageable implements Pageable { this.max = size; this.number = page; this.sort = sort == null ? Sort.unsorted() : sort; + this.requestTotal = requestTotal == null || requestTotal; } @Override @@ -65,12 +68,38 @@ public int getNumber() { return number; } + @Override + public Optional cursor() { + return Optional.empty(); + } + + @Override + public boolean requestTotal() { + return requestTotal; + } + @NonNull @Override public Sort getSort() { return sort; } + @Override + public Pageable withTotal() { + if (this.requestTotal) { + return this; + } + return new DefaultPageable(number, max, sort, true); + } + + @Override + public Pageable withoutTotal() { + if (!this.requestTotal) { + return this; + } + return new DefaultPageable(number, max, sort, false); + } + @Override public boolean equals(Object o) { if (this == o) { @@ -93,7 +122,7 @@ public int hashCode() { public String toString() { return "DefaultPageable{" + "max=" + max + - ", number=" + number + + ", page=" + number + ", sort=" + sort + '}'; } diff --git a/data-model/src/main/java/io/micronaut/data/model/DefaultSort.java b/data-model/src/main/java/io/micronaut/data/model/DefaultSort.java index 063cc9370c7..2f685a30f7d 100644 --- a/data-model/src/main/java/io/micronaut/data/model/DefaultSort.java +++ b/data-model/src/main/java/io/micronaut/data/model/DefaultSort.java @@ -96,6 +96,11 @@ public DefaultSort order(@NonNull String propertyName, @NonNull Order.Direction return order(new Order(propertyName, direction, false)); } + @Override + public String toString() { + return "DefaultSort{orderBy=" + orderBy + '}'; + } + @Override public boolean equals(Object o) { if (this == o) { diff --git a/data-model/src/main/java/io/micronaut/data/model/Page.java b/data-model/src/main/java/io/micronaut/data/model/Page.java index 7f8181d1dfc..1f817ceb811 100644 --- a/data-model/src/main/java/io/micronaut/data/model/Page.java +++ b/data-model/src/main/java/io/micronaut/data/model/Page.java @@ -20,9 +20,12 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import io.micronaut.context.annotation.DefaultImplementation; +import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; import io.micronaut.core.annotation.ReflectiveAccess; import io.micronaut.core.annotation.TypeHint; +import io.micronaut.data.model.Pageable.Cursor; import io.micronaut.serde.annotation.Serdeable; import java.util.Collections; @@ -34,7 +37,7 @@ * pagination operations. * *

A Page is a result set associated with a particular {@link Pageable} that includes a calculation of the total - * size of number of records.

+ * size of page of records.

* * @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 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 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