← Home
· 7 min

Extending the Page Interface

1.0.0 Pageable

Interface Pageable is an interface included in Spring Data Commons. Its implementations include PageRequest. When Pageable is used as a controller parameter as in the example, it is matched to the size, page, and sort request parameters in the URL's query string. If one of the three is missing, a default value is used instead. In PageRequest the property names are actually pageSize, pageNumber, and sort, and I could see that the internal property names changed when Spring Data went from version 2 to 3. A property rename would normally affect value binding in an ArgumentResolver, but Spring seems to have achieved backward compatibility across versions by having PageableHandlerMethodArgumentResolver manage this process internally.

public class MemoController {
    @GetMapping("/memo")// memo search page
    public String getMemoList(@SessionAttribute(name = "loginUser") User user,
                              @RequestParam(name = "keyword") @NotNull String keyword,
                              @PageableDefault(size = 10, sort = {}) Pageable pageable,
                              HttpServletRequest request,
                              Model model) {
    }
}

1.0.1 Using Pageable in a DAO

Interface Pageable can be used as the last parameter of a JPA Repository interface method. In that case the method's return type must be changed to Page<T>.

@Repository
public interface MemoRepository extends JpaRepository<Memo,Long> {
    Page<Memo> findMemoByUserIdAndMemoContentContainsOrderByCreatedateDesc(String userId, String keyword, Pageable pageable);
}

1.0.2 Page

Page is also an interface, with the implementation PageImpl. Page has the properties needed for pagination, most notably the current page (getNumber) and the total number of pages (getTotalPages).

public interface Page<T> extends Slice<T> {
    static <T> Page<T> empty() {
        return empty(Pageable.unpaged());
    }

    static <T> Page<T> empty(Pageable pageable) {
        return new PageImpl(Collections.emptyList(), pageable, 0L);
    }

    int getTotalPages();

    long getTotalElements();

    <U> Page<U> map(Function<? super T, ? extends U> converter);
}

1.0.2 A Small Drawback of Page

Page is useful on its own, but because it extends the Slice interface, its starting index is 0, not 1. That differs from page numbering as users see it. The total page count comes from a count, so the last page number and the total number of pages differ by 1.
This drawback causes confusion when building pagination URLs. If the page property mapped into Pageable in the controller is 0, it means index 0 in the Page interface, yet the web client has to present it as the first page.

1.0.3 How to Improve Page

As seen above, Page shares its lifecycle with Pageable and PageableHandlerMethodArgumentResolver, so changing the page index from 0 to 1 by modifying a single class is impossible. For a beginner, overriding an API that Spring provides by default is a heavy burden.
How to change it through configuration (Korean)

1.0.4 Focusing on Pagination Instead of Page

I have described the drawback Page has. I decided not to change Spring's default configuration and only to make pagination easy. What is needed is to show the correct page number on the user's screen and attach a hyperlink to it. I created Pagination, which extends Page.

1.0.4.1 Creating a Value Object to Hold the Values We Want

PageUrl has, as its attributes, the page number actually shown to the user and the hyperlink that matches that number.

// page number, url
public record PageUrl(
        int pageNumber,
        String urlString
){}

1.0.4.2 Inheritance

I made Pagination<T> extend Page. Its parameters are the width of one side of the pagination to fetch at once, the base URL, and any other parameters to add.

/**
 * Interface extending Page<T> that carries custom pagination information
 */
public interface Pagination<T> extends Page<T> {

    List<PageUrl> getPageUrlList(Integer pageOffset, String urlPath, Map<String,String[]> params);
}

1.0.4.3 Implementation

PaginationImpl is the implementation of Pagination.
fromPage(Page<T> page) calls the constructor from the Page<T> returned by the Repository.
PaginationImpl has no fields of its own, but it uses the properties of PageImpl, which it extends, to offer the getPageUrlList method as a public property. So it is a kind of wrapper class. In the method signature, Map<String,String[]> requestParams is the return type you get when calling getParameterMap() on the HttpServletRequest parameter of a controller method.
getMultiValueMap(@Nullable Map<String, String[]> requestParams) receives it and converts it into the Map type suited to UriComponentsBuilder's queryParams(@Nullable MultiValueMap<String, String> params).


public class PaginationImpl<T> extends PageImpl<T> implements Pagination<T> {

    private PaginationImpl(List<T> content, Pageable pageable, long total) {
        super(content, pageable, total);
    }

    public static <T>Pagination<T> fromPage(Page<T> page){
        return new PaginationImpl<>(page.getContent(), page.getPageable(), page.getTotalElements());
    }

    /** Builds the paging URLs
     * @param pageOffset the half-side width
     * @param urlPath the request URL without the query string
     * @param requestParams the query string map
     */
    @Override
    public List<PageUrl> getPageUrlList(@Nullable Integer pageOffset, String urlPath, @Nullable Map<String,String[]> requestParams) {
        // initialize the nullable page offset
        pageOffset = (pageOffset == null) ? 5 : pageOffset;
        // prepare the parameters
        MultiValueMap<String, String> params = getMultiValueMap(requestParams);
        // define the page numbers
        final int currentPage = getNumber() + 1;
        final int startPage = currentPage - pageOffset;
        final int totalPage = getTotalPages();

        // build the pages and urls
        List<PageUrl> pageUrlList = new ArrayList<>();
        for (int i = 0; i <= pageOffset * 2; i++) {
            int page = startPage + i;
            if (page >= 1 && page <= totalPage) {
                int pageIndex = page - 1;
                String PageUrl = UriComponentsBuilder
                        .fromHttpUrl(urlPath).queryParams(params)
                        .replaceQueryParam("page", pageIndex)
                        .replaceQueryParam("size", getSize())
                        .toUriString();
                pageUrlList.add(new PageUrl(page, PageUrl));
            }
        }
        return pageUrlList;
    }

    /** Converts the request parameter map into a MultiValueMap suited to UriComponentsBuilder.
     */
    private static MultiValueMap<String, String> getMultiValueMap(@Nullable Map<String, String[]> requestParams) {
        if(requestParams == null){
            return null;
        }
        return requestParams.entrySet().stream()
                .collect(Collectors.toMap(Map.Entry::getKey, e -> Arrays.asList(e.getValue()),
                        (existing, replacement) -> existing, LinkedMultiValueMap::new)
                );
    }
}

1.0.4.4 Implementation 2

The getPageUrlList method returns List<PageUrl>. PageUrl's pageNumber is given the conventional page number starting at 1, and urlString the 0-based index that Spring uses by default.

// build the pages and urls
       List<PageUrl> pageUrlList = new ArrayList<>();
       for (int i = 0; i <= pageOffset * 2; i++) {
           int page = startPage + i;
           if (page >= 1 && page <= totalPage) {
               int pageIndex = page - 1;
               String PageUrl = UriComponentsBuilder
                       .fromHttpUrl(urlPath).queryParams(params)
                       .replaceQueryParam("page", pageIndex)
                       .replaceQueryParam("size", getSize())
                       .toUriString();
               pageUrlList.add(new PageUrl(page, PageUrl));
           }
       }

1.0.5.1 Usage (Service)

In the Service, the Page received from the Repository can be wrapped with PaginationImpl.fromPage() and sent to the controller.

@Service
public class MemoService {
    private final MemoRepository memoRepository;
    
    public Pagination<Memo> retrieveUserMemoByKeywordPagination(String userId, String keyword, Pageable pageable) {
        Page<Memo> memoPage = memoRepository.findMemoByUserIdAndMemoContentContainsOrderByCreatedateDesc(userId, keyword, pageable);
        return PaginationImpl.fromPage(memoPage);
    }
}

1.0.5.2 Usage (Controller)

In the controller, request.getRequestURL().toString() and request.getParameterMap() can be used to pass the arguments that Pagination's getPageUrlList() needs. The returned list is put into the model with model.addAttribute("pageUrlList", pageUrlList) and sent to the view.

@GetMapping("/memo")// memo search page
    public String getMemoList(@SessionAttribute(name = "loginUser")User user,
                              @RequestParam(name = "keyword") @NotNull String keyword,
                              @PageableDefault(size = 10, sort = {}) Pageable pageable,
                              HttpServletRequest request,
                              Model model){
        // block sort input on pageable
        if(pageable.getSort().isSorted()){
            throw new IllegalArgumentException("The sort query string is forbidden.");
        }
        // get the pagination
        Pagination<Memo> memoPagination = memoService.retrieveUserMemoByKeywordPagination(user.getUserId(),keyword, pageable);
        // build the pagination info
        List<PageUrl> pageUrlList = memoPagination.getPageUrlList(5, request.getRequestURL().toString(), request.getParameterMap());
        log.debug("Generated pagination {}", pageUrlList.toString());

        model.addAttribute("retrievedMemoList", memoPagination.getContent());
        model.addAttribute("pageUrlList", pageUrlList);
        model.addAttribute("keyword", keyword);
        return "memo";
    }

1.0.5.3 Usage (View)

In the view, simply listing each object is all it takes.

<div class="d-flex justify-content-center">
    <h5>Search results for "<span th:text="${keyword}"/>"</h5>
</div>
<div class="d-flex justify-content-center mb-3">
    <a th:each="pageUrl : ${pageUrlList}" th:href="${pageUrl.urlString}" th:text="${pageUrl.pageNumber}" class="mx-1"></a>
</div>
<table class="table table-bordered">
    <thead>
    <tr>
        <th>Date</th>
        <th>Content</th>
        <th>Registered at</th>
    </tr>

1.0.6 User Screen

img.png

1.0.7 Conclusion

The Page interface is an API that Spring provides by default. It has the drawback of starting at 0 for backward compatibility, but it seems fine to use once that is compensated for. Here, rather than fixing the drawback in the API, I worked around it and, focusing only on the essential business logic, designed and implemented an extending interface called Pagination. This is a way of developing that, in line with OCP, puts more weight on extension. The code still has a lot to improve, but it seems usable at my current level, so I am posting it on the blog.

Comments 0

Be the first to comment.