Introduce Scroll API.

See: #2151
Original Pull Request: #2787
This commit is contained in:
Mark Paluch
2023-02-27 13:37:06 +01:00
committed by Christoph Strobl
parent a5de842460
commit 035965a3a2
27 changed files with 1280 additions and 134 deletions

View File

@@ -0,0 +1,144 @@
/*
* Copyright 2023 the original author or 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 org.springframework.data.domain;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
/**
* A {@link ScrollPosition} based on the last seen keyset. Keyset scrolling must be associated with a {@link Sort
* well-defined sort} to be able to extract the keyset when resuming scrolling within the sorted result set.
*
* @author Mark Paluch
* @since 3.1
*/
public final class KeysetScrollPosition implements ScrollPosition {
private static final KeysetScrollPosition initial = new KeysetScrollPosition(Collections.emptyMap(),
Direction.Forward);
private final Map<String, Object> keys;
private final Direction direction;
private KeysetScrollPosition(Map<String, Object> keys, Direction direction) {
this.keys = keys;
this.direction = direction;
}
/**
* Creates a new initial {@link KeysetScrollPosition} to start scrolling using keyset-queries.
*
* @return a new initial {@link KeysetScrollPosition} to start scrolling using keyset-queries.
*/
public static KeysetScrollPosition initial() {
return initial;
}
/**
* Creates a new {@link KeysetScrollPosition} from a keyset.
*
* @param keys must not be {@literal null}.
* @return a new {@link KeysetScrollPosition} for the given keyset.
*/
public static KeysetScrollPosition of(Map<String, ?> keys) {
return of(keys, Direction.Forward);
}
/**
* Creates a new {@link KeysetScrollPosition} from a keyset and {@link Direction}.
*
* @param keys must not be {@literal null}.
* @param direction must not be {@literal null}.
* @return a new {@link KeysetScrollPosition} for the given keyset and {@link Direction}.
*/
public static KeysetScrollPosition of(Map<String, ?> keys, Direction direction) {
Assert.notNull(keys, "Keys must not be null");
Assert.notNull(direction, "Direction must not be null");
if (keys.isEmpty()) {
return initial();
}
return new KeysetScrollPosition(Collections.unmodifiableMap(new LinkedHashMap<>(keys)), direction);
}
@Override
public boolean isInitial() {
return keys.isEmpty();
}
/**
* @return the keyset.
*/
public Map<String, Object> getKeys() {
return keys;
}
/**
* @return the scroll direction.
*/
public Direction getDirection() {
return direction;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
KeysetScrollPosition that = (KeysetScrollPosition) o;
return ObjectUtils.nullSafeEquals(keys, that.keys) && direction == that.direction;
}
@Override
public int hashCode() {
int result = 17;
result += 31 * ObjectUtils.nullSafeHashCode(keys);
result += 31 * ObjectUtils.nullSafeHashCode(direction);
return result;
}
@Override
public String toString() {
return String.format("KeysetScrollPosition [%s, %s]", direction, keys);
}
/**
* Keyset scrolling direction.
*/
enum Direction {
/**
* Forward (default) direction to scroll from the beginning of the results to their end.
*/
Forward,
/**
* Backward direction to scroll from the end of the results to their beginning.
*/
Backward;
}
}

View File

@@ -0,0 +1,127 @@
/*
* Copyright 2023 the original author or 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 org.springframework.data.domain;
import java.util.function.IntFunction;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
/**
* A {@link ScrollPosition} based on the offsets within query results.
*
* @author Mark Paluch
* @since 3.1
*/
public final class OffsetScrollPosition implements ScrollPosition {
private static final OffsetScrollPosition initial = new OffsetScrollPosition(0);
private final long offset;
private OffsetScrollPosition(long offset) {
this.offset = offset;
}
/**
* Creates a new initial {@link OffsetScrollPosition} to start scrolling using offset/limit.
*
* @return a new initial {@link OffsetScrollPosition} to start scrolling using offset/limit.
*/
public static OffsetScrollPosition initial() {
return initial;
}
/**
* Creates a new {@link OffsetScrollPosition} from an {@code offset}.
*
* @param offset
* @return a new {@link OffsetScrollPosition} with the given {@code offset}.
*/
public static OffsetScrollPosition of(long offset) {
if (offset == 0) {
return initial();
}
return new OffsetScrollPosition(offset);
}
/**
* Returns the {@link IntFunction position function} to calculate.
*
* @param startOffset the start offset to be used. Must not be negative.
* @return the offset-based position function.
*/
public static IntFunction<OffsetScrollPosition> positionFunction(long startOffset) {
Assert.isTrue(startOffset >= 0, "Start offset must not be negative");
return startOffset == 0 ? OffsetPositionFunction.ZERO : new OffsetPositionFunction(startOffset);
}
@Override
public boolean isInitial() {
return offset == 0;
}
/**
* @return the offset.
*/
public long getOffset() {
return offset;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
OffsetScrollPosition that = (OffsetScrollPosition) o;
return offset == that.offset;
}
@Override
public int hashCode() {
int result = 17;
result += 31 * ObjectUtils.nullSafeHashCode(offset);
return result;
}
@Override
public String toString() {
return String.format("OffsetScrollPosition [%s]", offset);
}
private record OffsetPositionFunction(long startOffset) implements IntFunction<OffsetScrollPosition> {
static final OffsetPositionFunction ZERO = new OffsetPositionFunction(0);
@Override
public OffsetScrollPosition apply(int offset) {
if (offset < 0) {
throw new IndexOutOfBoundsException(offset);
}
return of(startOffset + offset + 1);
}
}
}

View File

@@ -161,4 +161,20 @@ public interface Pageable {
return isUnpaged() ? Optional.empty() : Optional.of(this);
}
/**
* Returns an {@link OffsetScrollPosition} from this pageable if the page request {@link #isPaged() is paged}.
*
* @return
* @throws IllegalStateException if the request is {@link #isUnpaged()}
* @since 3.1
*/
default OffsetScrollPosition toScrollPosition() {
if (isUnpaged()) {
throw new IllegalStateException("Cannot create OffsetScrollPosition from an unpaged instance");
}
return OffsetScrollPosition.of(getOffset());
}
}

View File

@@ -0,0 +1,167 @@
/*
* Copyright 2023 the original author or 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 org.springframework.data.domain;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.function.Function;
import java.util.function.IntFunction;
import org.springframework.data.util.Streamable;
/**
* A scroll of data consumed from an underlying query result. A scroll is similar to {@link Slice} in the sense that it
* contains a subset of the actual query results for easier consumption of large result sets. The scroll is less
* opinionated about the actual data retrieval, whether the query has used index/offset, keyset-based pagination or
* cursor resume tokens.
*
* @author Mark Paluch
* @since 3.1
* @see ScrollPosition
*/
public interface Scroll<T> extends Streamable<T> {
/**
* Construct a {@link Scroll}.
*
* @param items the list of data.
* @param positionFunction the list of data.
* @return the {@link Scroll}.
* @param <T>
*/
static <T> Scroll<T> from(List<T> items, IntFunction<? extends ScrollPosition> positionFunction) {
return new ScrollImpl<>(items, positionFunction, false);
}
/**
* Construct a {@link Scroll}.
*
* @param items the list of data.
* @param positionFunction the list of data.
* @param hasNext
* @return the {@link Scroll}.
* @param <T>
*/
static <T> Scroll<T> from(List<T> items, IntFunction<? extends ScrollPosition> positionFunction, boolean hasNext) {
return new ScrollImpl<>(items, positionFunction, hasNext);
}
/**
* Returns the number of elements in this scroll.
*
* @return the number of elements in this scroll.
*/
int size();
/**
* Returns {@code true} if this scroll contains no elements.
*
* @return {@code true} if this scroll contains no elements
*/
boolean isEmpty();
/**
* Returns the scroll content as {@link List}.
*
* @return
*/
List<T> getContent();
/**
* Returns whether the current scroll is the last one.
*
* @return
*/
default boolean isLast() {
return !hasNext();
}
/**
* Returns if there is a next scroll.
*
* @return if there is a next scroll window.
*/
boolean hasNext();
/**
* Returns the {@link ScrollPosition} at {@code index}.
*
* @param index
* @return
* @throws IndexOutOfBoundsException if the index is out of range ({@code index < 0 || index >= size()}).
*/
ScrollPosition positionAt(int index);
/**
* Returns the {@link ScrollPosition} for {@code object}.
*
* @param object
* @return
* @throws NoSuchElementException if the object is not part of the result.
*/
default ScrollPosition positionAt(T object) {
int index = getContent().indexOf(object);
if (index == -1) {
throw new NoSuchElementException();
}
return positionAt(index);
}
// TODO: First and last seem to conflict with first/last scroll or first/last position of elements.
// these methods should rather express the position of the first element within this scroll and the scroll position
// to be used to get the next Scroll.
/**
* Returns the first {@link ScrollPosition} or throw {@link NoSuchElementException} if the list is empty.
*
* @return the first {@link ScrollPosition}.
* @throws NoSuchElementException if this result is empty.
*/
default ScrollPosition firstPosition() {
if (size() == 0) {
throw new NoSuchElementException();
}
return positionAt(0);
}
/**
* Returns the last {@link ScrollPosition} or throw {@link NoSuchElementException} if the list is empty.
*
* @return the last {@link ScrollPosition}.
* @throws NoSuchElementException if this result is empty.
*/
default ScrollPosition lastPosition() {
if (size() == 0) {
throw new NoSuchElementException();
}
return positionAt(size() - 1);
}
/**
* Returns a new {@link Scroll} with the content of the current one mapped by the given {@code converter}.
*
* @param converter must not be {@literal null}.
* @return a new {@link Scroll} with the content of the current one mapped by the given {@code converter}.
*/
<U> Scroll<U> map(Function<? super T, ? extends U> converter);
}

View File

@@ -0,0 +1,119 @@
/*
* Copyright 2023 the original author or 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 org.springframework.data.domain;
import java.util.Iterator;
import java.util.List;
import java.util.function.Function;
import java.util.function.IntFunction;
import java.util.stream.Collectors;
import org.jetbrains.annotations.NotNull;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
/**
* Default {@link Scroll} implementation.
*
* @author Mark Paluch
* @since 3.1
*/
class ScrollImpl<T> implements Scroll<T> {
private final List<T> items;
private final IntFunction<? extends ScrollPosition> positionFunction;
private final boolean hasNext;
ScrollImpl(List<T> items, IntFunction<? extends ScrollPosition> positionFunction, boolean hasNext) {
Assert.notNull(items, "List of items must not be null");
Assert.notNull(positionFunction, "Position function must not be null");
this.items = items;
this.positionFunction = positionFunction;
this.hasNext = hasNext;
}
@Override
public int size() {
return items.size();
}
@Override
public boolean isEmpty() {
return items.isEmpty();
}
@Override
public List<T> getContent() {
return items;
}
@Override
public boolean hasNext() {
return hasNext;
}
@Override
public ScrollPosition positionAt(int index) {
if (index < 0 || index >= size()) {
throw new IndexOutOfBoundsException(index);
}
return positionFunction.apply(index);
}
@Override
public <U> Scroll<U> map(Function<? super T, ? extends U> converter) {
Assert.notNull(converter, "Function must not be null");
return new ScrollImpl<>(stream().map(converter).collect(Collectors.toList()), positionFunction, hasNext);
}
@NotNull
@Override
public Iterator<T> iterator() {
return items.iterator();
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
ScrollImpl<?> that = (ScrollImpl<?>) o;
return ObjectUtils.nullSafeEquals(items, that.items)
&& ObjectUtils.nullSafeEquals(positionFunction, that.positionFunction)
&& ObjectUtils.nullSafeEquals(hasNext, that.hasNext);
}
@Override
public int hashCode() {
int result = ObjectUtils.nullSafeHashCode(items);
result = 31 * result + ObjectUtils.nullSafeHashCode(positionFunction);
result = 31 * result + ObjectUtils.nullSafeHashCode(hasNext);
return result;
}
@Override
public String toString() {
return "Scroll " + items;
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2023 the original author or 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 org.springframework.data.domain;
/**
* Interface to specify a position within a total query result. Scroll positions are used to start scrolling from the
* beginning of a query result or to resume scrolling from a given position within the query result.
*
* @author Mark Paluch
* @since 3.1
*/
public interface ScrollPosition {
/**
* Returns whether the current scroll position is the initial one.
*
* @return
*/
boolean isInitial();
}