Polishing.
Refactor WindowIterator to return individual objects during scrolling. Original Pull Request: #2787
This commit is contained in:
committed by
Christoph Strobl
parent
0c0c1afc8e
commit
2564f6a049
@@ -20,10 +20,10 @@ do {
|
||||
|
||||
// obtain the next Scroll
|
||||
users = repository.findFirst10ByLastnameOrderByFirstname("Doe", users.positionAt(users.size() - 1));
|
||||
} while (!users.isEmpty() && !users.isLast());
|
||||
} while (!users.isEmpty() && users.hasNext());
|
||||
----
|
||||
|
||||
`WindowIterator` provides a specialized implementation that simplifies consumption of multiple `Window` instances by removing the need to check for the presence of a next `Window` and applying the `ScrollPosition`.
|
||||
`WindowIterator` provides a utility to simplify scrolling across ``Window``s by removing the need to check for the presence of a next `Window` and applying the `ScrollPosition`.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@@ -31,9 +31,8 @@ WindowIterator<User> users = WindowIterator.of(position -> repository.findFirst1
|
||||
.startingAt(OffsetScrollPosition.initial());
|
||||
|
||||
while (users.hasNext()) {
|
||||
users.next().forEach(user -> {
|
||||
// consume the user
|
||||
});
|
||||
User u = users.next();
|
||||
// consume the user
|
||||
}
|
||||
----
|
||||
|
||||
@@ -56,7 +55,8 @@ interface UserRepository extends Repository<User, Long> {
|
||||
WindowIterator<User> users = WindowIterator.of(position -> repository.findFirst10ByLastnameOrderByFirstname("Doe", position))
|
||||
.startingAt(OffsetScrollPosition.initial()); <1>
|
||||
----
|
||||
<1> Start from the initial offset at position 0;
|
||||
|
||||
<1> Start from the initial offset at position `0`.
|
||||
====
|
||||
|
||||
[[repositories.scrolling.keyset]]
|
||||
@@ -76,7 +76,7 @@ The database needs only constructing a much smaller result from the given keyset
|
||||
|
||||
[WARNING]
|
||||
====
|
||||
Keyset-Filtering requires properties used for sorting to be non nullable.
|
||||
Keyset-Filtering requires the keyset properties (those used for sorting) to be non-nullable.
|
||||
This limitation applies due to the store specific `null` value handling of comparison operators as well as the need to run queries against an indexed source.
|
||||
Keyset-Filtering on nullable properties will lead to unexpected results.
|
||||
====
|
||||
|
||||
@@ -129,7 +129,7 @@ public final class KeysetScrollPosition implements ScrollPosition {
|
||||
/**
|
||||
* Keyset scrolling direction.
|
||||
*/
|
||||
enum Direction {
|
||||
public enum Direction {
|
||||
|
||||
/**
|
||||
* Forward (default) direction to scroll from the beginning of the results to their end.
|
||||
|
||||
@@ -24,8 +24,8 @@ import org.springframework.data.util.Streamable;
|
||||
|
||||
/**
|
||||
* A set of data consumed from an underlying query result. A {@link Window} 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 window is less
|
||||
* opinionated about the actual data retrieval, whether the query has used index/offset, keyset-based pagination or
|
||||
* that it contains a subset of the actual query results for easier scrolling across large result sets. The window 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
|
||||
@@ -93,16 +93,33 @@ public interface Window<T> extends Streamable<T> {
|
||||
/**
|
||||
* Returns if there is a next window.
|
||||
*
|
||||
* @return if there is a next window window.
|
||||
* @return if there is a next window.
|
||||
*/
|
||||
boolean hasNext();
|
||||
|
||||
/**
|
||||
* Returns whether the underlying scroll mechanism can provide a {@link ScrollPosition} at {@code index}.
|
||||
*
|
||||
* @param index
|
||||
* @return {@code true} if a {@link ScrollPosition} can be created; {@code false} otherwise.
|
||||
* @see #positionAt(int)
|
||||
*/
|
||||
default boolean hasPosition(int index) {
|
||||
try {
|
||||
return positionAt(index) != null;
|
||||
} catch (IllegalStateException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@link ScrollPosition} at {@code index}.
|
||||
*
|
||||
* @param index
|
||||
* @return
|
||||
* @throws IndexOutOfBoundsException if the index is out of range ({@code index < 0 || index >= size()}).
|
||||
* @throws IllegalStateException if the underlying scroll mechanism cannot provide a scroll position for the given
|
||||
* object.
|
||||
*/
|
||||
ScrollPosition positionAt(int index);
|
||||
|
||||
@@ -112,6 +129,8 @@ public interface Window<T> extends Streamable<T> {
|
||||
* @param object
|
||||
* @return
|
||||
* @throws NoSuchElementException if the object is not part of the result.
|
||||
* @throws IllegalStateException if the underlying scroll mechanism cannot provide a scroll position for the given
|
||||
* object.
|
||||
*/
|
||||
default ScrollPosition positionAt(T object) {
|
||||
|
||||
|
||||
@@ -15,37 +15,40 @@
|
||||
*/
|
||||
package org.springframework.data.domain;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* An {@link Iterator} over multiple {@link Window Windows} obtained via a {@link Function window function}, that keeps track of
|
||||
* the current {@link ScrollPosition} returning the Window {@link Window#getContent() content} on {@link #next()}.
|
||||
* An {@link Iterator} over multiple {@link Window Windows} obtained via a {@link Function window function}, that keeps
|
||||
* track of the current {@link ScrollPosition} allowing scrolling across all result elements.
|
||||
*
|
||||
* <pre class="code">
|
||||
* WindowIterator<User> users = WindowIterator.of(position -> repository.findFirst10By...("spring", position))
|
||||
* WindowIterator<User> users = WindowIterator.of(position -> repository.findFirst10By…("spring", position))
|
||||
* .startingAt(OffsetScrollPosition.initial());
|
||||
*
|
||||
* while (users.hasNext()) {
|
||||
* users.next().forEach(user -> {
|
||||
* // consume the user
|
||||
* });
|
||||
* User u = users.next();
|
||||
* // consume user
|
||||
* }
|
||||
* </pre>
|
||||
*
|
||||
* @author Christoph Strobl
|
||||
* @author Mark Paluch
|
||||
* @since 3.1
|
||||
*/
|
||||
public class WindowIterator<T> implements Iterator<List<T>> {
|
||||
public class WindowIterator<T> implements Iterator<T> {
|
||||
|
||||
private final Function<ScrollPosition, Window<T>> windowFunction;
|
||||
|
||||
private ScrollPosition currentPosition;
|
||||
|
||||
@Nullable //
|
||||
private Window<T> currentWindow;
|
||||
private @Nullable Window<T> currentWindow;
|
||||
|
||||
private @Nullable Iterator<T> currentIterator;
|
||||
|
||||
/**
|
||||
* Entrypoint to create a new {@link WindowIterator} for the given windowFunction.
|
||||
@@ -55,42 +58,57 @@ public class WindowIterator<T> implements Iterator<List<T>> {
|
||||
* @return new instance of {@link WindowIteratorBuilder}.
|
||||
*/
|
||||
public static <T> WindowIteratorBuilder<T> of(Function<ScrollPosition, Window<T>> windowFunction) {
|
||||
return new WindowIteratorBuilder(windowFunction);
|
||||
return new WindowIteratorBuilder<>(windowFunction);
|
||||
}
|
||||
|
||||
WindowIterator(Function<ScrollPosition, Window<T>> windowFunction, ScrollPosition position) {
|
||||
|
||||
this.windowFunction = windowFunction;
|
||||
this.currentPosition = position;
|
||||
this.currentWindow = doScroll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
return currentWindow != null;
|
||||
|
||||
// use while loop instead of recursion to fetch the next window.
|
||||
do {
|
||||
if (currentWindow == null) {
|
||||
currentWindow = windowFunction.apply(currentPosition);
|
||||
}
|
||||
|
||||
if (currentIterator == null) {
|
||||
if (currentWindow != null) {
|
||||
currentIterator = currentWindow.iterator();
|
||||
}
|
||||
}
|
||||
|
||||
if (currentIterator != null) {
|
||||
|
||||
if (currentIterator.hasNext()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (currentWindow != null && currentWindow.hasNext()) {
|
||||
|
||||
currentPosition = currentWindow.positionAt(currentWindow.size() - 1);
|
||||
currentIterator = null;
|
||||
currentWindow = null;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} while (true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<T> next() {
|
||||
public T next() {
|
||||
|
||||
List<T> toReturn = new ArrayList<>(currentWindow.getContent());
|
||||
currentPosition = currentWindow.positionAt(currentWindow.size() -1);
|
||||
currentWindow = doScroll();
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
Window<T> doScroll() {
|
||||
|
||||
if (currentWindow != null && !currentWindow.hasNext()) {
|
||||
return null;
|
||||
if (!hasNext()) {
|
||||
throw new NoSuchElementException();
|
||||
}
|
||||
|
||||
Window<T> window = windowFunction.apply(currentPosition);
|
||||
if (window.isEmpty() && window.isLast()) {
|
||||
return null;
|
||||
}
|
||||
return window;
|
||||
return currentIterator.next();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,15 +120,25 @@ public class WindowIterator<T> implements Iterator<List<T>> {
|
||||
*/
|
||||
public static class WindowIteratorBuilder<T> {
|
||||
|
||||
private Function<ScrollPosition, Window<T>> windowFunction;
|
||||
private final Function<ScrollPosition, Window<T>> windowFunction;
|
||||
|
||||
WindowIteratorBuilder(Function<ScrollPosition, Window<T>> windowFunction) {
|
||||
|
||||
Assert.notNull(windowFunction, "WindowFunction must not be null");
|
||||
|
||||
this.windowFunction = windowFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link WindowIterator} given {@link ScrollPosition}.
|
||||
*
|
||||
* @param position
|
||||
* @return
|
||||
*/
|
||||
public WindowIterator<T> startingAt(ScrollPosition position) {
|
||||
|
||||
Assert.state(windowFunction != null, "WindowFunction cannot not be null");
|
||||
Assert.notNull(position, "ScrollPosition must not be null");
|
||||
|
||||
return new WindowIterator<>(windowFunction, position);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,112 +20,110 @@ import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Captor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.data.domain.WindowIterator.WindowIteratorBuilder;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link WindowIterator}.
|
||||
*
|
||||
* @author Christoph Strobl
|
||||
* @author Mark Paluch
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class WindowIteratorUnitTests<T> {
|
||||
|
||||
@Mock Function<ScrollPosition, Window<T>> fkt;
|
||||
|
||||
@Mock Window<T> window;
|
||||
|
||||
@Mock ScrollPosition scrollPosition;
|
||||
|
||||
@Captor ArgumentCaptor<ScrollPosition> scrollCaptor;
|
||||
|
||||
@BeforeEach
|
||||
void beforeEach() {
|
||||
when(fkt.apply(any())).thenReturn(window);
|
||||
}
|
||||
class WindowIteratorUnitTests {
|
||||
|
||||
@Test // GH-2151
|
||||
void loadsDataOnCreation() {
|
||||
void loadsDataOnNext() {
|
||||
|
||||
WindowIteratorBuilder<T> of = WindowIterator.of(fkt);
|
||||
Function<ScrollPosition, Window<String>> fkt = mock(Function.class);
|
||||
WindowIterator<String> iterator = WindowIterator.of(fkt).startingAt(OffsetScrollPosition.initial());
|
||||
verifyNoInteractions(fkt);
|
||||
|
||||
of.startingAt(scrollPosition);
|
||||
verify(fkt).apply(eq(scrollPosition));
|
||||
when(fkt.apply(any())).thenReturn(Window.from(Collections.emptyList(), value -> OffsetScrollPosition.initial()));
|
||||
|
||||
iterator.hasNext();
|
||||
verify(fkt).apply(OffsetScrollPosition.initial());
|
||||
}
|
||||
|
||||
@Test // GH-2151
|
||||
void hasNextReturnsFalseIfNoDataAvailable() {
|
||||
|
||||
when(window.isLast()).thenReturn(true);
|
||||
when(window.isEmpty()).thenReturn(true);
|
||||
Window<Object> window = Window.from(Collections.emptyList(), value -> OffsetScrollPosition.initial());
|
||||
WindowIterator<Object> iterator = WindowIterator.of(it -> window).startingAt(OffsetScrollPosition.initial());
|
||||
|
||||
assertThat(WindowIterator.of(fkt).startingAt(scrollPosition).hasNext()).isFalse();
|
||||
assertThat(iterator.hasNext()).isFalse();
|
||||
}
|
||||
|
||||
@Test // GH-2151
|
||||
void nextThrowsExceptionIfNoElementAvailable() {
|
||||
|
||||
Window<Object> window = Window.from(Collections.emptyList(), value -> OffsetScrollPosition.initial());
|
||||
WindowIterator<Object> iterator = WindowIterator.of(it -> window).startingAt(OffsetScrollPosition.initial());
|
||||
|
||||
assertThatExceptionOfType(NoSuchElementException.class).isThrownBy(iterator::next);
|
||||
}
|
||||
|
||||
@Test // GH-2151
|
||||
void hasNextReturnsTrueIfDataAvailableButOnlyOnePage() {
|
||||
|
||||
when(window.isLast()).thenReturn(true);
|
||||
when(window.isEmpty()).thenReturn(false);
|
||||
Window<String> window = Window.from(List.of("a", "b"), value -> OffsetScrollPosition.initial());
|
||||
WindowIterator<String> iterator = WindowIterator.of(it -> window).startingAt(OffsetScrollPosition.initial());
|
||||
|
||||
assertThat(WindowIterator.of(fkt).startingAt(scrollPosition).hasNext()).isTrue();
|
||||
assertThat(iterator.hasNext()).isTrue();
|
||||
assertThat(iterator.next()).isEqualTo("a");
|
||||
assertThat(iterator.hasNext()).isTrue();
|
||||
assertThat(iterator.next()).isEqualTo("b");
|
||||
assertThat(iterator.hasNext()).isFalse();
|
||||
assertThat(iterator.hasNext()).isFalse();
|
||||
}
|
||||
|
||||
@Test // GH-2151
|
||||
void hasNextReturnsCorrectlyIfNextPageIsEmpty() {
|
||||
|
||||
Window<String> window = Window.from(List.of("a", "b"), value -> OffsetScrollPosition.initial());
|
||||
WindowIterator<String> iterator = WindowIterator.of(it -> {
|
||||
if (it.isInitial()) {
|
||||
return window;
|
||||
}
|
||||
|
||||
return Window.from(Collections.emptyList(), OffsetScrollPosition::of, false);
|
||||
}).startingAt(OffsetScrollPosition.initial());
|
||||
|
||||
assertThat(iterator.hasNext()).isTrue();
|
||||
assertThat(iterator.next()).isEqualTo("a");
|
||||
assertThat(iterator.hasNext()).isTrue();
|
||||
assertThat(iterator.next()).isEqualTo("b");
|
||||
assertThat(iterator.hasNext()).isFalse();
|
||||
assertThat(iterator.hasNext()).isFalse();
|
||||
}
|
||||
|
||||
@Test // GH-2151
|
||||
void allowsToIterateAllWindows() {
|
||||
|
||||
ScrollPosition p1 = mock(ScrollPosition.class);
|
||||
ScrollPosition p2 = mock(ScrollPosition.class);
|
||||
Window<String> window1 = Window.from(List.of("a", "b"), OffsetScrollPosition::of, true);
|
||||
Window<String> window2 = Window.from(List.of("c", "d"), value -> OffsetScrollPosition.of(2 + value));
|
||||
WindowIterator<String> iterator = WindowIterator.of(it -> {
|
||||
if (it.isInitial()) {
|
||||
return window1;
|
||||
}
|
||||
|
||||
when(window.isEmpty()).thenReturn(false, false, false);
|
||||
when(window.isLast()).thenReturn(false, false, true);
|
||||
when(window.hasNext()).thenReturn(true, true, false);
|
||||
when(window.size()).thenReturn(1, 1, 1);
|
||||
when(window.positionAt(anyInt())).thenReturn(p1, p2);
|
||||
when(window.getContent()).thenReturn(List.of((T) "0"), List.of((T) "1"), List.of((T) "2"));
|
||||
return window2;
|
||||
}).startingAt(OffsetScrollPosition.initial());
|
||||
|
||||
WindowIterator<T> iterator = WindowIterator.of(fkt).startingAt(scrollPosition);
|
||||
List<T> capturedResult = new ArrayList<>(3);
|
||||
List<String> capturedResult = new ArrayList<>(4);
|
||||
while (iterator.hasNext()) {
|
||||
capturedResult.addAll(iterator.next());
|
||||
capturedResult.add(iterator.next());
|
||||
}
|
||||
|
||||
verify(fkt, times(3)).apply(scrollCaptor.capture());
|
||||
assertThat(scrollCaptor.getAllValues()).containsExactly(scrollPosition, p1, p2);
|
||||
assertThat(capturedResult).containsExactly((T) "0", (T) "1", (T) "2");
|
||||
}
|
||||
|
||||
@Test // GH-2151
|
||||
void stopsAfterFirstPageIfOnlyOneWindowAvailable() {
|
||||
|
||||
ScrollPosition p1 = mock(ScrollPosition.class);
|
||||
|
||||
when(window.isEmpty()).thenReturn(false);
|
||||
when(window.isLast()).thenReturn(true);
|
||||
when(window.hasNext()).thenReturn(false);
|
||||
when(window.size()).thenReturn(1);
|
||||
when(window.positionAt(anyInt())).thenReturn(p1);
|
||||
when(window.getContent()).thenReturn(List.of((T) "0"));
|
||||
|
||||
WindowIterator<T> iterator = WindowIterator.of(fkt).startingAt(scrollPosition);
|
||||
List<T> capturedResult = new ArrayList<>(1);
|
||||
while (iterator.hasNext()) {
|
||||
capturedResult.addAll(iterator.next());
|
||||
}
|
||||
|
||||
verify(fkt).apply(scrollCaptor.capture());
|
||||
assertThat(scrollCaptor.getAllValues()).containsExactly(scrollPosition);
|
||||
assertThat(capturedResult).containsExactly((T) "0");
|
||||
assertThat(capturedResult).containsExactly("a", "b", "c", "d");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user