diff --git a/src/main/asciidoc/repositories-scrolling.adoc b/src/main/asciidoc/repositories-scrolling.adoc index 3e06ed779..5f90fadfc 100644 --- a/src/main/asciidoc/repositories-scrolling.adoc +++ b/src/main/asciidoc/repositories-scrolling.adoc @@ -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 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 { WindowIterator 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. ==== diff --git a/src/main/java/org/springframework/data/domain/KeysetScrollPosition.java b/src/main/java/org/springframework/data/domain/KeysetScrollPosition.java index 9063b6f3f..81b3058e2 100644 --- a/src/main/java/org/springframework/data/domain/KeysetScrollPosition.java +++ b/src/main/java/org/springframework/data/domain/KeysetScrollPosition.java @@ -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. diff --git a/src/main/java/org/springframework/data/domain/Window.java b/src/main/java/org/springframework/data/domain/Window.java index d27e2249e..536e58f08 100644 --- a/src/main/java/org/springframework/data/domain/Window.java +++ b/src/main/java/org/springframework/data/domain/Window.java @@ -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 extends Streamable { /** * 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 extends Streamable { * @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) { diff --git a/src/main/java/org/springframework/data/domain/WindowIterator.java b/src/main/java/org/springframework/data/domain/WindowIterator.java index f4c06833b..923dd44cf 100644 --- a/src/main/java/org/springframework/data/domain/WindowIterator.java +++ b/src/main/java/org/springframework/data/domain/WindowIterator.java @@ -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. + * *
- * 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
  * }
  * 
* * @author Christoph Strobl + * @author Mark Paluch * @since 3.1 */ -public class WindowIterator implements Iterator> { +public class WindowIterator implements Iterator { private final Function> windowFunction; + private ScrollPosition currentPosition; - @Nullable // - private Window currentWindow; + private @Nullable Window currentWindow; + + private @Nullable Iterator currentIterator; /** * Entrypoint to create a new {@link WindowIterator} for the given windowFunction. @@ -55,42 +58,57 @@ public class WindowIterator implements Iterator> { * @return new instance of {@link WindowIteratorBuilder}. */ public static WindowIteratorBuilder of(Function> windowFunction) { - return new WindowIteratorBuilder(windowFunction); + return new WindowIteratorBuilder<>(windowFunction); } WindowIterator(Function> 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 next() { + public T next() { - List toReturn = new ArrayList<>(currentWindow.getContent()); - currentPosition = currentWindow.positionAt(currentWindow.size() -1); - currentWindow = doScroll(); - return toReturn; - } - - @Nullable - Window doScroll() { - - if (currentWindow != null && !currentWindow.hasNext()) { - return null; + if (!hasNext()) { + throw new NoSuchElementException(); } - Window window = windowFunction.apply(currentPosition); - if (window.isEmpty() && window.isLast()) { - return null; - } - return window; + return currentIterator.next(); } /** @@ -102,15 +120,25 @@ public class WindowIterator implements Iterator> { */ public static class WindowIteratorBuilder { - private Function> windowFunction; + private final Function> windowFunction; WindowIteratorBuilder(Function> windowFunction) { + + Assert.notNull(windowFunction, "WindowFunction must not be null"); + this.windowFunction = windowFunction; } + /** + * Create a {@link WindowIterator} given {@link ScrollPosition}. + * + * @param position + * @return + */ public WindowIterator 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); } } diff --git a/src/test/java/org/springframework/data/domain/WindowIteratorUnitTests.java b/src/test/java/org/springframework/data/domain/WindowIteratorUnitTests.java index a8f525df7..9b27d15fe 100644 --- a/src/test/java/org/springframework/data/domain/WindowIteratorUnitTests.java +++ b/src/test/java/org/springframework/data/domain/WindowIteratorUnitTests.java @@ -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 { - - @Mock Function> fkt; - - @Mock Window window; - - @Mock ScrollPosition scrollPosition; - - @Captor ArgumentCaptor scrollCaptor; - - @BeforeEach - void beforeEach() { - when(fkt.apply(any())).thenReturn(window); - } +class WindowIteratorUnitTests { @Test // GH-2151 - void loadsDataOnCreation() { + void loadsDataOnNext() { - WindowIteratorBuilder of = WindowIterator.of(fkt); + Function> fkt = mock(Function.class); + WindowIterator 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 window = Window.from(Collections.emptyList(), value -> OffsetScrollPosition.initial()); + WindowIterator 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 window = Window.from(Collections.emptyList(), value -> OffsetScrollPosition.initial()); + WindowIterator 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 window = Window.from(List.of("a", "b"), value -> OffsetScrollPosition.initial()); + WindowIterator 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 window = Window.from(List.of("a", "b"), value -> OffsetScrollPosition.initial()); + WindowIterator 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 window1 = Window.from(List.of("a", "b"), OffsetScrollPosition::of, true); + Window window2 = Window.from(List.of("c", "d"), value -> OffsetScrollPosition.of(2 + value)); + WindowIterator 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 iterator = WindowIterator.of(fkt).startingAt(scrollPosition); - List capturedResult = new ArrayList<>(3); + List 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 iterator = WindowIterator.of(fkt).startingAt(scrollPosition); - List 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"); } }