diff --git a/pom.xml b/pom.xml index 13bf0377b4..a8b89c32b4 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-commons - 3.1.0-SNAPSHOT + 3.1.0-GH-2151-SNAPSHOT Spring Data Core Core Spring concepts underpinning every Spring Data module. diff --git a/src/main/asciidoc/index.adoc b/src/main/asciidoc/index.adoc index 6cc84d4cfb..14fd59748d 100644 --- a/src/main/asciidoc/index.adoc +++ b/src/main/asciidoc/index.adoc @@ -2,6 +2,7 @@ Oliver Gierke; Thomas Darimont; Christoph Strobl; Mark Pollack; Thomas Risberg; Mark Paluch; Jay Bryant :revnumber: {version} :revdate: {localdate} +:feature-scroll: true ifdef::backend-epub3[:front-cover-image: image:epub-cover.png[Front Cover,1050,1600]] (C) 2008-2022 The original authors. diff --git a/src/main/asciidoc/repositories-paging-sorting.adoc b/src/main/asciidoc/repositories-paging-sorting.adoc new file mode 100644 index 0000000000..8bf940f474 --- /dev/null +++ b/src/main/asciidoc/repositories-paging-sorting.adoc @@ -0,0 +1,136 @@ +[[repositories.special-parameters]] +=== Special parameter handling + +To handle parameters in your query, define method parameters as already seen in the preceding examples. +Besides that, the infrastructure recognizes certain specific types like `Pageable` and `Sort`, to apply pagination and sorting to your queries dynamically. +The following example demonstrates these features: + +ifdef::feature-scroll[] +.Using `Pageable`, `Slice`, `ScrollPosition`, and `Sort` in query methods +==== +[source,java] +---- +Page findByLastname(String lastname, Pageable pageable); + +Slice findByLastname(String lastname, Pageable pageable); + +Scroll findTop10ByLastname(String lastname, ScrollPosition position, Sort sort); + +List findByLastname(String lastname, Sort sort); + +List findByLastname(String lastname, Pageable pageable); +---- +==== +endif::[] + +ifndef::feature-scroll[] +.Using `Pageable`, `Slice`, and `Sort` in query methods +==== +[source,java] +---- +Page findByLastname(String lastname, Pageable pageable); + +Slice findByLastname(String lastname, Pageable pageable); + +List findByLastname(String lastname, Sort sort); + +List findByLastname(String lastname, Pageable pageable); +---- +==== +endif::[] + +IMPORTANT: APIs taking `Sort` and `Pageable` expect non-`null` values to be handed into methods. +If you do not want to apply any sorting or pagination, use `Sort.unsorted()` and `Pageable.unpaged()`. + +The first method lets you pass an `org.springframework.data.domain.Pageable` instance to the query method to dynamically add paging to your statically defined query. +A `Page` knows about the total number of elements and pages available. +It does so by the infrastructure triggering a count query to calculate the overall number. +As this might be expensive (depending on the store used), you can instead return a `Slice`. +A `Slice` knows only about whether a next `Slice` is available, which might be sufficient when walking through a larger result set. + +Sorting options are handled through the `Pageable` instance, too. +If you need only sorting, add an `org.springframework.data.domain.Sort` parameter to your method. +As you can see, returning a `List` is also possible. +In this case, the additional metadata required to build the actual `Page` instance is not created (which, in turn, means that the additional count query that would have been necessary is not issued). +Rather, it restricts the query to look up only the given range of entities. + +NOTE: To find out how many pages you get for an entire query, you have to trigger an additional count query. +By default, this query is derived from the query you actually trigger. + +[[repositories.paging-and-sorting]] +==== Paging and Sorting + +You can define simple sorting expressions by using property names. +You can concatenate expressions to collect multiple criteria into one expression. + +.Defining sort expressions +==== +[source,java] +---- +Sort sort = Sort.by("firstname").ascending() + .and(Sort.by("lastname").descending()); +---- +==== + +For a more type-safe way to define sort expressions, start with the type for which to define the sort expression and use method references to define the properties on which to sort. + +.Defining sort expressions by using the type-safe API +==== +[source,java] +---- +TypedSort person = Sort.sort(Person.class); + +Sort sort = person.by(Person::getFirstname).ascending() + .and(person.by(Person::getLastname).descending()); +---- +==== + +NOTE: `TypedSort.by(…)` makes use of runtime proxies by (typically) using CGlib, which may interfere with native image compilation when using tools such as Graal VM Native. + +If your store implementation supports Querydsl, you can also use the generated metamodel types to define sort expressions: + +.Defining sort expressions by using the Querydsl API +==== +[source,java] +---- +QSort sort = QSort.by(QPerson.firstname.asc()) + .and(QSort.by(QPerson.lastname.desc())); +---- +==== + +ifdef::feature-scroll[] +include::repositories-scrolling.adoc[] +endif::[] + +[[repositories.limit-query-result]] +=== Limiting Query Results + +You can limit the results of query methods by using the `first` or `top` keywords, which you can use interchangeably. +You can append an optional numeric value to `top` or `first` to specify the maximum result size to be returned. +If the number is left out, a result size of 1 is assumed. +The following example shows how to limit the query size: + +.Limiting the result size of a query with `Top` and `First` +==== +[source,java] +---- +User findFirstByOrderByLastnameAsc(); + +User findTopByOrderByAgeDesc(); + +Page queryFirst10ByLastname(String lastname, Pageable pageable); + +Slice findTop3ByLastname(String lastname, Pageable pageable); + +List findFirst10ByLastname(String lastname, Sort sort); + +List findTop10ByLastname(String lastname, Pageable pageable); +---- +==== + +The limiting expressions also support the `Distinct` keyword for datastores that support distinct queries. +Also, for the queries that limit the result set to one instance, wrapping the result into with the `Optional` keyword is supported. + +If pagination or slicing is applied to a limiting query pagination (and the calculation of the number of available pages), it is applied within the limited result. + +NOTE: Limiting the results in combination with dynamic sorting by using a `Sort` parameter lets you express query methods for the 'K' smallest as well as for the 'K' biggest elements. diff --git a/src/main/asciidoc/repositories-scrolling.adoc b/src/main/asciidoc/repositories-scrolling.adoc new file mode 100644 index 0000000000..5f90fadfc3 --- /dev/null +++ b/src/main/asciidoc/repositories-scrolling.adoc @@ -0,0 +1,102 @@ +[[repositories.scrolling]] +==== Scrolling + +Scrolling is a more fine-grained approach to iterate through larger results set chunks. +Scrolling consists of a stable sort, a scroll type (Offset- or Keyset-based scrolling) and result limiting. +You can define simple sorting expressions by using property names and define static result limiting using the <> through query derivation. +You can concatenate expressions to collect multiple criteria into one expression. + +Scroll queries return a `Window` that allows obtaining the scroll position to resume to obtain the next `Window` until your application has consumed the entire query result. +Similar to consuming a Java `Iterator>` by obtaining the next batch of results, query result scrolling lets you access the a `ScrollPosition` through `Window.positionAt(...)`. + +[source,java] +---- +Window users = repository.findFirst10ByLastnameOrderByFirstname("Doe", OffsetScrollPosition.initial()); +do { + + for (User u : users) { + // consume the user + } + + // obtain the next Scroll + users = repository.findFirst10ByLastnameOrderByFirstname("Doe", users.positionAt(users.size() - 1)); +} while (!users.isEmpty() && users.hasNext()); +---- + +`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] +---- +WindowIterator users = WindowIterator.of(position -> repository.findFirst10ByLastnameOrderByFirstname("Doe", position)) + .startingAt(OffsetScrollPosition.initial()); + +while (users.hasNext()) { + User u = users.next(); + // consume the user +} +---- + +[[repositories.scrolling.offset]] +===== Scrolling using Offset + +Offset scrolling uses similar to pagination, an Offset counter to skip a number of results and let the data source only return results beginning at the given Offset. +This simple mechanism avoids large results being sent to the client application. +However, most databases require materializing the full query result before your server can return the results. + +.Using `OffsetScrollPosition` with Repository Query Methods +==== +[source,java] +---- +interface UserRepository extends Repository { + + Window findFirst10ByLastnameOrderByFirstname(String lastname, OffsetScrollPosition position); +} + +WindowIterator users = WindowIterator.of(position -> repository.findFirst10ByLastnameOrderByFirstname("Doe", position)) + .startingAt(OffsetScrollPosition.initial()); <1> +---- + +<1> Start from the initial offset at position `0`. +==== + +[[repositories.scrolling.keyset]] +===== Scrolling using Keyset-Filtering + +Offset-based requires most databases require materializing the entire result before your server can return the results. +So while the client only sees the portion of the requested results, your server needs to build the full result, which causes additional load. + +Keyset-Filtering approaches result subset retrieval by leveraging built-in capabilities of your database aiming to reduce the computation and I/O requirements for individual queries. +This approach maintains a set of keys to resume scrolling by passing keys into the query, effectively amending your filter criteria. + +The core idea of Keyset-Filtering is to start retrieving results using a stable sorting order. +Once you want to scroll to the next chunk, you obtain a `ScrollPosition` that is used to reconstruct the position within the sorted result. +The `ScrollPosition` captures the keyset of the last entity within the current `Window`. +To run the query, reconstruction rewrites the criteria clause to include all sort fields and the primary key so that the database can leverage potential indexes to run the query. +The database needs only constructing a much smaller result from the given keyset position without the need to fully materialize a large result and then skipping results until reaching a particular offset. + +[WARNING] +==== +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. +==== + +.Using `KeysetScrollPosition` with Repository Query Methods +==== +[source,java] +---- +interface UserRepository extends Repository { + + Window findFirst10ByLastnameOrderByFirstname(String lastname, KeysetScrollPosition position); +} + +WindowIterator users = WindowIterator.of(position -> repository.findFirst10ByLastnameOrderByFirstname("Doe", position)) + .startingAt(KeysetScrollPosition.initial()); <1> +---- +<1> Start at the very beginning and do not apply additional filtering. +==== + +Keyset-Filtering works best when your database contains an index that matches the sort fields, hence a static sort works well. +Scroll queries applying Keyset-Filtering require to the properties used in the sort order to be returned by the query, and these must be mapped in the returned entity. + +You can use interface and DTO projections, however make sure to include all properties that you've sorted by to avoid keyset extraction failures. diff --git a/src/main/asciidoc/repositories.adoc b/src/main/asciidoc/repositories.adoc index c60fd8d691..2b2a520cd2 100644 --- a/src/main/asciidoc/repositories.adoc +++ b/src/main/asciidoc/repositories.adoc @@ -92,6 +92,10 @@ Page users = repository.findAll(PageRequest.of(1, 20)); ---- ==== +ifdef::feature-scroll[] +In addition to pagination, scrolling provides a more fine-grained access to iterate through chunks of larger result sets. +endif::[] + In addition to query methods, query derivation for both count and delete queries is available. The following list shows the interface definition for a derived count query: @@ -517,118 +521,7 @@ List findByAddress_ZipCode(ZipCode zipCode); Because we treat the underscore character as a reserved character, we strongly advise following standard Java naming conventions (that is, not using underscores in property names but using camel case instead). -[[repositories.special-parameters]] -=== Special parameter handling - -To handle parameters in your query, define method parameters as already seen in the preceding examples. -Besides that, the infrastructure recognizes certain specific types like `Pageable` and `Sort`, to apply pagination and sorting to your queries dynamically. -The following example demonstrates these features: - -.Using `Pageable`, `Slice`, and `Sort` in query methods -==== -[source,java] ----- -Page findByLastname(String lastname, Pageable pageable); - -Slice findByLastname(String lastname, Pageable pageable); - -List findByLastname(String lastname, Sort sort); - -List findByLastname(String lastname, Pageable pageable); ----- -==== - -IMPORTANT: APIs taking `Sort` and `Pageable` expect non-`null` values to be handed into methods. -If you do not want to apply any sorting or pagination, use `Sort.unsorted()` and `Pageable.unpaged()`. - -The first method lets you pass an `org.springframework.data.domain.Pageable` instance to the query method to dynamically add paging to your statically defined query. -A `Page` knows about the total number of elements and pages available. -It does so by the infrastructure triggering a count query to calculate the overall number. -As this might be expensive (depending on the store used), you can instead return a `Slice`. -A `Slice` knows only about whether a next `Slice` is available, which might be sufficient when walking through a larger result set. - -Sorting options are handled through the `Pageable` instance, too. -If you need only sorting, add an `org.springframework.data.domain.Sort` parameter to your method. -As you can see, returning a `List` is also possible. -In this case, the additional metadata required to build the actual `Page` instance is not created (which, in turn, means that the additional count query that would have been necessary is not issued). -Rather, it restricts the query to look up only the given range of entities. - -NOTE: To find out how many pages you get for an entire query, you have to trigger an additional count query. -By default, this query is derived from the query you actually trigger. - -[[repositories.paging-and-sorting]] -==== Paging and Sorting - -You can define simple sorting expressions by using property names. -You can concatenate expressions to collect multiple criteria into one expression. - -.Defining sort expressions -==== -[source,java] ----- -Sort sort = Sort.by("firstname").ascending() - .and(Sort.by("lastname").descending()); ----- -==== - -For a more type-safe way to define sort expressions, start with the type for which to define the sort expression and use method references to define the properties on which to sort. - -.Defining sort expressions by using the type-safe API -==== -[source,java] ----- -TypedSort person = Sort.sort(Person.class); - -Sort sort = person.by(Person::getFirstname).ascending() - .and(person.by(Person::getLastname).descending()); ----- -==== - -NOTE: `TypedSort.by(…)` makes use of runtime proxies by (typically) using CGlib, which may interfere with native image compilation when using tools such as Graal VM Native. - -If your store implementation supports Querydsl, you can also use the generated metamodel types to define sort expressions: - -.Defining sort expressions by using the Querydsl API -==== -[source,java] ----- -QSort sort = QSort.by(QPerson.firstname.asc()) - .and(QSort.by(QPerson.lastname.desc())); ----- -==== - -[[repositories.limit-query-result]] -=== Limiting Query Results - -You can limit the results of query methods by using the `first` or `top` keywords, which you can use interchangeably. -You can append an optional numeric value to `top` or `first` to specify the maximum result size to be returned. -If the number is left out, a result size of 1 is assumed. -The following example shows how to limit the query size: - -.Limiting the result size of a query with `Top` and `First` -==== -[source,java] ----- -User findFirstByOrderByLastnameAsc(); - -User findTopByOrderByAgeDesc(); - -Page queryFirst10ByLastname(String lastname, Pageable pageable); - -Slice findTop3ByLastname(String lastname, Pageable pageable); - -List findFirst10ByLastname(String lastname, Sort sort); - -List findTop10ByLastname(String lastname, Pageable pageable); ----- -==== - -The limiting expressions also support the `Distinct` keyword for datastores that support distinct queries. -Also, for the queries that limit the result set to one instance, wrapping the result into with the `Optional` keyword is supported. - -If pagination or slicing is applied to a limiting query pagination (and the calculation of the number of available pages), it is applied within the limited result. - -NOTE: Limiting the results in combination with dynamic sorting by using a `Sort` parameter lets you express query methods for the 'K' smallest as well as for the 'K' biggest elements. +include::repositories-paging-sorting.adoc[] [[repositories.collections-and-iterables]] === Repository Methods Returning Collections or Iterables diff --git a/src/main/java/org/springframework/data/domain/KeysetScrollPosition.java b/src/main/java/org/springframework/data/domain/KeysetScrollPosition.java new file mode 100644 index 0000000000..81b3058e21 --- /dev/null +++ b/src/main/java/org/springframework/data/domain/KeysetScrollPosition.java @@ -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 keys; + + private final Direction direction; + + private KeysetScrollPosition(Map 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 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 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 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. + */ + public 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; + } +} diff --git a/src/main/java/org/springframework/data/domain/OffsetScrollPosition.java b/src/main/java/org/springframework/data/domain/OffsetScrollPosition.java new file mode 100644 index 0000000000..07000c7b8a --- /dev/null +++ b/src/main/java/org/springframework/data/domain/OffsetScrollPosition.java @@ -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 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 { + + 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); + } + } +} diff --git a/src/main/java/org/springframework/data/domain/Pageable.java b/src/main/java/org/springframework/data/domain/Pageable.java index 7fd873f721..4486394de6 100644 --- a/src/main/java/org/springframework/data/domain/Pageable.java +++ b/src/main/java/org/springframework/data/domain/Pageable.java @@ -161,4 +161,20 @@ default Optional toOptional() { 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()); + } + } diff --git a/src/main/java/org/springframework/data/domain/ScrollPosition.java b/src/main/java/org/springframework/data/domain/ScrollPosition.java new file mode 100644 index 0000000000..0eeba7530a --- /dev/null +++ b/src/main/java/org/springframework/data/domain/ScrollPosition.java @@ -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(); +} diff --git a/src/main/java/org/springframework/data/domain/Window.java b/src/main/java/org/springframework/data/domain/Window.java new file mode 100644 index 0000000000..536e58f08d --- /dev/null +++ b/src/main/java/org/springframework/data/domain/Window.java @@ -0,0 +1,154 @@ +/* + * 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 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 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 + * @author Christoph Strobl + * @since 3.1 + * @see ScrollPosition + */ +public interface Window extends Streamable { + + /** + * Construct a {@link Window}. + * + * @param items the list of data. + * @param positionFunction the list of data. + * @return the {@link Window}. + * @param + */ + static Window from(List items, IntFunction positionFunction) { + return new WindowImpl<>(items, positionFunction, false); + } + + /** + * Construct a {@link Window}. + * + * @param items the list of data. + * @param positionFunction the list of data. + * @param hasNext + * @return the {@link Window}. + * @param + */ + static Window from(List items, IntFunction positionFunction, boolean hasNext) { + return new WindowImpl<>(items, positionFunction, hasNext); + } + + /** + * Returns the number of elements in this window. + * + * @return the number of elements in this window. + */ + int size(); + + /** + * Returns {@code true} if this window contains no elements. + * + * @return {@code true} if this window contains no elements + */ + boolean isEmpty(); + + /** + * Returns the windows content as {@link List}. + * + * @return + */ + List getContent(); + + /** + * Returns whether the current window is the last one. + * + * @return + */ + default boolean isLast() { + return !hasNext(); + } + + /** + * Returns if there is a next 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); + + /** + * Returns the {@link ScrollPosition} for {@code object}. + * + * @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) { + + int index = getContent().indexOf(object); + + if (index == -1) { + throw new NoSuchElementException(); + } + + return positionAt(index); + } + + /** + * Returns a new {@link Window} with the content of the current one mapped by the given {@code converter}. + * + * @param converter must not be {@literal null}. + * @return a new {@link Window} with the content of the current one mapped by the given {@code converter}. + */ + Window map(Function converter); + +} diff --git a/src/main/java/org/springframework/data/domain/WindowImpl.java b/src/main/java/org/springframework/data/domain/WindowImpl.java new file mode 100644 index 0000000000..2272447f4e --- /dev/null +++ b/src/main/java/org/springframework/data/domain/WindowImpl.java @@ -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 Window} implementation. + * + * @author Mark Paluch + * @since 3.1 + */ +class WindowImpl implements Window { + + private final List items; + private final IntFunction positionFunction; + + private final boolean hasNext; + + WindowImpl(List items, IntFunction 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 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 Window map(Function converter) { + + Assert.notNull(converter, "Function must not be null"); + + return new WindowImpl<>(stream().map(converter).collect(Collectors.toList()), positionFunction, hasNext); + } + + @NotNull + @Override + public Iterator iterator() { + return items.iterator(); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + WindowImpl that = (WindowImpl) 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; + } +} diff --git a/src/main/java/org/springframework/data/domain/WindowIterator.java b/src/main/java/org/springframework/data/domain/WindowIterator.java new file mode 100644 index 0000000000..923dd44cf7 --- /dev/null +++ b/src/main/java/org/springframework/data/domain/WindowIterator.java @@ -0,0 +1,145 @@ +/* + * 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.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} allowing scrolling across all result elements. + * + *
+ * WindowIterator<User> users = WindowIterator.of(position -> repository.findFirst10By…("spring", position))
+ *   .startingAt(OffsetScrollPosition.initial());
+ *
+ * while (users.hasNext()) {
+ *   User u = users.next();
+ *   // consume user
+ * }
+ * 
+ * + * @author Christoph Strobl + * @author Mark Paluch + * @since 3.1 + */ +public class WindowIterator implements Iterator { + + private final Function> windowFunction; + + private ScrollPosition currentPosition; + + private @Nullable Window currentWindow; + + private @Nullable Iterator currentIterator; + + /** + * Entrypoint to create a new {@link WindowIterator} for the given windowFunction. + * + * @param windowFunction must not be {@literal null}. + * @param + * @return new instance of {@link WindowIteratorBuilder}. + */ + public static WindowIteratorBuilder of(Function> windowFunction) { + return new WindowIteratorBuilder<>(windowFunction); + } + + WindowIterator(Function> windowFunction, ScrollPosition position) { + + this.windowFunction = windowFunction; + this.currentPosition = position; + } + + @Override + public boolean hasNext() { + + // 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 T next() { + + if (!hasNext()) { + throw new NoSuchElementException(); + } + + return currentIterator.next(); + } + + /** + * Builder API to construct a {@link WindowIterator}. + * + * @param + * @author Christoph Strobl + * @since 3.1 + */ + public static class WindowIteratorBuilder { + + 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.notNull(position, "ScrollPosition must not be null"); + + return new WindowIterator<>(windowFunction, position); + } + } +} diff --git a/src/main/java/org/springframework/data/repository/query/FluentQuery.java b/src/main/java/org/springframework/data/repository/query/FluentQuery.java index 909a0d7169..b36437dfbd 100644 --- a/src/main/java/org/springframework/data/repository/query/FluentQuery.java +++ b/src/main/java/org/springframework/data/repository/query/FluentQuery.java @@ -15,6 +15,7 @@ */ package org.springframework.data.repository.query; +import org.springframework.data.domain.Window; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -26,6 +27,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Sort; import org.springframework.lang.Nullable; @@ -44,10 +46,23 @@ public interface FluentQuery { * @param sort the {@link Sort} specification to sort the results by, may be {@link Sort#unsorted()}, must not be * {@literal null}. * @return a new instance of {@link FluentQuery}. - * @throws IllegalArgumentException if resultType is {@code null}. + * @throws IllegalArgumentException if {@code sort} is {@code null}. */ FluentQuery sortBy(Sort sort); + /** + * Define the query limit. + * + * @param limit the limit to apply to the query to limit results. Must not be negative. + * @return a new instance of {@link FluentQuery}. + * @throws IllegalArgumentException if {@code limit} is less than zero. + * @throws UnsupportedOperationException if not supported by the underlying implementation. + * @since 3.1 + */ + default FluentQuery limit(int limit) { + throw new UnsupportedOperationException("Limit not supported"); + } + /** * Define the target type the result should be mapped to. Skip this step if you are only interested in the original * domain type. @@ -55,7 +70,7 @@ public interface FluentQuery { * @param resultType must not be {@code null}. * @param result type. * @return a new instance of {@link FluentQuery}. - * @throws IllegalArgumentException if resultType is {@code null}. + * @throws IllegalArgumentException if {@code resultType} is {@code null}. */ FluentQuery as(Class resultType); @@ -64,7 +79,7 @@ public interface FluentQuery { * * @param properties must not be {@code null}. * @return a new instance of {@link FluentQuery}. - * @throws IllegalArgumentException if fields is {@code null}. + * @throws IllegalArgumentException if {@code properties} is {@code null}. */ default FluentQuery project(String... properties) { return project(Arrays.asList(properties)); @@ -75,7 +90,7 @@ default FluentQuery project(String... properties) { * * @param properties must not be {@code null}. * @return a new instance of {@link FluentQuery}. - * @throws IllegalArgumentException if fields is {@code null}. + * @throws IllegalArgumentException if {@code properties} is {@code null}. */ FluentQuery project(Collection properties); @@ -90,6 +105,11 @@ interface FetchableFluentQuery extends FluentQuery { @Override FetchableFluentQuery sortBy(Sort sort); + @Override + default FetchableFluentQuery limit(int limit) { + throw new UnsupportedOperationException("Limit not supported"); + } + @Override FetchableFluentQuery as(Class resultType); @@ -144,12 +164,27 @@ default Optional first() { */ List all(); + /** + * Get all matching elements as {@link Window} to start result scrolling or resume scrolling at + * {@code scrollPosition}. + * + * @param scrollPosition must not be {@literal null}. + * @return + * @throws IllegalArgumentException if {@code scrollPosition} is {@literal null}. + * @throws UnsupportedOperationException if not supported by the underlying implementation. + * @since 3.1 + */ + default Window scroll(ScrollPosition scrollPosition) { + throw new UnsupportedOperationException("Scrolling not supported"); + } + /** * Get a page of matching elements for {@link Pageable}. * * @param pageable the pageable to request a paged result, can be {@link Pageable#unpaged()}, must not be * {@literal null}. The given {@link Pageable} will override any previously specified {@link Sort sort} if - * the {@link Sort} object is not {@link Sort#isUnsorted()}. + * the {@link Sort} object is not {@link Sort#isUnsorted()}. Any potentially specified {@link #limit(int)} + * will be overridden by {@link Pageable#getPageSize()}. * @return */ Page page(Pageable pageable); @@ -187,6 +222,11 @@ interface ReactiveFluentQuery extends FluentQuery { @Override ReactiveFluentQuery sortBy(Sort sort); + @Override + default ReactiveFluentQuery limit(int limit) { + throw new UnsupportedOperationException("Limit not supported"); + } + @Override ReactiveFluentQuery as(Class resultType); @@ -220,12 +260,27 @@ default ReactiveFluentQuery project(String... properties) { */ Flux all(); + /** + * Get all matching elements as {@link Window} to start result scrolling or resume scrolling at + * {@code scrollPosition}. + * + * @param scrollPosition must not be {@literal null}. + * @return + * @throws IllegalArgumentException if {@code scrollPosition} is {@literal null}. + * @throws UnsupportedOperationException if not supported by the underlying implementation. + * @since 3.1 + */ + default Mono> scroll(ScrollPosition scrollPosition) { + throw new UnsupportedOperationException("Scrolling not supported"); + } + /** * Get a page of matching elements for {@link Pageable}. * * @param pageable the pageable to request a paged result, can be {@link Pageable#unpaged()}, must not be * {@literal null}. The given {@link Pageable} will override any previously specified {@link Sort sort} if - * the {@link Sort} object is not {@link Sort#isUnsorted()}. + * the {@link Sort} object is not {@link Sort#isUnsorted()}. Any potentially specified {@link #limit(int)} + * will be overridden by {@link Pageable#getPageSize()}. * @return */ Mono> page(Pageable pageable); diff --git a/src/main/java/org/springframework/data/repository/query/Parameter.java b/src/main/java/org/springframework/data/repository/query/Parameter.java index 304772e50c..c751db67c5 100644 --- a/src/main/java/org/springframework/data/repository/query/Parameter.java +++ b/src/main/java/org/springframework/data/repository/query/Parameter.java @@ -26,6 +26,7 @@ import org.springframework.core.MethodParameter; import org.springframework.core.ResolvableType; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Sort; import org.springframework.data.repository.util.ClassUtils; import org.springframework.data.repository.util.QueryExecutionConverters; @@ -57,7 +58,7 @@ public class Parameter { static { - List> types = new ArrayList<>(Arrays.asList(Pageable.class, Sort.class)); + List> types = new ArrayList<>(Arrays.asList(ScrollPosition.class, Pageable.class, Sort.class)); // consider Kotlin Coroutines Continuation a special parameter. That parameter is synthetic and should not get // bound to any query. @@ -192,6 +193,16 @@ public String toString() { return format("%s:%s", isNamedParameter() ? getName() : "#" + getIndex(), getType().getName()); } + /** + * Returns whether the {@link Parameter} is a {@link ScrollPosition} parameter. + * + * @return + * @since 3.1 + */ + boolean isScrollPosition() { + return ScrollPosition.class.isAssignableFrom(getType()); + } + /** * Returns whether the {@link Parameter} is a {@link Pageable} parameter. * diff --git a/src/main/java/org/springframework/data/repository/query/ParameterAccessor.java b/src/main/java/org/springframework/data/repository/query/ParameterAccessor.java index f5ccd7ec3d..369c781a56 100644 --- a/src/main/java/org/springframework/data/repository/query/ParameterAccessor.java +++ b/src/main/java/org/springframework/data/repository/query/ParameterAccessor.java @@ -18,6 +18,7 @@ import java.util.Iterator; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Sort; import org.springframework.lang.Nullable; @@ -29,6 +30,14 @@ */ public interface ParameterAccessor extends Iterable { + /** + * Returns the {@link ScrollPosition} of the parameters, if available. Returns {@code null} otherwise. + * + * @return + */ + @Nullable + ScrollPosition getScrollPosition(); + /** * Returns the {@link Pageable} of the parameters, if available. Returns {@link Pageable#unpaged()} otherwise. * diff --git a/src/main/java/org/springframework/data/repository/query/Parameters.java b/src/main/java/org/springframework/data/repository/query/Parameters.java index 24426f4bd0..523f127995 100644 --- a/src/main/java/org/springframework/data/repository/query/Parameters.java +++ b/src/main/java/org/springframework/data/repository/query/Parameters.java @@ -28,6 +28,7 @@ import org.springframework.core.MethodParameter; import org.springframework.core.ParameterNameDiscoverer; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Sort; import org.springframework.data.util.Lazy; import org.springframework.data.util.Streamable; @@ -42,7 +43,7 @@ */ public abstract class Parameters, T extends Parameter> implements Streamable { - public static final List> TYPES = Arrays.asList(Pageable.class, Sort.class); + public static final List> TYPES = Arrays.asList(ScrollPosition.class, Pageable.class, Sort.class); private static final String PARAM_ON_SPECIAL = format("You must not use @%s on a parameter typed %s or %s", Param.class.getSimpleName(), Pageable.class.getSimpleName(), Sort.class.getSimpleName()); @@ -52,6 +53,7 @@ public abstract class Parameters, T extends Parameter private static final ParameterNameDiscoverer PARAMETER_NAME_DISCOVERER = new DefaultParameterNameDiscoverer(); + private final int scrollPositionIndex; private final int pageableIndex; private final int sortIndex; private final List parameters; @@ -91,6 +93,7 @@ protected Parameters(Method method, Function parameterFactor this.parameters = new ArrayList<>(parameterCount); this.dynamicProjectionIndex = -1; + int scrollPositionIndex = -1; int pageableIndex = -1; int sortIndex = -1; @@ -111,6 +114,10 @@ protected Parameters(Method method, Function parameterFactor this.dynamicProjectionIndex = parameter.getIndex(); } + if (ScrollPosition.class.isAssignableFrom(parameter.getType())) { + scrollPositionIndex = i; + } + if (Pageable.class.isAssignableFrom(parameter.getType())) { pageableIndex = i; } @@ -122,6 +129,7 @@ protected Parameters(Method method, Function parameterFactor parameters.add(parameter); } + this.scrollPositionIndex = scrollPositionIndex; this.pageableIndex = pageableIndex; this.sortIndex = sortIndex; this.bindable = Lazy.of(this::getBindable); @@ -138,6 +146,7 @@ protected Parameters(List originals) { this.parameters = new ArrayList<>(originals.size()); + int scrollPositionIndexTemp = -1; int pageableIndexTemp = -1; int sortIndexTemp = -1; int dynamicProjectionTemp = -1; @@ -147,11 +156,13 @@ protected Parameters(List originals) { T original = originals.get(i); this.parameters.add(original); + scrollPositionIndexTemp = original.isScrollPosition() ? i : -1; pageableIndexTemp = original.isPageable() ? i : -1; sortIndexTemp = original.isSort() ? i : -1; dynamicProjectionTemp = original.isDynamicProjectionParameter() ? i : -1; } + this.scrollPositionIndex = scrollPositionIndexTemp; this.pageableIndex = pageableIndexTemp; this.sortIndex = sortIndexTemp; this.dynamicProjectionIndex = dynamicProjectionTemp; @@ -185,6 +196,27 @@ protected T createParameter(MethodParameter parameter) { return (T) new Parameter(parameter); } + /** + * Returns whether the method the {@link Parameters} was created for contains a {@link ScrollPosition} argument. + * + * @return + * @since 3.1 + */ + public boolean hasScrollPositionParameter() { + return scrollPositionIndex != -1; + } + + /** + * Returns the index of the {@link ScrollPosition} {@link Method} parameter if available. Will return {@literal -1} if + * there is no {@link ScrollPosition} argument in the {@link Method}'s parameter list. + * + * @return the scrollPositionIndex + * @since 3.1 + */ + public int getScrollPositionIndex() { + return scrollPositionIndex; + } + /** * Returns whether the method the {@link Parameters} was created for contains a {@link Pageable} argument. * @@ -195,8 +227,8 @@ public boolean hasPageableParameter() { } /** - * Returns the index of the {@link Pageable} {@link Method} parameter if available. Will return {@literal -1} if - * there is no {@link Pageable} argument in the {@link Method}'s parameter list. + * Returns the index of the {@link Pageable} {@link Method} parameter if available. Will return {@literal -1} if there + * is no {@link Pageable} argument in the {@link Method}'s parameter list. * * @return the pageableIndex */ @@ -205,8 +237,8 @@ public int getPageableIndex() { } /** - * Returns the index of the {@link Sort} {@link Method} parameter if available. Will return {@literal -1} if there - * is no {@link Sort} argument in the {@link Method}'s parameter list. + * Returns the index of the {@link Sort} {@link Method} parameter if available. Will return {@literal -1} if there is + * no {@link Sort} argument in the {@link Method}'s parameter list. * * @return */ @@ -288,7 +320,7 @@ public boolean hasParameterAt(int position) { * @return */ public boolean hasSpecialParameter() { - return hasSortParameter() || hasPageableParameter(); + return hasScrollPositionParameter() || hasSortParameter() || hasPageableParameter(); } /** @@ -315,8 +347,8 @@ public S getBindableParameters() { /** * Returns a bindable parameter with the given index. So for a method with a signature of - * {@code (Pageable pageable, String name)} a call to {@code #getBindableParameter(0)} will return the - * {@link String} parameter. + * {@code (Pageable pageable, String name)} a call to {@code #getBindableParameter(0)} will return the {@link String} + * parameter. * * @param bindableIndex * @return diff --git a/src/main/java/org/springframework/data/repository/query/ParametersParameterAccessor.java b/src/main/java/org/springframework/data/repository/query/ParametersParameterAccessor.java index 0b056142ba..045ab16b1f 100644 --- a/src/main/java/org/springframework/data/repository/query/ParametersParameterAccessor.java +++ b/src/main/java/org/springframework/data/repository/query/ParametersParameterAccessor.java @@ -18,6 +18,7 @@ import java.util.Iterator; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Sort; import org.springframework.data.repository.util.QueryExecutionConverters; import org.springframework.data.repository.util.ReactiveWrapperConverters; @@ -91,6 +92,22 @@ protected Object[] getValues() { return this.values; } + @Override + public ScrollPosition getScrollPosition() { + + if (!parameters.hasScrollPositionParameter()) { + + Pageable pageable = getPageable(); + if (pageable.isPaged()) { + return pageable.toScrollPosition(); + } + + return null; + } + + return (ScrollPosition) values[parameters.getScrollPositionIndex()]; + } + @Override public Pageable getPageable() { diff --git a/src/main/java/org/springframework/data/repository/query/QueryMethod.java b/src/main/java/org/springframework/data/repository/query/QueryMethod.java index 9a39f271ce..1d12f533d8 100644 --- a/src/main/java/org/springframework/data/repository/query/QueryMethod.java +++ b/src/main/java/org/springframework/data/repository/query/QueryMethod.java @@ -18,11 +18,14 @@ import static org.springframework.data.repository.util.ClassUtils.*; import java.lang.reflect.Method; +import java.util.Collections; import java.util.Set; import java.util.stream.Stream; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Window; +import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; import org.springframework.data.projection.ProjectionFactory; @@ -31,6 +34,7 @@ import org.springframework.data.repository.util.QueryExecutionConverters; import org.springframework.data.repository.util.ReactiveWrapperConverters; import org.springframework.data.util.Lazy; +import org.springframework.data.util.ReactiveWrappers; import org.springframework.data.util.TypeInformation; import org.springframework.util.Assert; @@ -92,6 +96,10 @@ public QueryMethod(Method method, RepositoryMetadata metadata, ProjectionFactory } } + if (hasParameterOfType(method, ScrollPosition.class)) { + assertReturnTypeAssignable(method, Collections.singleton(Window.class)); + } + Assert.notNull(this.parameters, () -> String.format("Parameters extracted from method '%s' must not be null", method.getName())); @@ -100,6 +108,12 @@ public QueryMethod(Method method, RepositoryMetadata metadata, ProjectionFactory String.format("Paging query needs to have a Pageable parameter; Offending method: %s", method)); } + if (isScrollQuery()) { + + Assert.isTrue(this.parameters.hasScrollPositionParameter() || this.parameters.hasPageableParameter(), + String.format("Scroll query needs to have a ScrollPosition parameter; Offending method: %s", method)); + } + this.domainClass = Lazy.of(() -> { Class repositoryDomainClass = metadata.getDomainType(); @@ -188,6 +202,16 @@ public boolean isCollectionQuery() { return isCollectionQuery.get(); } + /** + * Returns whether the query method will return a {@link Window}. + * + * @return + * @since 3.1 + */ + public boolean isScrollQuery() { + return org.springframework.util.ClassUtils.isAssignable(Window.class, unwrappedReturnType); + } + /** * Returns whether the query method will return a {@link Slice}. * @@ -268,7 +292,7 @@ public String toString() { private boolean calculateIsCollectionQuery() { - if (isPageQuery() || isSliceQuery()) { + if (isPageQuery() || isSliceQuery() || isScrollQuery()) { return false; } @@ -314,6 +338,10 @@ private static void assertReturnTypeAssignable(Method method, Set> type // TODO: to resolve generics fully we'd need the actual repository interface here TypeInformation returnType = TypeInformation.fromReturnTypeOf(method); + returnType = ReactiveWrappers.isSingleValueType(returnType.getType()) // + ? returnType.getRequiredComponentType() // + : returnType; + returnType = QueryExecutionConverters.isSingleValue(returnType.getType()) // ? returnType.getRequiredComponentType() // : returnType; @@ -324,6 +352,6 @@ private static void assertReturnTypeAssignable(Method method, Set> type } } - throw new IllegalStateException("Method has to have one of the following return types" + types); + throw new IllegalStateException("Method has to have one of the following return types " + types); } } diff --git a/src/main/java/org/springframework/data/repository/query/ResultProcessor.java b/src/main/java/org/springframework/data/repository/query/ResultProcessor.java index b91449dac9..4083df7c5e 100644 --- a/src/main/java/org/springframework/data/repository/query/ResultProcessor.java +++ b/src/main/java/org/springframework/data/repository/query/ResultProcessor.java @@ -26,6 +26,7 @@ import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.converter.Converter; import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.data.domain.Window; import org.springframework.data.domain.Slice; import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.repository.util.ReactiveWrapperConverters; @@ -143,6 +144,10 @@ public T processResult(@Nullable Object source, Converter pr ChainingConverter converter = ChainingConverter.of(type.getReturnedType(), preparingConverter).and(this.converter); + if (source instanceof Window && method.isScrollQuery()) { + return (T) ((Window) source).map(converter::convert); + } + if (source instanceof Slice && (method.isPageQuery() || method.isSliceQuery())) { return (T) ((Slice) source).map(converter::convert); } @@ -163,7 +168,7 @@ public T processResult(@Nullable Object source, Converter pr } if (ReactiveWrapperConverters.supports(source.getClass())) { - return (T) ReactiveWrapperConverters.map(source, converter::convert); + return (T) ReactiveWrapperConverters.map(source, it -> processResult(it, preparingConverter)); } return (T) converter.convert(source); diff --git a/src/main/java/org/springframework/data/repository/util/QueryExecutionConverters.java b/src/main/java/org/springframework/data/repository/util/QueryExecutionConverters.java index 281c13a742..b900480c21 100644 --- a/src/main/java/org/springframework/data/repository/util/QueryExecutionConverters.java +++ b/src/main/java/org/springframework/data/repository/util/QueryExecutionConverters.java @@ -36,6 +36,7 @@ import org.springframework.core.convert.support.ConfigurableConversionService; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.data.domain.Page; +import org.springframework.data.domain.Window; import org.springframework.data.domain.Slice; import org.springframework.data.geo.GeoResults; import org.springframework.data.util.CustomCollections; @@ -97,6 +98,7 @@ public abstract class QueryExecutionConverters { ALLOWED_PAGEABLE_TYPES.add(Slice.class); ALLOWED_PAGEABLE_TYPES.add(Page.class); ALLOWED_PAGEABLE_TYPES.add(List.class); + ALLOWED_PAGEABLE_TYPES.add(Window.class); WRAPPER_TYPES.add(NullableWrapperToCompletableFutureConverter.getWrapperType()); diff --git a/src/main/java/org/springframework/data/util/CustomCollections.java b/src/main/java/org/springframework/data/util/CustomCollections.java index c0533c275d..21857bf006 100644 --- a/src/main/java/org/springframework/data/util/CustomCollections.java +++ b/src/main/java/org/springframework/data/util/CustomCollections.java @@ -223,7 +223,7 @@ public boolean hasSuperTypeFor(Class type) { /** * Returns whether the current's raw type is one of the given ones. * - * @param candidates must not be {@literal null}. + * @param type must not be {@literal null}. * @return */ public boolean has(Class type) { diff --git a/src/test/java/org/springframework/data/domain/KeysetScrollPositionUnitTests.java b/src/test/java/org/springframework/data/domain/KeysetScrollPositionUnitTests.java new file mode 100644 index 0000000000..6fe28d4598 --- /dev/null +++ b/src/test/java/org/springframework/data/domain/KeysetScrollPositionUnitTests.java @@ -0,0 +1,43 @@ +/* + * 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 static org.assertj.core.api.Assertions.*; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.KeysetScrollPosition.Direction; + +/** + * Unit tests for {@link KeysetScrollPosition}. + * + * @author Mark Paluch + */ +class KeysetScrollPositionUnitTests { + + @Test // GH-2151 + void equalsAndHashCode() { + + KeysetScrollPosition foo1 = KeysetScrollPosition.of(Collections.singletonMap("k", "v")); + KeysetScrollPosition foo2 = KeysetScrollPosition.of(Collections.singletonMap("k", "v")); + KeysetScrollPosition bar = KeysetScrollPosition.of(Collections.singletonMap("k", "v"), Direction.Backward); + + assertThat(foo1).isEqualTo(foo2).hasSameClassAs(foo2); + assertThat(foo1).isNotEqualTo(bar).doesNotHaveSameHashCodeAs(bar); + } + +} diff --git a/src/test/java/org/springframework/data/domain/OffsetScrollPositionUnitTests.java b/src/test/java/org/springframework/data/domain/OffsetScrollPositionUnitTests.java new file mode 100644 index 0000000000..a04f57752a --- /dev/null +++ b/src/test/java/org/springframework/data/domain/OffsetScrollPositionUnitTests.java @@ -0,0 +1,50 @@ +/* + * 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 static org.assertj.core.api.Assertions.*; +import static org.springframework.data.domain.OffsetScrollPosition.*; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link OffsetScrollPosition}. + * + * @author Mark Paluch + */ +class OffsetScrollPositionUnitTests { + + @Test // GH-2151 + void equalsAndHashCode() { + + OffsetScrollPosition foo1 = OffsetScrollPosition.of(1); + OffsetScrollPosition foo2 = OffsetScrollPosition.of(1); + OffsetScrollPosition bar = OffsetScrollPosition.of(2); + + assertThat(foo1).isEqualTo(foo2).hasSameClassAs(foo2); + assertThat(foo1).isNotEqualTo(bar).doesNotHaveSameHashCodeAs(bar); + } + + @Test // GH-2151 + void shouldCreateCorrectIndexPosition() { + + assertThat(positionFunction(0).apply(0)).isEqualTo(OffsetScrollPosition.of(1)); + assertThat(positionFunction(0).apply(1)).isEqualTo(OffsetScrollPosition.of(2)); + + assertThat(positionFunction(100).apply(0)).isEqualTo(OffsetScrollPosition.of(101)); + assertThat(positionFunction(100).apply(1)).isEqualTo(OffsetScrollPosition.of(102)); + } +} diff --git a/src/test/java/org/springframework/data/domain/PageRequestUnitTests.java b/src/test/java/org/springframework/data/domain/PageRequestUnitTests.java index 0727553d8e..fe0bfe31f6 100755 --- a/src/test/java/org/springframework/data/domain/PageRequestUnitTests.java +++ b/src/test/java/org/springframework/data/domain/PageRequestUnitTests.java @@ -19,7 +19,6 @@ import static org.springframework.data.domain.UnitTestUtils.*; import org.junit.jupiter.api.Test; - import org.springframework.data.domain.Sort.Direction; /** @@ -67,4 +66,12 @@ void rejectsNullSort() { assertThatIllegalArgumentException() // .isThrownBy(() -> PageRequest.of(0, 10, null)); } + + @Test // GH-2151 + void createsOffsetScrollPosition() { + + PageRequest request = PageRequest.of(1, 10); + + assertThat(request.toScrollPosition()).isEqualTo(OffsetScrollPosition.of(10)); + } } diff --git a/src/test/java/org/springframework/data/domain/WindowIteratorUnitTests.java b/src/test/java/org/springframework/data/domain/WindowIteratorUnitTests.java new file mode 100644 index 0000000000..9b27d15fe1 --- /dev/null +++ b/src/test/java/org/springframework/data/domain/WindowIteratorUnitTests.java @@ -0,0 +1,129 @@ +/* + * 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 static org.assertj.core.api.Assertions.*; +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.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +/** + * Unit tests for {@link WindowIterator}. + * + * @author Christoph Strobl + * @author Mark Paluch + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class WindowIteratorUnitTests { + + @Test // GH-2151 + void loadsDataOnNext() { + + Function> fkt = mock(Function.class); + WindowIterator iterator = WindowIterator.of(fkt).startingAt(OffsetScrollPosition.initial()); + verifyNoInteractions(fkt); + + when(fkt.apply(any())).thenReturn(Window.from(Collections.emptyList(), value -> OffsetScrollPosition.initial())); + + iterator.hasNext(); + verify(fkt).apply(OffsetScrollPosition.initial()); + } + + @Test // GH-2151 + void hasNextReturnsFalseIfNoDataAvailable() { + + Window window = Window.from(Collections.emptyList(), value -> OffsetScrollPosition.initial()); + WindowIterator iterator = WindowIterator.of(it -> window).startingAt(OffsetScrollPosition.initial()); + + 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() { + + Window window = Window.from(List.of("a", "b"), value -> OffsetScrollPosition.initial()); + WindowIterator iterator = WindowIterator.of(it -> window).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 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() { + + 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; + } + + return window2; + }).startingAt(OffsetScrollPosition.initial()); + + List capturedResult = new ArrayList<>(4); + while (iterator.hasNext()) { + capturedResult.add(iterator.next()); + } + + assertThat(capturedResult).containsExactly("a", "b", "c", "d"); + } +} diff --git a/src/test/java/org/springframework/data/domain/WindowUnitTests.java b/src/test/java/org/springframework/data/domain/WindowUnitTests.java new file mode 100644 index 0000000000..210fc9327e --- /dev/null +++ b/src/test/java/org/springframework/data/domain/WindowUnitTests.java @@ -0,0 +1,70 @@ +/* + * 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 static org.assertj.core.api.Assertions.*; + +import java.util.List; +import java.util.function.IntFunction; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link Window}. + * + * @author Mark Paluch + * @author Christoph Strobl + */ +class WindowUnitTests { + + @Test // GH-2151 + void equalsAndHashCode() { + + IntFunction positionFunction = OffsetScrollPosition.positionFunction(0); + Window one = Window.from(List.of(1, 2, 3), positionFunction); + Window two = Window.from(List.of(1, 2, 3), positionFunction); + + assertThat(one).isEqualTo(two).hasSameHashCodeAs(two); + assertThat(one.equals(two)).isTrue(); + + assertThat(Window.from(List.of(1, 2, 3), positionFunction, true)).isNotEqualTo(two).doesNotHaveSameHashCodeAs(two); + } + + @Test // GH-2151 + void allowsIteration() { + + Window window = Window.from(List.of(1, 2, 3), OffsetScrollPosition.positionFunction(0)); + + for (Integer integer : window) { + assertThat(integer).isBetween(1, 3); + } + } + + @Test // GH-2151 + void shouldCreateCorrectPositions() { + + Window window = Window.from(List.of(1, 2, 3), OffsetScrollPosition.positionFunction(0)); + + assertThat(window.positionAt(0)).isEqualTo(OffsetScrollPosition.of(1)); + assertThat(window.positionAt(window.size() - 1)).isEqualTo(OffsetScrollPosition.of(3)); + + // by index + assertThat(window.positionAt(1)).isEqualTo(OffsetScrollPosition.of(2)); + + // by object + assertThat(window.positionAt(Integer.valueOf(1))).isEqualTo(OffsetScrollPosition.of(1)); + } +} diff --git a/src/test/java/org/springframework/data/repository/query/ParametersParameterAccessorUnitTests.java b/src/test/java/org/springframework/data/repository/query/ParametersParameterAccessorUnitTests.java index 2bb109e9c6..68c7b51684 100755 --- a/src/test/java/org/springframework/data/repository/query/ParametersParameterAccessorUnitTests.java +++ b/src/test/java/org/springframework/data/repository/query/ParametersParameterAccessorUnitTests.java @@ -19,14 +19,17 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.data.domain.OffsetScrollPosition; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScrollPosition; /** * Unit tests for {@link ParametersParameterAccessor}. * * @author Oliver Gierke * @author Greg Turnquist + * @author Mark Paluch */ class ParametersParameterAccessorUnitTests { @@ -75,6 +78,18 @@ void iteratesonlyOverBindableValues() throws Exception { assertThat(accessor.getBindableValue(0)).isEqualTo("Foo"); } + @Test // GH-2151 + void handlesScrollPositionAsAParameterType() throws NoSuchMethodException { + + var method = Sample.class.getMethod("method", ScrollPosition.class, String.class); + var parameters = new DefaultParameters(method); + + var accessor = new ParametersParameterAccessor(parameters, new Object[] { OffsetScrollPosition.of(1), "Foo" }); + + assertThat(accessor).hasSize(1); + assertThat(accessor.getBindableValue(0)).isEqualTo("Foo"); + } + @Test // #2626 void handlesPageRequestAsAParameterType() throws NoSuchMethodException { @@ -93,6 +108,8 @@ interface Sample { void method(Pageable pageable, String string); + void method(ScrollPosition scrollPosition, String string); + void methodWithPageRequest(PageRequest pageRequest, String string); } } diff --git a/src/test/java/org/springframework/data/repository/query/ParametersUnitTests.java b/src/test/java/org/springframework/data/repository/query/ParametersUnitTests.java index ca76a8c108..bea7d7b780 100755 --- a/src/test/java/org/springframework/data/repository/query/ParametersUnitTests.java +++ b/src/test/java/org/springframework/data/repository/query/ParametersUnitTests.java @@ -25,9 +25,10 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.reactivestreams.Publisher; - +import org.springframework.data.domain.OffsetScrollPosition; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Window; import org.springframework.data.domain.Sort; import org.springframework.test.util.ReflectionTestUtils; @@ -182,6 +183,14 @@ void acceptsCustomPageableParameter() throws Exception { assertThat(parameters.hasPageableParameter()).isTrue(); } + @Test // GH-2151 + void acceptsScrollPositionSubtypeParameter() throws Exception { + + var parameters = getParametersFor("customScrollPosition", OffsetScrollPosition.class); + + assertThat(parameters.hasScrollPositionParameter()).isTrue(); + } + private Parameters getParametersFor(String methodName, Class... parameterTypes) throws SecurityException, NoSuchMethodException { @@ -221,6 +230,8 @@ static interface SampleDao { void methodWithSingle(Single single); Page customPageable(SomePageable pageable); + + Window customScrollPosition(OffsetScrollPosition request); } interface SomePageable extends Pageable {} diff --git a/src/test/java/org/springframework/data/repository/query/QueryMethodUnitTests.java b/src/test/java/org/springframework/data/repository/query/QueryMethodUnitTests.java index 8aff2582b3..e5ad94eefb 100755 --- a/src/test/java/org/springframework/data/repository/query/QueryMethodUnitTests.java +++ b/src/test/java/org/springframework/data/repository/query/QueryMethodUnitTests.java @@ -19,6 +19,8 @@ import io.vavr.collection.Seq; import io.vavr.control.Option; +import org.springframework.data.domain.Window; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.io.Serializable; @@ -31,6 +33,7 @@ import org.junit.jupiter.api.Test; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Slice; import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.projection.SpelAwareProxyProjectionFactory; @@ -88,6 +91,48 @@ void doesNotConsiderPageMethodCollectionQuery() throws Exception { assertThat(queryMethod.isCollectionQuery()).isFalse(); } + @Test // GH-2151 + void supportsImperativecursorQueries() throws Exception { + var method = SampleRepository.class.getMethod("cursorWindow", ScrollPosition.class); + var queryMethod = new QueryMethod(method, metadata, factory); + + assertThat(queryMethod.isPageQuery()).isFalse(); + assertThat(queryMethod.isScrollQuery()).isTrue(); + assertThat(queryMethod.isCollectionQuery()).isFalse(); + } + + @Test // GH-2151 + void supportsReactiveCursorQueries() throws Exception { + var method = SampleRepository.class.getMethod("reactiveCursorWindow", ScrollPosition.class); + var queryMethod = new QueryMethod(method, metadata, factory); + assertThat(queryMethod.isPageQuery()).isFalse(); + + assertThat(queryMethod.isScrollQuery()).isTrue(); + assertThat(queryMethod.isCollectionQuery()).isFalse(); + } + + @Test // GH-2151 + void rejectsInvalidReactiveCursorQueries() throws Exception { + var method = SampleRepository.class.getMethod("invalidReactiveCursorWindow", ScrollPosition.class); + + assertThatIllegalStateException().isThrownBy(() -> new QueryMethod(method, metadata, factory)); + } + + @Test // GH-2151 + void rejectsCursorWindowMethodWithoutPageable() throws Exception { + var method = SampleRepository.class.getMethod("cursorWindowWithoutScrollPosition"); + + assertThatIllegalArgumentException().isThrownBy(() -> new QueryMethod(method, metadata, factory)); + } + + @Test // GH-2151 + void rejectsCursorWindowMethodWithInvalidReturnType() throws Exception { + + var method = SampleRepository.class.getMethod("cursorWindowMethodWithInvalidReturnType", ScrollPosition.class); + + assertThatIllegalStateException().isThrownBy(() -> new QueryMethod(method, metadata, factory)); + } + @Test // DATACMNS-171 void detectsAnEntityBeingReturned() throws Exception { @@ -305,6 +350,16 @@ interface SampleRepository extends Repository { Mono> reactiveSlice(); ImmutableList returnsEclipseCollection(); + + Window cursorWindow(ScrollPosition cursorRequest); + + Mono> reactiveCursorWindow(ScrollPosition cursorRequest); + + Flux> invalidReactiveCursorWindow(ScrollPosition cursorRequest); + + Page cursorWindowMethodWithInvalidReturnType(ScrollPosition cursorRequest); + + Window cursorWindowWithoutScrollPosition(); } class User { diff --git a/src/test/java/org/springframework/data/repository/query/SimpleParameterAccessorUnitTests.java b/src/test/java/org/springframework/data/repository/query/SimpleParameterAccessorUnitTests.java index e9e504ecb4..f17f7264ac 100755 --- a/src/test/java/org/springframework/data/repository/query/SimpleParameterAccessorUnitTests.java +++ b/src/test/java/org/springframework/data/repository/query/SimpleParameterAccessorUnitTests.java @@ -19,23 +19,27 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.data.domain.OffsetScrollPosition; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Sort; /** * Unit tests for {@link ParametersParameterAccessor}. * * @author Oliver Gierke + * @author Mark Paluch */ class SimpleParameterAccessorUnitTests { - Parameters parameters, sortParameters, pageableParameters; + Parameters parameters, cursorRequestParameters, sortParameters, pageableParameters; @BeforeEach void setUp() throws SecurityException, NoSuchMethodException { parameters = new DefaultParameters(Sample.class.getMethod("sample", String.class)); + cursorRequestParameters = new DefaultParameters(Sample.class.getMethod("sample", ScrollPosition.class)); sortParameters = new DefaultParameters(Sample.class.getMethod("sample1", String.class, Sort.class)); pageableParameters = new DefaultParameters(Sample.class.getMethod("sample2", String.class, Pageable.class)); } @@ -75,6 +79,16 @@ void returnsNullForPageableAndSortIfNoneAvailable() throws Exception { assertThat(accessor.getSort().isSorted()).isFalse(); } + @Test // GH-2151 + void returnsScrollPositionIfAvailable() { + + var cursorRequest = OffsetScrollPosition.of(1); + ParameterAccessor accessor = new ParametersParameterAccessor(cursorRequestParameters, + new Object[] { cursorRequest }); + + assertThat(accessor.getScrollPosition()).isEqualTo(cursorRequest); + } + @Test void returnsSortIfAvailable() { @@ -110,6 +124,8 @@ interface Sample { void sample(String firstname); + void sample(ScrollPosition scrollPosition); + void sample1(String firstname, Sort sort); void sample2(String firstname, Pageable pageable);