diff --git a/hibernate-core/src/main/java/org/hibernate/query/KeyedPage.java b/hibernate-core/src/main/java/org/hibernate/query/KeyedPage.java index 8942fa97fc..1e1fffdbba 100644 --- a/hibernate-core/src/main/java/org/hibernate/query/KeyedPage.java +++ b/hibernate-core/src/main/java/org/hibernate/query/KeyedPage.java @@ -12,6 +12,9 @@ import org.hibernate.Internal; import java.util.List; import static java.util.Collections.unmodifiableList; +import static org.hibernate.query.KeyedPage.KeyInterpretation.KEY_OF_FIRST_ON_NEXT_PAGE; +import static org.hibernate.query.KeyedPage.KeyInterpretation.KEY_OF_LAST_ON_PREVIOUS_PAGE; +import static org.hibernate.query.KeyedPage.KeyInterpretation.NO_KEY; /** * Support for pagination based on a unique key of the result @@ -49,15 +52,17 @@ public class KeyedPage { private final List> keyDefinition; private final Page page; private final List> key; + private final KeyInterpretation keyInterpretation; KeyedPage(List> keyDefinition, Page page) { - this( keyDefinition, page, null ); + this( keyDefinition, page, null, NO_KEY ); } - KeyedPage(List> keyDefinition, Page page, List> key) { - this.page = page; + KeyedPage(List> keyDefinition, Page page, List> key, KeyInterpretation interpretation) { this.keyDefinition = unmodifiableList(keyDefinition); + this.page = page; this.key = key; + this.keyInterpretation = interpretation; } /** @@ -78,36 +83,76 @@ public class KeyedPage { } /** - * The key of the last result on the previous page, which is used - * to locate the start of the current page. + * The key of the last result on the previous page, or of the + * first result on the next page, which may be used to locate + * the start or end, respectively, of the current page. *

* A null key indicates that an {@linkplain Page#getFirstResult() * offset} should be used instead. This is used to obtain an * initial page of results. * - * @return the key of the last result on the previous page, or - * null if an offset should be used to obtain an initial - * page + * @return the key, or null if an offset should be used */ public List> getKey() { return key; } + /** + * Determines whether the {@link #getKey() key} should be + * interpreted as the last result on the previous page, or + * as the first result on the next page. + */ + public KeyInterpretation getKeyInterpretation() { + return keyInterpretation; + } + /** * Obtain a specification of the next page of results, which is * to be located using the given key, which must be the key of * the last result on this page. * - * @param keyOfLastResult the key of the last result on this page + * @param keyOfLastResultOnThisPage the key of the last result on this page * @return a {@link KeyedPage} representing the next page of results */ @Internal - public KeyedPage nextPage(List> keyOfLastResult) { - return new KeyedPage<>( keyDefinition, page.next(), keyOfLastResult ); + public KeyedPage nextPage(List> keyOfLastResultOnThisPage) { + return new KeyedPage<>( keyDefinition, page.next(), keyOfLastResultOnThisPage, KEY_OF_LAST_ON_PREVIOUS_PAGE ); } + /** + * Obtain a specification of the previous page of results, which + * is to be located using the given key, which must be the key of + * the first result on this page. + * + * @param keyOfFirstResultOnThisPage the key of the first result on this page + * @return a {@link KeyedPage} representing the next page of results + */ @Internal - public KeyedPage withKey(List> keyOfLastResult) { - return new KeyedPage<>( keyDefinition, page, keyOfLastResult ); + public KeyedPage previousPage(List> keyOfFirstResultOnThisPage) { + if ( page.isFirst() ) { + return null; + } + else { + return new KeyedPage<>( keyDefinition, page.previous(), keyOfFirstResultOnThisPage, KEY_OF_FIRST_ON_NEXT_PAGE ); + } + } + + /** + * Attach the given key to the specification of this page, + * with the given interpretation. + * + * @return a {@link KeyedPage} representing the same page + * of results, but which may be located using the + * given key + */ + @Internal + public KeyedPage withKey(List> key, KeyInterpretation interpretation) { + return new KeyedPage<>( keyDefinition, page, key, interpretation ); + } + + public enum KeyInterpretation { + KEY_OF_LAST_ON_PREVIOUS_PAGE, + KEY_OF_FIRST_ON_NEXT_PAGE, + NO_KEY } } diff --git a/hibernate-core/src/main/java/org/hibernate/query/KeyedResultList.java b/hibernate-core/src/main/java/org/hibernate/query/KeyedResultList.java index c6e6e96d91..69df542493 100644 --- a/hibernate-core/src/main/java/org/hibernate/query/KeyedResultList.java +++ b/hibernate-core/src/main/java/org/hibernate/query/KeyedResultList.java @@ -30,12 +30,16 @@ public class KeyedResultList { private final List> keyList; private final KeyedPage page; private final KeyedPage nextPage; + private final KeyedPage previousPage; - public KeyedResultList(List resultList, List> keyList, KeyedPage page, KeyedPage nextPage) { + public KeyedResultList( + List resultList, List> keyList, + KeyedPage page, KeyedPage nextPage, KeyedPage previousPage) { this.resultList = resultList; this.keyList = keyList; this.page = page; this.nextPage = nextPage; + this.previousPage = previousPage; } /** @@ -71,6 +75,15 @@ public class KeyedResultList { return nextPage; } + /** + * The specification of the previous page of results, + * or {@code null} if it is known that this is the + * first page. + */ + public KeyedPage getPreviousPage() { + return previousPage; + } + /** * @return {@code true} if this is known to be the * last page of results. diff --git a/hibernate-core/src/main/java/org/hibernate/query/Order.java b/hibernate-core/src/main/java/org/hibernate/query/Order.java index c386835f1a..3f0f60430a 100644 --- a/hibernate-core/src/main/java/org/hibernate/query/Order.java +++ b/hibernate-core/src/main/java/org/hibernate/query/Order.java @@ -9,8 +9,11 @@ package org.hibernate.query; import jakarta.persistence.metamodel.SingularAttribute; import org.hibernate.Incubating; +import java.util.List; import java.util.Objects; +import static java.util.stream.Collectors.toList; + /** * A rule for sorting a query result set. *

@@ -92,6 +95,16 @@ public class Order { this.ignoreCase = ignoreCase; } + private Order(Order other, SortDirection order) { + this.order = order; + this.attribute = other.attribute; + this.entityClass = other.entityClass; + this.attributeName = other.attributeName; + this.nullPrecedence = other.nullPrecedence; + this.element = other.element; + this.ignoreCase = other.ignoreCase; + } + public static Order asc(SingularAttribute attribute) { return new Order<>(SortDirection.ASCENDING, NullPrecedence.NONE, attribute); } @@ -176,6 +189,10 @@ public class Order { return element; } + public Order reverse() { + return new Order<>( this, order.reverse() ); + } + @Override public String toString() { return attributeName + " " + order; @@ -200,4 +217,18 @@ public class Order { public int hashCode() { return Objects.hash( order, element, attributeName, entityClass ); } + + /** + * Reverse the direction of the given ordering list + * + * @param ordering a list of {@code Order} items + * @return a new list, with each {@code Order} reversed + * + * @see #reverse() + * + * @since 6.5 + */ + public static List> reverse(List> ordering) { + return ordering.stream().map(Order::reverse).collect(toList()); + } } diff --git a/hibernate-core/src/main/java/org/hibernate/query/Page.java b/hibernate-core/src/main/java/org/hibernate/query/Page.java index 3f97c051a1..e3eacb12bb 100644 --- a/hibernate-core/src/main/java/org/hibernate/query/Page.java +++ b/hibernate-core/src/main/java/org/hibernate/query/Page.java @@ -87,6 +87,9 @@ public class Page { } public KeyedPage keyedBy(Order keyDefinition) { + if ( keyDefinition == null ) { + throw new IllegalArgumentException("Key definition must not null"); + } return new KeyedPage<>( List.of(keyDefinition), this ); } @@ -98,8 +101,8 @@ public class Page { * @return a {@link KeyedPage} representing this page */ public KeyedPage keyedBy(List> keyDefinition) { - if ( keyDefinition.isEmpty() ) { - throw new IllegalArgumentException("Key definition must not be empty"); + if ( keyDefinition == null || keyDefinition.isEmpty() ) { + throw new IllegalArgumentException("Key definition must not be empty or null"); } return new KeyedPage<>( keyDefinition, this ); } diff --git a/hibernate-core/src/main/java/org/hibernate/query/sqm/internal/AbstractSqmSelectionQuery.java b/hibernate-core/src/main/java/org/hibernate/query/sqm/internal/AbstractSqmSelectionQuery.java index d652f90af6..8142a0c71e 100644 --- a/hibernate-core/src/main/java/org/hibernate/query/sqm/internal/AbstractSqmSelectionQuery.java +++ b/hibernate-core/src/main/java/org/hibernate/query/sqm/internal/AbstractSqmSelectionQuery.java @@ -27,11 +27,11 @@ import org.hibernate.query.sqm.tree.SqmStatement; import org.hibernate.query.sqm.tree.select.SqmSelectStatement; import org.hibernate.sql.results.internal.TupleMetadata; -import java.util.ArrayList; import java.util.List; import static java.util.stream.Collectors.toList; import static org.hibernate.cfg.QuerySettings.FAIL_ON_PAGINATION_OVER_COLLECTION_FETCH; +import static org.hibernate.query.KeyedPage.KeyInterpretation.KEY_OF_FIRST_ON_NEXT_PAGE; import static org.hibernate.query.sqm.internal.KeyBasedPagination.paginate; import static org.hibernate.query.sqm.internal.KeyedResult.collectKeys; import static org.hibernate.query.sqm.internal.KeyedResult.collectResults; @@ -152,13 +152,15 @@ abstract class AbstractSqmSelectionQuery extends AbstractSelectionQuery { @Override public KeyedResultList getKeyedResultList(KeyedPage keyedPage) { if ( keyedPage == null ) { - throw new IllegalStateException( "KeyedPage was not null" ); + throw new IllegalArgumentException( "KeyedPage was null" ); } final Page page = keyedPage.getPage(); - final List> keyDefinition = keyedPage.getKeyDefinition(); final List> key = keyedPage.getKey(); + final List> keyDefinition = keyedPage.getKeyDefinition(); + final List> appliedKeyDefinition = + keyedPage.getKeyInterpretation() == KEY_OF_FIRST_ON_NEXT_PAGE + ? Order.reverse(keyDefinition) : keyDefinition; - setOrder( keyDefinition ); setMaxResults( page.getMaxResults() + 1 ); if ( key == null ) { setFirstResult( page.getFirstResult() ); @@ -166,21 +168,28 @@ abstract class AbstractSqmSelectionQuery extends AbstractSelectionQuery { // getQueryOptions().setQueryPlanCachingEnabled( false ); final List> results = - buildConcreteQueryPlan( paginateQuery( keyDefinition, key ), getQueryOptions() ) + buildConcreteQueryPlan( paginateQuery( appliedKeyDefinition, key ), getQueryOptions() ) .performList(this); return new KeyedResultList<>( collectResults( results, page.getSize() ), collectKeys( results, page.getSize() ), keyedPage, - getNextPage( keyedPage, results ) + nextPage( keyedPage, results ), + previousPage( keyedPage, results ) ); } - private static KeyedPage getNextPage(KeyedPage keyedPage, List> executed) { + private static KeyedPage nextPage(KeyedPage keyedPage, List> results) { final int pageSize = keyedPage.getPage().getSize(); - return executed.size() == pageSize + 1 - ? keyedPage.nextPage( executed.get(pageSize - 1).getKey() ) + return results.size() == pageSize + 1 + ? keyedPage.nextPage( results.get(pageSize - 1).getKey() ) + : null; + } + + private static KeyedPage previousPage(KeyedPage keyedPage, List> results) { + return !results.isEmpty() + ? keyedPage.previousPage( results.get(0).getKey() ) : null; } diff --git a/hibernate-core/src/main/java/org/hibernate/query/sqm/internal/KeyBasedPagination.java b/hibernate-core/src/main/java/org/hibernate/query/sqm/internal/KeyBasedPagination.java index f827126695..f1eb6eabab 100644 --- a/hibernate-core/src/main/java/org/hibernate/query/sqm/internal/KeyBasedPagination.java +++ b/hibernate-core/src/main/java/org/hibernate/query/sqm/internal/KeyBasedPagination.java @@ -25,6 +25,8 @@ import java.util.ArrayList; import java.util.List; import static java.util.Arrays.asList; +import static java.util.stream.Collectors.toList; +import static org.hibernate.query.sqm.internal.SqmUtil.sortSpecification; /** * Manipulation of SQM query tree for key-based pagination. @@ -35,20 +37,22 @@ public class KeyBasedPagination { static SqmSelectStatement> paginate( List> keyDefinition, List> keyValues, - SqmSelectStatement> sqm, NodeBuilder builder) { - final SqmQuerySpec querySpec = sqm.getQuerySpec(); + SqmSelectStatement> statement, NodeBuilder builder) { + final SqmQuerySpec querySpec = statement.getQuerySpec(); final List> items = querySpec.getSelectClause().getSelectionItems(); if ( items.size() == 1 ) { final JpaSelection selected = items.get(0); if ( selected instanceof SqmRoot) { + statement.orderBy( keyDefinition.stream().map( order -> sortSpecification( statement, order ) ) + .collect( toList() ) ); final SqmFrom root = (SqmFrom) selected; - sqm.select( keySelection( keyDefinition, root, selected, builder ) ); + statement.select( keySelection( keyDefinition, root, selected, builder ) ); if ( keyValues != null ) { final SqmPredicate restriction = keyRestriction( keyDefinition, keyValues, root, builder ); final SqmPredicate queryWhere = querySpec.getRestriction(); - sqm.where( queryWhere == null ? restriction : builder.and( queryWhere, restriction ) ); + statement.where( queryWhere == null ? restriction : builder.and( queryWhere, restriction ) ); } - return sqm; + return statement; } else { throw new IllegalQueryOperationException("Select item was not an entity type"); diff --git a/hibernate-core/src/main/java/org/hibernate/query/sqm/internal/SqmUtil.java b/hibernate-core/src/main/java/org/hibernate/query/sqm/internal/SqmUtil.java index e5748fa12b..26970f7ac7 100644 --- a/hibernate-core/src/main/java/org/hibernate/query/sqm/internal/SqmUtil.java +++ b/hibernate-core/src/main/java/org/hibernate/query/sqm/internal/SqmUtil.java @@ -564,7 +564,7 @@ public class SqmUtil { } } - static JpaOrder sortSpecification(SqmSelectStatement sqm, Order order) { + static JpaOrder sortSpecification(SqmSelectStatement sqm, Order order) { final List> items = sqm.getQuerySpec().getSelectClause().getSelectionItems(); int element = order.getElement(); if ( element < 1) { diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/paging/keybased/KeyBasedPagingTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/paging/keybased/KeyBasedPagingTest.java index 24f1aafd49..95cf01d5b5 100644 --- a/hibernate-core/src/test/java/org/hibernate/orm/test/paging/keybased/KeyBasedPagingTest.java +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/paging/keybased/KeyBasedPagingTest.java @@ -14,6 +14,8 @@ import java.time.LocalDate; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; @SessionFactory @DomainModel(annotatedClasses = KeyBasedPagingTest.Person.class) @@ -35,17 +37,38 @@ public class KeyBasedPagingTest { session.createSelectionQuery("from Person", Person.class) .getKeyedResultList(Page.first(5) .keyedBy(Order.asc(Person.class, "ssn"))); + assertTrue( first.isFirstPage() ); + assertFalse( first.isLastPage() ); assertEquals(5,first.getResultList().size()); - int page = 1; + int page = 0; KeyedResultList next = first; - while ( next.getNextPage() != null ) { + while ( !next.isLastPage() ) { + page ++; next = session.createSelectionQuery("from Person", Person.class) .getKeyedResultList(next.getNextPage()); assertEquals(page, next.getPage().getPage().getNumber()); - page ++; + if ( !next.isLastPage() ) { + assertEquals(5, next.getResultList().size()); + } } - assertEquals(4, page); - assertEquals( 2, next.getResultList().size()); + assertEquals(3, page); + assertEquals(2, next.getResultList().size()); + assertTrue( next.isLastPage() ); + assertFalse( next.isFirstPage() ); + + KeyedResultList previous = next; + while ( !previous.isFirstPage() ) { + page --; + previous = session.createSelectionQuery("from Person where dob > :minDate", Person.class) + .setParameter("minDate", LocalDate.of(1970, 2, 5)) + .getKeyedResultList(previous.getPreviousPage()); + assertEquals(page, previous.getPage().getPage().getNumber()); + assertEquals(5, previous.getResultList().size()); + } + assertEquals(0, page); + assertEquals(5, previous.getResultList().size()); + assertTrue( previous.isFirstPage() ); + assertFalse( previous.isLastPage() ); }); scope.inSession(session -> { @@ -74,18 +97,39 @@ public class KeyBasedPagingTest { .setParameter("minDate", LocalDate.of(1970, 2, 5)) .getKeyedResultList(Page.first(5) .keyedBy(Order.asc(Person.class, "ssn"))); + assertTrue( first.isFirstPage() ); + assertFalse( first.isLastPage() ); assertEquals(5,first.getResultList().size()); - int page = 1; + int page = 0; KeyedResultList next = first; - while ( next.getNextPage() != null ) { + while ( !next.isLastPage() ) { + page ++; next = session.createSelectionQuery("from Person where dob > :minDate", Person.class) .setParameter("minDate", LocalDate.of(1970, 2, 5)) .getKeyedResultList(next.getNextPage()); assertEquals(page, next.getPage().getPage().getNumber()); - page ++; + if ( !next.isLastPage() ) { + assertEquals(5, next.getResultList().size()); + } } - assertEquals(3, page); - assertEquals( 2, next.getResultList().size()); + assertEquals(2, page); + assertEquals(2, next.getResultList().size()); + assertTrue( next.isLastPage() ); + assertFalse( next.isFirstPage() ); + + KeyedResultList previous = next; + while ( !previous.isFirstPage() ) { + page --; + previous = session.createSelectionQuery("from Person where dob > :minDate", Person.class) + .setParameter("minDate", LocalDate.of(1970, 2, 5)) + .getKeyedResultList(previous.getPreviousPage()); + assertEquals(page, previous.getPage().getPage().getNumber()); + assertEquals(5, previous.getResultList().size()); + } + assertEquals(0, page); + assertEquals(5, previous.getResultList().size()); + assertTrue( previous.isFirstPage() ); + assertFalse( previous.isLastPage() ); }); }