SQL Query Pagination With Spring Boot and JdbcTemplate

Published: Sep 10, 2022
Updated: Sep 16, 2022

There are times when the amount of data you need to fetch is simply too large for a single trip. Don’t fear, pagination is one way to solve this.

How it Works #

If you’ve used Spring Boot before, you may know that JPA includes pagination support out-of-the-box. While this is nice, there are some scenarios (like the one I encountered at work recently) where you’re working with raw, complex SQL, but you still need some way to do pagination.

Luckily, with a bit of trial and error, and a bunch of googling, I crafted a solution.

If you get stuck, checkout the GitHub repo for the full project.

Relevant Files #

PaginationRunner.java #

File: src/main/java/com/example/demo/PaginationRunner.java

// package and import lines removed for brevity

@Component
public class PaginationRunner implements ApplicationRunner {

  Logger logger = LoggerFactory.getLogger(PaginationRunner.class);

  /**
   * The pageSize is configurable. We default it to 5 here.
   * You can override it in the src/main/resources/application.properties file by setting pagination_runner.page_size.
   * Or, via env var by setting PAGINATION_RUNNER_PAGE_SIZE.
   */
  @Value("${pagination_runner.page_size:5}")
  private int pageSize;

  /**
   * The jdbcTemplate uses the default data source. Which, in this demo, is the in-memory H2 database.
   */
  @Autowired
  private JdbcTemplate jdbcTemplate;

  /**
   * This class implements ApplicationRunner.
   * So, this component will run after the Spring Application Context is initialized.
   */
  @Override
  public void run(ApplicationArguments args) throws Exception {
    logger.info("Starting PaginationRunner");
    loopThroughThePages();
    logger.info("Finished PaginationRunner");
  }

  /**
   * Loop through the pages until you encounter an empty page.
   */
  private void loopThroughThePages() {
    Pageable pageable = PageRequest.of(0, pageSize);
    Page<Map<String, Object>> page = findAll(pageable);

    while (!page.isEmpty()) {
      logProgress(pageable, page);
      page.stream().forEach(this::handleRow);
      pageable = pageable.next();
      page = findAll(pageable);
    }
  }

  /**
   * Find all the rows.
   * You _could_ create the query using LIMIT and OFFSET...
   * But, I went with a plain WHERE clause that selects a range of IDs because it's faster.
   */
  private Page<Map<String, Object>> findAll(Pageable pageable) {
    long startId = pageable.getOffset();
    long endId = startId + pageable.getPageSize();
    String sql = String.format(
        "SELECT * FROM word WHERE id > %s AND id <= %s",
        startId,
        endId
    );
    logger.info("findAll sql: {}", sql);
    List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql);
    long total = countAll();
    return new PageImpl<>(rows, pageable, total);
  }

  /**
   * Count all the rows.
   */
  private long countAll() {
    String sql = "SELECT COUNT(*) FROM word";
    logger.info("countAll sql: {}", sql);
    return jdbcTemplate.queryForObject(sql, Long.class);
  }

  /**
   * Log the progress.
   * You'll thank yourself for this, especially if the "job" is long-running.
   */
  private void logProgress(Pageable pageable, Page<Map<String, Object>> page) {
    int currentPage = pageable.getPageNumber() + 1;
    int totalPages = page.getTotalPages();
    int currentRowCount = page.getNumberOfElements();
    long totalRowCount = page.getTotalElements();
    logger.info(
        "On page {} of {}. Rows in page: {}. Total rows: {}",
        currentPage, totalPages, currentRowCount, totalRowCount
    );
  }

  /**
   * Actually do something with each row.
   * In this demo, I'm just logging the row.
   * In a real scenario, maybe you're building up a bulk request to send somewhere else, etc.
   */
  private void handleRow(Map<String, Object> row) {
    logger.info(row.toString());
  }

}

schema.sql #

File: src/main/resources/schema.sql

-- When using the in-memory H2 database, this is how the schema is defined.

CREATE TABLE word (
  id INT AUTO_INCREMENT PRIMARY KEY,
  word CHARACTER VARYING
);

data.sql #

File: src/main/resources/data.sql

-- When using the in-memory H2 database, this is how the data is seeded.
-- I got this data by grepping for words starting with "b" in the built-in Mac dictionary at /usr/share/dict/words.

INSERT INTO word (word) VALUES ('babblesome');
INSERT INTO word (word) VALUES ('babbling');
INSERT INTO word (word) VALUES ('babblingly');
INSERT INTO word (word) VALUES ('babblish');
INSERT INTO word (word) VALUES ('babblishly');
INSERT INTO word (word) VALUES ('babbly');
INSERT INTO word (word) VALUES ('babby');
INSERT INTO word (word) VALUES ('babe');
INSERT INTO word (word) VALUES ('babehood');
INSERT INTO word (word) VALUES ('babelet');
INSERT INTO word (word) VALUES ('babelike');

Sample Output #

You can view this by running the app with: ./gradlew bootRun

Starting PaginationRunner
findAll sql: SELECT * FROM word WHERE id > 0 AND id <= 5
countAll sql: SELECT COUNT(*) FROM word
On page 1 of 3. Rows in page: 5. Total rows: 11
{ID=1, WORD=babblesome}
{ID=2, WORD=babbling}
{ID=3, WORD=babblingly}
{ID=4, WORD=babblish}
{ID=5, WORD=babblishly}
findAll sql: SELECT * FROM word WHERE id > 5 AND id <= 10
countAll sql: SELECT COUNT(*) FROM word
On page 2 of 3. Rows in page: 5. Total rows: 11
{ID=6, WORD=babbly}
{ID=7, WORD=babby}
{ID=8, WORD=babe}
{ID=9, WORD=babehood}
{ID=10, WORD=babelet}
findAll sql: SELECT * FROM word WHERE id > 10 AND id <= 15
countAll sql: SELECT COUNT(*) FROM word
On page 3 of 3. Rows in page: 1. Total rows: 11
{ID=11, WORD=babelike}
findAll sql: SELECT * FROM word WHERE id > 15 AND id <= 20
countAll sql: SELECT COUNT(*) FROM word
Finished PaginationRunner