diff --git a/jpa/example/src/main/java/example/springdata/jpa/pagination/Author.java b/jpa/example/src/main/java/example/springdata/jpa/pagination/Author.java new file mode 100644 index 000000000..a6d92c174 --- /dev/null +++ b/jpa/example/src/main/java/example/springdata/jpa/pagination/Author.java @@ -0,0 +1,35 @@ +/* + * 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 example.springdata.jpa.pagination; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Data; + +/** + * @author Christoph Strobl + */ +@Data +@Entity +@Table(name = "authors") +public class Author { + + @Id + private String id; + private String firstName; + private String lastName; +} diff --git a/jpa/example/src/main/java/example/springdata/jpa/pagination/Book.java b/jpa/example/src/main/java/example/springdata/jpa/pagination/Book.java new file mode 100644 index 000000000..7d78c48e3 --- /dev/null +++ b/jpa/example/src/main/java/example/springdata/jpa/pagination/Book.java @@ -0,0 +1,42 @@ +/* + * 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 example.springdata.jpa.pagination; + +import java.util.Date; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Data; + +/** + * @author Christoph Strobl + */ +@Data +@Entity +@Table(name = "books") +public class Book { + + @Id + private String id; + private String title; + private String isbn10; + private Date publicationDate; + + @ManyToOne + Author author; +} diff --git a/jpa/example/src/main/java/example/springdata/jpa/pagination/BookRepository.java b/jpa/example/src/main/java/example/springdata/jpa/pagination/BookRepository.java new file mode 100644 index 000000000..53dbde8af --- /dev/null +++ b/jpa/example/src/main/java/example/springdata/jpa/pagination/BookRepository.java @@ -0,0 +1,63 @@ +/* + * 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 example.springdata.jpa.pagination; + +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.domain.Window; +import org.springframework.data.repository.ListCrudRepository; + +/** + * @author Christoph Strobl + */ +public interface BookRepository extends ListCrudRepository { + + /** + * Uses an {@literal offset} based pagination that first sorts the entries by their {@link Book#getPublicationDate() publication_date} + * and then limits the result by dropping the number of rows specified in the {@link Pageable#getOffset() offset} clause. + * To retrieve {@link Page#getTotalElements()} an additional count query is executed. + * + * @param title + * @param pageable + * @return + */ + Page findByTitleContainsOrderByPublicationDate(String title, Pageable pageable); + + /** + * Uses an {@literal offset} based slicing that first sorts the entries by their {@link Book#getPublicationDate() publication_date} + * and then limits the result by dropping the number of rows specified in the {@link Pageable#getOffset() offset} clause. + * + * @param title + * @param pageable + * @return + */ + Slice findBooksByTitleContainsOrderByPublicationDate(String title, Pageable pageable); + + /** + * Depending on the provided {@link ScrollPosition} either {@link org.springframework.data.domain.OffsetScrollPosition offset} + * or {@link org.springframework.data.domain.KeysetScrollPosition keyset} scrolling is possible. + * Scrolling through results requires a stable {@link org.springframework.data.domain.Sort} which is different from + * what {@link Pageable#getSort()} offers. + * The {@literal limit} is defined via the {@literal Top} keyword. + * + * @param title + * @param scrollPosition + * @return + */ + Window findTop2ByTitleContainsOrderByPublicationDate(String title, ScrollPosition scrollPosition); +} diff --git a/jpa/example/src/main/java/example/springdata/jpa/pagination/PagingRepoConfig.java b/jpa/example/src/main/java/example/springdata/jpa/pagination/PagingRepoConfig.java new file mode 100644 index 000000000..d6b4ec99f --- /dev/null +++ b/jpa/example/src/main/java/example/springdata/jpa/pagination/PagingRepoConfig.java @@ -0,0 +1,28 @@ +/* + * 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 example.springdata.jpa.pagination; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * @author Christoph Strobl + */ +@SpringBootApplication +@EnableJpaRepositories(repositoryBaseClass = BookRepository.class) +class PagingRepoConfig { + +} diff --git a/jpa/example/src/test/java/example/springdata/jpa/pagination/PaginationTests.java b/jpa/example/src/test/java/example/springdata/jpa/pagination/PaginationTests.java new file mode 100644 index 000000000..70ba81f84 --- /dev/null +++ b/jpa/example/src/test/java/example/springdata/jpa/pagination/PaginationTests.java @@ -0,0 +1,193 @@ +/* + * 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 example.springdata.jpa.pagination; + +import static org.assertj.core.api.AssertionsForClassTypes.*; + +import java.util.List; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import com.github.javafaker.Faker; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.data.domain.OffsetScrollPosition; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Window; +import org.springframework.transaction.annotation.Transactional; + +/** + * Show different types of paging styles using {@link Page}, {@link org.springframework.data.domain.Slice} and {@link Window} + * + * @author Christoph Strobl + */ +@SpringBootTest +@Transactional +class PaginationTests { + + @Configuration + @EnableAutoConfiguration + static class Config { + + } + + @Autowired + BookRepository books; + + @BeforeEach + void setUp() { + + Faker faker = new Faker(); + + // create some sample data + List authorList = createAuthors(faker); + createBooks(faker, authorList); + } + + /** + * Page through the results using an offset/limit approach where the server skips over the number of results + * specified via {@link Pageable#getOffset()}. + * The {@link Page} return type will run an additional {@literal count} query to read the total number of matching rows + * on each request. + */ + @Test + void pageThroughResultsWithSkipAndLimit() { + + Page page; + Pageable pageRequest = PageRequest.of(0, 2); + + do { + + page = books.findByTitleContainsOrderByPublicationDate("the", pageRequest); + assertThat(page.getContent().size()).isGreaterThanOrEqualTo(1).isLessThanOrEqualTo(2); + + pageRequest = page.nextPageable(); + } while (page.hasNext()); + } + + /** + * Run through the results using an offset/limit approach where the server skips over the number of results specified + * via {@link Pageable#getOffset()}. + * No additional {@literal count} query to read the total number of matching rows is issued. Still {@link Slice} requests, + * but does not emit, one row more than specified via {@link Page#getSize()} to feed {@link Slice#hasNext()} + */ + @Test + void sliceThroughResultsWithSkipAndLimit() { + + Slice slice; + Pageable pageRequest = PageRequest.of(0, 2); + + do { + + slice = books.findBooksByTitleContainsOrderByPublicationDate("the", pageRequest); + assertThat(slice.getContent().size()).isGreaterThanOrEqualTo(1).isLessThanOrEqualTo(2); + + pageRequest = slice.nextPageable(); + } while (slice.hasNext()); + } + + /** + * Scroll through the results using an offset/limit approach where the server skips over the number of results + * specified via {@link OffsetScrollPosition#getOffset()}. + *

+ * This approach is similar to the {@link #sliceThroughResultsWithSkipAndLimit() slicing one}. + */ + @Test + void scrollThroughResultsWithSkipAndLimit() { + + Window window; + ScrollPosition scrollPosition = OffsetScrollPosition.initial(); + + do { + + window = books.findTop2ByTitleContainsOrderByPublicationDate("the", scrollPosition); + assertThat(window.getContent().size()).isGreaterThanOrEqualTo(1).isLessThanOrEqualTo(2); + + scrollPosition = window.positionAt(window.getContent().size() - 1); + } while (window.hasNext()); + } + + /** + * Scroll through the results using an index based approach where the {@link KeysetScrollPosition#getKeys() keyset} + * keeps track of already seen values to resume scrolling by altering the where clause to only return rows after the + * values contained in the keyset. + * Set {@literal logging.level.org.hibernate.SQL=debug} to show the modified query in the log. + */ + @Test + void scrollThroughResultsWithKeyset() { + + Window window; + ScrollPosition scrollPosition = KeysetScrollPosition.initial(); + do { + + window = books.findTop2ByTitleContainsOrderByPublicationDate("the", scrollPosition); + assertThat(window.getContent().size()).isGreaterThanOrEqualTo(1).isLessThanOrEqualTo(2); + + scrollPosition = window.positionAt(window.getContent().size() - 1); + } while (window.hasNext()); + } + + // --> Test Data + + @Autowired + EntityManager em; + + private List createAuthors(Faker faker) { + + List authors = IntStream.range(0, 10).mapToObj(id -> { + + Author author = new Author(); + author.setId("author-%s".formatted(id)); + author.setFirstName(faker.name().firstName()); + author.setLastName(faker.name().lastName()); + + em.persist(author); + return author; + }).collect(Collectors.toList()); + return authors; + } + + private List createBooks(Faker faker, List authors) { + + Random rand = new Random(); + return IntStream.range(0, 100) + .mapToObj(id -> { + + Book book = new Book(); + book.setId("book-%03d".formatted(id)); + book.setTitle(faker.book().title()); + book.setIsbn10(UUID.randomUUID().toString().substring(0, 10)); + book.setPublicationDate(faker.date().past(5000, TimeUnit.DAYS)); + book.setAuthor(authors.get(rand.nextInt(authors.size()))); + + em.persist(book); + return book; + }).collect(Collectors.toList()); + } +} diff --git a/pom.xml b/pom.xml index 3c9728fd1..eee4b4893 100644 --- a/pom.xml +++ b/pom.xml @@ -145,6 +145,12 @@ junit-vintage-engine test + + com.github.javafaker + javafaker + 1.0.1 + test +