Build a Spring Boot App with Pagination. Use Thymeleaf and a SQL Database.
Our DB contains Directors, Movies and Genres. Each Movies has one Director (@OneToMany). A Movie can have multiple Genres and vice versa (@ManyToMany).
Generate Project with Spring Initializr:
This project will use Dependency ‘Lombok’ to omit getters, setters and constructors. However you don’t have to use this Dependency if you’re not comfortable with it. Same goes for ‘DevTools’. ‘PostgreSQL’ will be necessary for a Heroku Deployment.
Open the generated file in an IDE of your choice. Create a Database “pagination_db” in your MySQL Workbench.
pom.xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.1.4.RELEASE</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.example</groupId> <artifactId>paginationexample</artifactId> <version>0.0.1-SNAPSHOT</version> <name>paginationexample</name> <description>Demo project for Spring Boot</description> <properties> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
application.properties
.Change user and password according to your MySQL Workbench nstallation. If you want to deploy your app to heroku later, delete everything below “Properties for Running in IDE”.
These properties initialize SQL Data after every restart. If you want to prevent this change “spring.jpa.hibernate.ddl-auto = create” to “update” and “spring.datasource.initialization-mode=always” to “never”.
########################################### # Properties for Heroku ########################################### #spring.datasource.url=${JDBC_DATABSE_URL} #spring.datasource.username=${JDBC_DATABSE_USERNAME} #spring.datasource.password=${JDBC_DATABSE_PASSWORD} #spring.jpa.show-sql = false #spring.jpa.generate.ddl = true #spring.jpa.hibernate.ddl-auto = create #spring.datasource.initialization-mode=always ########################################### #Properties for Running in IDE ########################################### spring.datasource.url=jdbc:mysql://localhost/pagination_db?useSSL=false&serverTimezone=UTC&useLegacyDatetimeCode=false spring.datasource.username=root spring.datasource.password=password spring.jpa.show-sql=true ## Hibernate Logging logging.level.org.hibernate.SQL= DEBUG #init data after every restart spring.jpa.hibernate.ddl-auto=create spring.datasource.initialization-mode=always ## Server Properties server.port= 5000 ## Hibernate Properties spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.MySQL5Dialect ## Jackson Properties spring.jackson.serialization.WRITE_DATES_AS_TIMESTAMPS= false spring.jackson.time-zone= UTC
data.sql
INSERT INTO directors (id, name) VALUES ( 1000, 'Christopher Nolan'), ( 1001, 'David Fincher'), ( 1002, 'Quentin Tarantino'), ( 1003, 'Stanley Kubrick'), ( 1004, 'Ridley Scott'), ( 1005, 'Joss Whedon'), ( 1006, 'Scott Derrickson'), ( 1007, 'James Gunn'), ( 1008, 'Jon Favreau'), ( 1009, 'Jon Watts'), ( 2000, 'Taika Waititi'), ( 2001, 'Shane Black'), ( 2002, 'Chad Stahelski'), ( 2003, 'David F. Sandberg'); INSERT INTO movies (id, name, director_id) VALUES ( 1000, 'The Avengers', 1005), ( 1001, 'Interstellar', 1000), ( 1002, 'Django Unchained', 1002), ( 1003, 'A Clockwork Orange', 1003), ( 1004, 'The Dark Knight', 1000), ( 1005, 'Inglorious Basterds', 1002), ( 1006, 'Inception', 1000), ( 1007, 'Dunkirk', 1000), ( 1008, 'Batman Begins', 1000), ( 1009, 'Prestige', 1000), ( 1010, 'The Dark Knight Rises', 1000); INSERT INTO genres (id, name) VALUES (1000, 'Fantasy'), (1001, 'Adventure'), (1002, 'Mystery'), (1003, 'Science Fiction'), (1004, 'Action'), (1005, 'Crime'), (1006, 'Drama'); INSERT INTO movies_genres (movie_id, genre_id) VALUES (1000, 1003), (1000, 1001), (1000, 1004), (1001, 1003), (1001, 1002), (1001, 1001), (1002, 1004), (1002, 1001), (1003, 1003), (1003, 1005), (1003, 1006), (1004, 1006), (1004, 1005), (1004, 1004), (1005, 1001), (1000, 1006), (1006, 1001), (1006, 1003), (1006, 1006), (1007, 1006), (1008, 1005), (1008, 1006), (1008, 1004), (1009, 1002), (1009, 1003), (1009, 1006), (1010, 1006), (1010, 1004), (1010, 1005);
Director, Movie and Genre Entities with Lombock Annotations to shorten the Code:
package com.example.paginationexample.model; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import javax.persistence.*; @AllArgsConstructor @NoArgsConstructor @Getter @Setter @Entity @Table(name = "directors") public class Director { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; }
package com.example.paginationexample.model; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import javax.persistence.*; import java.util.HashSet; import java.util.Set; @AllArgsConstructor @NoArgsConstructor @Getter @Setter @Entity @Table(name = "genres") public class Genre { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @ManyToMany(fetch = FetchType.LAZY, cascade = { CascadeType.PERSIST, CascadeType.MERGE }, mappedBy = "genres") private Set<Movie> movies = new HashSet<>(); }
package com.example.paginationexample.model; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import javax.persistence.*; import java.util.HashSet; import java.util.Set; @Getter @Setter @AllArgsConstructor @NoArgsConstructor @Entity @Table(name = "movies") public class Movie { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "director_id", nullable = false) private Director director; @ManyToMany(fetch = FetchType.LAZY, cascade = { CascadeType.PERSIST, CascadeType.MERGE }) @JoinTable(name = "movies_genres", joinColumns = { @JoinColumn(name = "movie_id") }, inverseJoinColumns = { @JoinColumn(name = "genre_id") }) private Set<Genre> genres = new HashSet<>(); }
The Repositories:
@Repository public interface DirectorRepository extends JpaRepository<Director, Long> { Optional<Director> findById( Long directorId); }
@Repository public interface GenreRepository extends JpaRepository<Genre, Long> { Optional<Genre> findById(Long genreId); /** * DB contains table 'movies_genres:(movie_id, genre_id) * Spring Data JPA automatically finds all Genres that are related to 'movie' * * @param movie movie object, contains 'movie_id' * @param pageable Abstract interface for pagination information from PageRequest * @return Page<T> Object with Genre Objects */ Page<Genre> findAllByMovies(Movie movie, Pageable pageable); }
@Repository public interface MovieRepository extends JpaRepository<Movie, Long> { Optional<Movie> findById(Long movieId); /** * DB has table 'movies:(id, name, director_id)' * @param directorId director_id * @param pageable Abstract interface for pagination information from PageRequest * @return Page<T> Object with Movie Objects */ Page<Movie> findAllByDirectorId(Long directorId, Pageable pageable); /** * DB contains table 'movies_genres:(movie_id, genre_id) * Spring Data JPA automatically finds all Genres that are related to 'genre' * * @param genre genre object, contains 'genre_id' * @param pageable Abstract interface for pagination information from PageRequest * @return Page<T> Object with Movie Objects */ Page<Movie> findAllByGenres(Genre genre, Pageable pageable); }
The PageModel for Pagination:
@Component is a generic Spring Annotation like @Controller, @Repository and @Service. A Java class decorated with this Annotation is found during classpath scanning and registered in the Spring Context. We will use it with @Autowire in our MainController to initialize pagination.
package com.example.paginationexample.helper; import org.springframework.stereotype.Component; import javax.servlet.http.HttpServletRequest; @Component public class PageModel { private static int PAGE = 0; private static int SIZE = 5; private HttpServletRequest request; public PageModel(HttpServletRequest request) { this.request = request; } public void initPageAndSize(){ if (request.getParameter("page") != null && !request.getParameter("page").isEmpty()) { PAGE = Integer.parseInt(request.getParameter("page")) - 1; } if (request.getParameter("size") != null && !request.getParameter("size").isEmpty()) { PAGE = Integer.parseInt(request.getParameter("size")); } } public static void setSIZE(int SIZE) { PageModel.SIZE = SIZE; } public static int getPAGE() { return PAGE; } public static int getSIZE() { return SIZE; } }
MainController:
package com.example.paginationexample.controller; import com.example.paginationexample.helper.PageModel; import com.example.paginationexample.model.Director; import com.example.paginationexample.model.Genre; import com.example.paginationexample.model.Movie; import com.example.paginationexample.repository.DirectorRepository; import com.example.paginationexample.repository.GenreRepository; import com.example.paginationexample.repository.MovieRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import java.util.Optional; @Controller public class MainController { @Autowired private DirectorRepository directorRepository; @Autowired private MovieRepository movieRepository; @Autowired private GenreRepository genreRepository; @Autowired private PageModel pageModel; /** * Pagination for one Entity example * @param model represents data in the MVC, ( director objects per Page * @return template 'directors.html' */ @GetMapping("/directors") public String getAllDirectors(Model model) { pageModel.setSIZE(8); pageModel.initPageAndSize(); model.addAttribute("directors", directorRepository.findAll(PageRequest.of(pageModel.getPAGE(), pageModel.getSIZE()))); return "directors"; } /** * 1:M example (Director:Movies) * @param directorId id of director * @param model represents data in the MVC: director object, Page with movie objects * @return template 'moviesByDirector.html' */ @GetMapping("/getMoviesByDirector/{directorId}") public String getMoviesByDirector(@PathVariable Long directorId, Model model) { pageModel.initPageAndSize(); Optional<Director> director = directorRepository.findById(directorId); model.addAttribute("director", director.get()); model.addAttribute("movies", movieRepository.findAllByDirectorId(directorId, PageRequest.of(pageModel.getPAGE(), pageModel.getSIZE()))); return "moviesByDirector"; } /** * M:M example (Movies:Genres) * @param genreId id of the genre * @param model represents data in the MVC: genre object, Page with movie objects * @return template 'moviesByGenre.html' */ @GetMapping("/getMoviesByGenre/{genreId}") public String getMoviesByGenre(@PathVariable Long genreId, Model model) { pageModel.initPageAndSize(); Optional<Genre> genre = genreRepository.findById(genreId); model.addAttribute("movies", movieRepository.findAllByGenres(genre.get(), PageRequest.of(pageModel.getPAGE(), pageModel.getSIZE()))); model.addAttribute("genre", genre.get()); return "moviesByGenre"; } /** * M:M example (Movies:Genres) * @param movieId id of the movie * @param model represents data in the MVC: movie object, Page with genre objects * @return template 'genresByMovie.html' */ @GetMapping("/getGenresByMovie/{movieId}") public String getGenresByMovie(@PathVariable Long movieId, Model model) { pageModel.initPageAndSize(); Optional<Movie> movie = movieRepository.findById(movieId); model.addAttribute("genres", genreRepository.findAllByMovies(movie.get(), PageRequest.of(pageModel.getPAGE(), pageModel.getSIZE()))); model.addAttribute("movie", movie.get()); return "genresByMovie"; } }
This concludes the Backend.
The Frontend:
fragments/footer.html
<footer></footer> <script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" crossorigin="anonymous"></script> <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
fragments/head.html
<head> <meta charset="UTF-8"> <title>Entities with pagination</title> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous"> <link rel="stylesheet" type="text/css" th:href="@{https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Roboto+Slab:400,700|Material+Icons}" /> <link th:href="@{/css/style.css}" rel="stylesheet" /> </head>
The Tables and Pagination:
directors.html
<!DOCTYPE html> <html lang="en" xmlns:th="http://www.thymeleaf.org"> <th:block th:include="fragments/head"></th:block> <body> <div class="container" style="margin-top: 50px;"> <div class="row"> <div class="col"> <h3>Pagination Example: Directors</h3> <!-- Table directors --> <table class="table table-bordered"> <thead> <tr> <th>ID</th> <th>Name</th> <th>nested Table</th> </tr> </thead> <tbody> <tr th:each="director : ${directors}"> <td th:text="${director.id}"></td> <td th:text="${director.name}"></td> <td><a th:href="@{/getMoviesByDirector/{directorId}(directorId=${director.id})}">show movies</a></td> </tr> </tbody> </table> <!-- Table End--> </div> </div> <div class="row"> <div class="col"> <!-- directors pagination --> <nav aria-label="Pagination" th:if="${directors.totalPages gt 0}"> <ul class="pagination justify-content-center font-weight-bold"> <li class="page-item" th:classappend="${directors.number eq 0} ? 'disabled'"> <a class="page-link" th:href="@{/directors?page={id}(id=${directors.number lt 2 ? 1 : directors.number})}" aria-label="Previous" title="Previous Page" data-toggle="tooltip"> <span aria-hidden="true">«</span> </a> </li> <li class="page-item" th:classappend="${i eq directors.number + 1} ? 'active'" th:each="i : ${#numbers.sequence( 1, directors.totalPages, 1)}"> <a class="page-link" th:href="@{/directors?page={id}(id=${i})}" th:text="${i}" th:title="${'Page '+ i}" data-toggle="tooltip"></a> </li> <li class="page-item" th:classappend="${directors.number + 1 eq directors.totalPages} ? 'disabled'"> <a class="page-link" th:href="@{/directors?page={id}(id=${directors.number + 2})}" aria-label="Next" title="Next Page" data-toggle="tooltip"> <span aria-hidden="true">»</span> </a> </li> </ul> </nav> </div> </div> </div> <th:block th:include="fragments/footer"></th:block> </body> </html>
genresByMovie.html
<!DOCTYPE html> <html lang="en" xmlns:th="http://www.thymeleaf.org"> <th:block th:include="fragments/head"></th:block> <body> <div class="container" style="margin-top: 50px;"> <div class="row"> <div class="col"> <h5>Genres:Movies (M:M)</h5> <h5>Movie: <span th:text="${movie.name}"></span> has the following genres:</h5> <!-- Table --> <table class="table table-bordered"> <thead> <tr> <th>ID</th> <th>Name</th> <th>Nested Table</th> </tr> </thead> <tbody> <tr th:each="genre : ${genres}"> <td th:text="${genre.id}"></td> <td th:text="${genre.name}"></td> <td><a th:href="@{/getMoviesByGenre/{movieId}(movieId=${movie.id})}">show movies</a></td> </tr> </tbody> </table> <!-- Table End--> </div> </div> <div class="row"> <div class="col"> <!-- products pagination --> <nav aria-label="Pagination" th:if="${genres.totalPages gt 0}"> <ul class="pagination justify-content-center font-weight-bold"> <li class="page-item" th:classappend="${genres.number eq 0} ? 'disabled'"> <a class="page-link" th:href="@{/getGenresByMovie/{movieId}?page={id}(movieId=${movie.id},id=${genres.number lt 2 ? 1 : genres.number})}" aria-label="Previous" title="Previous Page" data-toggle="tooltip"> <span aria-hidden="true">«</span> </a> </li> <li class="page-item" th:classappend="${i eq genres.number + 1} ? 'active'" th:each="i : ${#numbers.sequence( 1, genres.totalPages, 1)}"> <a class="page-link" th:href="@{/getGenresByMovie/{movieId}?page={id}(movieId=${movie.id},id=${i})}" th:text="${i}" th:title="${'Page '+ i}" data-toggle="tooltip"></a> </li> <li class="page-item" th:classappend="${genres.number + 1 eq genres.totalPages} ? 'disabled'"> <a class="page-link" th:href="@{/getGenresByMovie/{movieId}?page={id}(movieId=${movie.id},id=${genres.number + 2})}" aria-label="Next" title="Next Page" data-toggle="tooltip"> <span aria-hidden="true">»</span> </a> </li> </ul> </nav> </div> </div> </div> <th:block th:include="fragments/footer"></th:block> </body> </html>
moviesByDirector.html
<!DOCTYPE html> <html lang="en" xmlns:th="http://www.thymeleaf.org"> <th:block th:include="fragments/head"></th:block> <body> <div class="container" style="margin-top: 50px;"> <div class="row"> <div class="col"> <h5>Director:Movies (1:M)</h5> <h5>Director: <span th:text="${director.name}"></span> directed these movies:</h5> <!-- Table --> <table class="table table-bordered"> <thead> <tr> <th>ID</th> <th>Name</th> <th>Nested Table</th> </tr> </thead> <tbody> <tr th:each="movie : ${movies}"> <td th:text="${movie.id}"></td> <td th:text="${movie.name}"></td> <td><a th:href="@{/getGenresByMovie/{movieId}(movieId=${movie.id})}">show genres</a></td> </tr> </tbody> </table> <!-- Table End--> </div> </div> <div class="row"> <div class="col"> <!-- movies pagination --> <nav aria-label="Pagination" th:if="${movies.totalPages gt 0}"> <ul class="pagination justify-content-center font-weight-bold"> <li class="page-item" th:classappend="${movies.number eq 0} ? 'disabled'"> <a class="page-link" th:href="@{/getMoviesByDirector/{directorId}?page={id}(directorId=${director.id},id=${movies.number lt 2 ? 1 : movies.number})}" aria-label="Previous" title="Previous Page" data-toggle="tooltip"> <span aria-hidden="true">«</span> </a> </li> <li class="page-item" th:classappend="${i eq movies.number + 1} ? 'active'" th:each="i : ${#numbers.sequence( 1, movies.totalPages, 1)}"> <a class="page-link" th:href="@{/getMoviesByDirector/{directorId}?page={id}(directorId=${director.id},id=${i})}" th:text="${i}" th:title="${'Page '+ i}" data-toggle="tooltip"></a> </li> <li class="page-item" th:classappend="${movies.number + 1 eq movies.totalPages} ? 'disabled'"> <a class="page-link" th:href="@{/getMoviesByDirector/{directorId}?page={id}(directorId=${director.id},id=${movies.number + 2})}" aria-label="Next" title="Next Page" data-toggle="tooltip"> <span aria-hidden="true">»</span> </a> </li> </ul> </nav> </div> </div> </div> <th:block th:include="fragments/footer"></th:block> </body> </html>
moviesByGenre.html
<!DOCTYPE html> <html lang="en" xmlns:th="http://www.thymeleaf.org"> <th:block th:include="fragments/head"></th:block> <body> <div class="container" style="margin-top: 50px;"> <div class="row"> <div class="col"> <h5>Genres:Movies (M:M)</h5> <h5>Genre: = <span th:text="${genre.name}"></span> has the following products:</h5> <!-- Table --> <table class="table table-bordered"> <thead> <tr> <th>ID</th> <th>Name</th> <th>Nested Table</th> </tr> </thead> <tbody> <tr th:each="movie : ${movies}"> <td th:text="${movie.id}"></td> <td th:text="${movie.name}"></td> <td><a th:href="@{/getGenresByMovie/{genreId}(genreId=${genre.id})}">show all genres</a></td> </tr> </tbody> </table> <!-- Table End--> </div> </div> <div class="row"> <div class="col"> <!-- movie pagination --> <nav aria-label="Pagination" th:if="${movies.totalPages gt 0}"> <ul class="pagination justify-content-center font-weight-bold"> <li class="page-item" th:classappend="${movies.number eq 0} ? 'disabled'"> <a class="page-link" th:href="@{/getMoviesByGenre/{genreId}?page={id}(genreId=${genre.id},id=${movies.number lt 2 ? 1 : movies.number})}" aria-label="Previous" title="Previous Page" data-toggle="tooltip"> <span aria-hidden="true">«</span> </a> </li> <li class="page-item" th:classappend="${i eq movies.number + 1} ? 'active'" th:each="i : ${#numbers.sequence( 1, movies.totalPages, 1)}"> <a class="page-link" th:href="@{/getMoviesByGenre/{genreId}?page={id}(genreId=${genre.id},id=${i})}" th:text="${i}" th:title="${'Page '+ i}" data-toggle="tooltip"></a> </li> <li class="page-item" th:classappend="${movies.number + 1 eq movies.totalPages} ? 'disabled'"> <a class="page-link" th:href="@{/getMoviesByGenre/{genreId}?page={id}(genreId=${genre.id},id=${movies.number + 2})}" aria-label="Next" title="Next Page" data-toggle="tooltip"> <span aria-hidden="true">»</span> </a> </li> </ul> </nav> </div> </div> </div> <th:block th:include="fragments/footer"></th:block> </body> </html>
Run the app on http://localhost:5000/directors
Thanks for reading.