Programming Thoughts
J2EE - Pagination
Base Record List

Displaying search results in pages

Most applications are data driven, users search large tables and expect search results split into multiple pages. This series of articles describes a simplified version of a Template helper class, named list cache, that was developed over the years according to user requirements. This articles extends a previous, basic design for displaying search results in pages.

The problem

Loading search results into a simple list usually works well for a small, single table as databases just need to read one continuous block on the disk. Alas, users often want data from remote services, related tables, and even statistics calculated on-the-fly. Complex queries returning large result sets can create database and network strain as well as frustrating UI delays.

Lazy loading

Users viewing results in pages has the possibility of lazy loading. If most of the data comes from a single table, the base record, additional data can be attached only for records in the current page. Whether reading the base records and lazy loading additional data is faster than reading everything is a question answered by experience.

Query Strategies

The base record list finder is the list finder in the previous article but the client must also supply a page extension assembler, which is a Transfer Object Assembler, that attaches record data not returned by the base record list finder. The assembler has the following signature.

/** * Transfer Object Assembler for lazy loading additional data needed for a search results page that a base record * list finder (instance of {@link ListFinder}) does not find. */ @FunctionalInterface public interface PageExtensionAssembler<T extends Serializable> extends Serializable { public void assemblePageExtensions(Collection<T> items) throws Exception; }

An example of a page extension assembler is shown below.

public static class GamePageExtensionAssembler implements PageExtensionAssembler<GameDTO> { @Override public void assemblePageExtensions(Collection<GameDTO> items) throws Exception { DesignerTable designerTable; designerTable = Model.getInstance().getDesignerTable(); for (GameDTO game: items) { game.setDesigners(designerTable.findByGameId(game.getId())); } } }

Updating member fields

The ItemData class identified by the previous article must now include whether the record's page extensions needs to be lazy loaded.

private static class ItemData<T> implements Serializable { private T item; // Record, which is null if not yet loaded private boolean pageExtensionReload; // Whether further data must be added to base record to display in a page public ItemData() { item = null; pageExtensionReload = false; } public T getItem() { return item; } public void setItem(T value) { item = value; } public boolean getPageExtensionReload() { return pageExtensionReload; } public void setPageExtensionReload(boolean pageExtensionReload) { this.pageExtensionReload = pageExtensionReload; } }

List cache must also include more member variables for the query strategies, including the pagination mode.

public class ListCache<T extends Serializable> implements Serializable { public enum PaginationMode {BASE_RECORD_LIST, FULL_LIST} private ListFinder<T> baseRecordListFinder; // Strategy to load base record list in base record list pagination mode private List<ItemData<T>> list; // List of records or placeholders private ListFinder<T> listFinder; // Strategy to load entire list in full list pagination mode private PageExtensionAssembler<T> pageExtensionAssembler;// Adds additional data for current page for base record list pagination mode private int pageSize; private PaginationMode paginationMode; private boolean reload; // Whether list must be reloaded private int selectedIndex; // Index of currently selected record, 0 based, or -1 for no selected record

Updating core functions

When retrieving a page in full list mode, expected data is already loaded but other modes must check for and load missing data.

public synchronized List<T> getPage() throws Exception { int pageNo, startIndex, endIndex; checkReloadState(); if (list.size() > 0) { pageNo = getPageNo(); pageNo = (pageNo < 1)?1:pageNo; startIndex = (pageNo - 1) * pageSize; endIndex = startIndex + pageSize - 1; endIndex = (endIndex >= list.size())?list.size() - 1:endIndex; checkPageListData(startIndex, endIndex); return getListUnchecked().subList(startIndex, endIndex + 1); } else { return new ArrayList<T>(); } }

The new functions are below, where the second finds the records in the current page lacking page extensions and asks the page extension assembler to load them.

/** * Assembles any missing data for items in a range of indices, usually the current page, not loaded by the base * record list finder. */ private void checkPageListData(int startIndex, int endIndex) throws Exception { switch (paginationMode) { case BASE_RECORD_LIST: checkPageListDataByBaseRecords(startIndex, endIndex); break; case FULL_LIST: // Entire list already loaded break; } } /** * Loads any items in a range of indices, usually the current page, that need loading. */ private void checkPageListDataByBaseRecords(int startIndex, int endIndex) throws Exception { ItemData<T> itemData; T item; int size, i, workingStartIndex, workingEndIndex; Collection<T> loadingItems; size = list.size(); workingStartIndex = (startIndex >= 0 && startIndex < size)?startIndex:0; workingEndIndex = (endIndex >= 0 && endIndex < size)?endIndex:(size-1); loadingItems = new ArrayList<>(); for (i = workingStartIndex; i <= workingEndIndex; i++) { itemData = list.get(i); item = itemData.getItem(); if (itemData.getPageExtensionReload()) { loadingItems.add(item); } } if (loadingItems.size() > 0) { pageExtensionAssembler.assemblePageExtensions(loadingItems); for (i = workingStartIndex; i <= workingEndIndex; i++) { itemData = list.get(i); itemData.setPageExtensionReload(false); } } }

Client code using pagination shouldn't read the entire list at once. Nonetheless, the changes to getList are shown for completeness and work in a similar way.

public synchronized List<T> getList() throws Exception { checkReloadState(); checkEntireListData(); return getListUnchecked(); } /** * Checks all list data has been loaded, not just placeholders such as base record or id. */ private void checkEntireListData() throws Exception { switch (paginationMode) { case BASE_RECORD_LIST: checkEntireListDataByBaseRecords(); break; case FULL_LIST: // Entire list already loaded break; } } /** * Assembles any missing data for all items not loaded by the base record list finder. */ private void checkEntireListDataByBaseRecords() throws Exception { ItemData<T> itemData; T item; int size, i; Collection<T> loadingItems; size = list.size(); loadingItems = new ArrayList<>(); for (i = 0; i < size; i++) { itemData = list.get(i); item = itemData.getItem(); loadingItems.add(item); } pageExtensionAssembler.assemblePageExtensions(loadingItems); for (i = 0; i < size; i++) { itemData = list.get(i); itemData.setPageExtensionReload(false); } }

The existence of new pagination modes means other functions must be updated.

private void loadList() throws Exception { List<T> itemList; switch (paginationMode) { case BASE_RECORD_LIST: itemList = baseRecordListFinder.getList(); if (itemList == null) { itemList = new ArrayList<T>(); } setList(itemList, false); break; } case FULL_LIST: itemList = listFinder.getList(); if (itemList == null) { itemList = new ArrayList<T>(); } setList(itemList, true); break; } private synchronized void setList(List<T> value, boolean pageExtensionsLoaded) { ItemData<T> itemData; reload = false; list = new ArrayList<ItemData<T>>(value.size()); for (T item: value) { itemData = new ItemData<T>(); itemData.setItem(item); itemData.setPageExtensionReload(!pageExtensionsLoaded); list.add(itemData); } checkSelectedIndex(); }

Setting base record list

Like with full list pagination, the search results cannot be set on its own as the query strategy used to retrieve it is also needed. Although the full list finder is only used in full list pagination mode, some pages read the current query to display its search criteria and a null value indicates it's not the current query.

/** * Caches list already loaded, sets base record list finder Strategy used to load it, and sets base record list pagination mode. */ public synchronized void setBaseRecordListAndFinder(List<T> list, ListFinder<T> baseRecordListFinder) { setList(list, false); this.baseRecordListFinder = baseRecordListFinder; this.listFinder = null; this.paginationMode = PaginationMode.BASE_RECORD_LIST; setSelectedIndex(0); } /** * Sets base record list finder, marks list for reload, and sets base record list pagination mode.. */ public void setBaseRecordListFinder(ListFinder<T> baseRecordListFinder) { setList(new ArrayList<>(), false); this.baseRecordListFinder = baseRecordListFinder; this.listFinder = null; this.paginationMode = PaginationMode.BASE_RECORD_LIST; markReload(); } public synchronized void setListAndFinder(List<T> list, ListFinder<T> listFinder) { setList(list, true); this.baseRecordListFinder = null; this.listFinder = listFinder; this.paginationMode = PaginationMode.FULL_LIST; setSelectedIndex(0); } public void setListFinder(ListFinder<T> listFinder) { setList(new ArrayList<>(), true); this.baseRecordListFinder = null; this.listFinder = listFinder; this.paginationMode = PaginationMode.FULL_LIST; markReload(); }

Notes and Limitations

The structural difference between base record and the full data expected by the page is fixed, so the page extension assembler is also fixed. In contrast, different search criteria must set their own base record list finders.

Next part

Continued in By Ids.