HHH-17779 previous-page nativation for key-based pagination

This commit is contained in:
Gavin King 2024-02-28 13:14:53 +01:00
parent 1eff3c990b
commit 5d498c1063
8 changed files with 190 additions and 41 deletions

View File

@ -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<R> {
private final List<Order<? super R>> keyDefinition;
private final Page page;
private final List<Comparable<?>> key;
private final KeyInterpretation keyInterpretation;
KeyedPage(List<Order<? super R>> keyDefinition, Page page) {
this( keyDefinition, page, null );
this( keyDefinition, page, null, NO_KEY );
}
KeyedPage(List<Order<? super R>> keyDefinition, Page page, List<Comparable<?>> key) {
this.page = page;
KeyedPage(List<Order<? super R>> keyDefinition, Page page, List<Comparable<?>> key, KeyInterpretation interpretation) {
this.keyDefinition = unmodifiableList(keyDefinition);
this.page = page;
this.key = key;
this.keyInterpretation = interpretation;
}
/**
@ -78,36 +83,76 @@ public class KeyedPage<R> {
}
/**
* 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.
* <p>
* 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<Comparable<?>> 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<R> nextPage(List<Comparable<?>> keyOfLastResult) {
return new KeyedPage<>( keyDefinition, page.next(), keyOfLastResult );
public KeyedPage<R> nextPage(List<Comparable<?>> 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<R> withKey(List<Comparable<?>> keyOfLastResult) {
return new KeyedPage<>( keyDefinition, page, keyOfLastResult );
public KeyedPage<R> previousPage(List<Comparable<?>> 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<R> withKey(List<Comparable<?>> 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
}
}

View File

@ -30,12 +30,16 @@ public class KeyedResultList<R> {
private final List<List<?>> keyList;
private final KeyedPage<R> page;
private final KeyedPage<R> nextPage;
private final KeyedPage<R> previousPage;
public KeyedResultList(List<R> resultList, List<List<?>> keyList, KeyedPage<R> page, KeyedPage<R> nextPage) {
public KeyedResultList(
List<R> resultList, List<List<?>> keyList,
KeyedPage<R> page, KeyedPage<R> nextPage, KeyedPage<R> previousPage) {
this.resultList = resultList;
this.keyList = keyList;
this.page = page;
this.nextPage = nextPage;
this.previousPage = previousPage;
}
/**
@ -71,6 +75,15 @@ public class KeyedResultList<R> {
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<R> getPreviousPage() {
return previousPage;
}
/**
* @return {@code true} if this is known to be the
* last page of results.

View File

@ -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.
* <p>
@ -92,6 +95,16 @@ public class Order<X> {
this.ignoreCase = ignoreCase;
}
private Order(Order<X> 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 <T> Order<T> asc(SingularAttribute<T,?> attribute) {
return new Order<>(SortDirection.ASCENDING, NullPrecedence.NONE, attribute);
}
@ -176,6 +189,10 @@ public class Order<X> {
return element;
}
public Order<X> reverse() {
return new Order<>( this, order.reverse() );
}
@Override
public String toString() {
return attributeName + " " + order;
@ -200,4 +217,18 @@ public class Order<X> {
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 <T> List<Order<? super T>> reverse(List<Order<? super T>> ordering) {
return ordering.stream().map(Order::reverse).collect(toList());
}
}

View File

@ -87,6 +87,9 @@ public class Page {
}
public <R> KeyedPage<R> keyedBy(Order<? super R> 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 <R> KeyedPage<R> keyedBy(List<Order<? super R>> 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 );
}

View File

@ -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<R> extends AbstractSelectionQuery<R> {
@Override
public KeyedResultList<R> getKeyedResultList(KeyedPage<R> keyedPage) {
if ( keyedPage == null ) {
throw new IllegalStateException( "KeyedPage was not null" );
throw new IllegalArgumentException( "KeyedPage was null" );
}
final Page page = keyedPage.getPage();
final List<Order<? super R>> keyDefinition = keyedPage.getKeyDefinition();
final List<Comparable<?>> key = keyedPage.getKey();
final List<Order<? super R>> keyDefinition = keyedPage.getKeyDefinition();
final List<Order<? super R>> 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<R> extends AbstractSelectionQuery<R> {
// getQueryOptions().setQueryPlanCachingEnabled( false );
final List<KeyedResult<R>> 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 <R> KeyedPage<R> getNextPage(KeyedPage<R> keyedPage, List<KeyedResult<R>> executed) {
private static <R> KeyedPage<R> nextPage(KeyedPage<R> keyedPage, List<KeyedResult<R>> 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 <R> KeyedPage<R> previousPage(KeyedPage<R> keyedPage, List<KeyedResult<R>> results) {
return !results.isEmpty()
? keyedPage.previousPage( results.get(0).getKey() )
: null;
}

View File

@ -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 <R> SqmSelectStatement<KeyedResult<R>> paginate(
List<Order<? super R>> keyDefinition, List<Comparable<?>> keyValues,
SqmSelectStatement<KeyedResult<R>> sqm, NodeBuilder builder) {
final SqmQuerySpec<?> querySpec = sqm.getQuerySpec();
SqmSelectStatement<KeyedResult<R>> statement, NodeBuilder builder) {
final SqmQuerySpec<?> querySpec = statement.getQuerySpec();
final List<? extends JpaSelection<?>> 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");

View File

@ -564,7 +564,7 @@ public class SqmUtil {
}
}
static <T> JpaOrder sortSpecification(SqmSelectStatement<T> sqm, Order<? super T> order) {
static JpaOrder sortSpecification(SqmSelectStatement<?> sqm, Order<?> order) {
final List<SqmSelectableNode<?>> items = sqm.getQuerySpec().getSelectClause().getSelectionItems();
int element = order.getElement();
if ( element < 1) {

View File

@ -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<Person> 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(3, page);
assertEquals(2, next.getResultList().size());
assertTrue( next.isLastPage() );
assertFalse( next.isFirstPage() );
KeyedResultList<Person> 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<Person> 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, page);
assertEquals(2, next.getResultList().size());
assertTrue( next.isLastPage() );
assertFalse( next.isFirstPage() );
KeyedResultList<Person> 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() );
});
}