Programming Thoughts
J2EE - Pagination
By Ids
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 a list cache has a list of
placeholder ids, it could lazy load the records for the requested page. Whether obtaining just the placeholder
ids and lazy loading pages is usefully faster than reading the entire result set is a question answered by
experience. However, sometimes the answer is obvious. Consider the following query, which finds games designed
by a specific designer and designer_game
is a many-to-many table.
SELECT g.* FROM game g INNER JOIN designer_game dg ON g.id = dg.game_id WHERE dg.designer_id = ?
Some designers are prolific and have designed hundreds of games in an industry of tens of thousands of games. The results are unlikely to be in a single block on disk. Consider the following query to obtain just the game ids.
SELECT dg.game_id FROM designer_game dg WHERE dg.designer_id = ?
This is bound to be faster simply but not using a large table.
Query strategies
Like the list finder in the previous article, client supplied queries are
Strategies and uses the following
interfaces, where K
is the class of record id, usually Integer
, and T
is
the class describing records. The page by ids finder does not receive ids or return records in display order as
queries don't consider input parameter order. The list cache will rearrange records in the order defined by the id list finder.
/** * Strategy for finding list of ids or other primary keys. This is useful for pages using pagination with some * instance of {@link PageByIdsFinder} lazy loading the current page. */ @FunctionalInterface public interface IdListFinder<K extends Serializable> extends Serializable { /** * Returns list of ids of record according to implementing search criteria. Null may be returned if nothing found. */ public List<K> getIds() throws Exception; } /** * Strategy for lazy loading a page of records to display from their ids (or other unique key), typically found by a * paired {@link IdListFinder} instance. */ @FunctionalInterface public interface PageByIdsFinder<K extends Serializable,T extends Serializable> extends Serializable { /** * Returns items from their ids. If any records are missing, the successfully retrieved ones must still be * returned. No item should ever be null. */ public Collection<T> getItems(Collection<K> keys) throws Exception; }
An example of an id finder and page by ids finder are shown below.
public static class GameIdFinderByDesigner implements IdListFinder<Integer> { private static final long serialVersionUID = 9106528005887125428L; private int designerId; private String designerName; public GameIdFinderByDesigner(int designerId, String designerName) { this.designerId = designerId; this.designerName = designerName; } @Override public List<Integer> getIds() throws Exception { DesignerGameTable designerGameTable; designerGameTable = DesignerGameTable.getInstance(); return designerGameTable.getGameIdsByDesignerId(designerId); } public int getDesignerId() { return designerId; } public String getDesignerName() { return designerName; } } public static class GamePageByIdsFinder implements PageByIdsFinder<Integer,GameDTO> { private static final long serialVersionUID = 4032331794734656685L; @Override public Collection<GameDTO> getItems(Collection<Integer> keys) throws Exception { GameTable gameTable; Collection<GameDTO> result; gameTable = GameTable.getInstance(); result = gameTable.findByPrimaryKeys(keys, GameOption.DESIGNERS); return result; } }
Updating member fields
The list cache must include another generic type, K
, for the record id, which is usually
Integer
. As record and id type are only known at run-time, the client must supply an adapter for
linking a record with its id. The interface for this is shown below
/** * Generic Adapter definition for extracting the id or primary key from a Data Transfer Object or entity bean. */ @FunctionalInterface public interface KeyExtractor<K extends Serializable,V extends Serializable> extends Serializable { public K getKey(V value); }
The ItemData
class identified by the previous article must now include the id placeholder and
whether the record needs to be lazy loaded
private static class ItemData<K,T> implements Serializable { private K id; private T item; private boolean pageExtensionReload; private boolean reload; public ItemData() { id = null; item = null; pageExtensionReload = false; reload = false; } public K getId() { return id; } public void setId(K value) { id = value; } 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; } public boolean getReload() { return reload; } public void setReload(boolean reload) { this.reload = reload; } }
List cache must also include more member variables for the query strategies.
public class ListCache<K extends Serializable,T extends Serializable> implements Serializable { public enum PaginationMode {BASE_RECORD_LIST, FULL_LIST, PAGE_BY_IDS} private ListFinder<T> baseRecordListFinder; // Strategy to load base record list in base record list pagination mode private IdListFinder<K> idListFinder; // Strategy to load ids of list for page by ids pagination mode private KeyExtractor<K,T> keyExtractor; private List<ItemData<K,T>> list; // List of records or placeholders private ListFinder<T> listFinder; // Strategy to load entire list in full list pagination mode private PageByIdsFinder<K,T> pageByIdsFinder; // Strategy to retrieve page in page by ids 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
Like base record list mode, reading a page requires checking which records must be loaded. However, when retrieving a page by ids, expected records may be deleted. If so, ids must be deleted and the next records retrieved till the page is full or no more records remain.
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; case PAGE_BY_IDS: checkPageListDataByIds(startIndex, endIndex); break; } } /** * Loads any missing records for items in a range of indices, usually the current page, from their ids. */ private void checkPageListDataByIds(int startIndex, int endIndex) throws Exception { ItemData<K,T> itemData; Collection<K> ids; Collection<T> loadingItems; List<Integer> removalIndices; Map<K,Integer> loadingIds; K id; T item; int i, index, listSize; boolean loadFailed; do { loadFailed = false; listSize = list.size(); // Find items that need to be loaded ids = new ArrayList<K>(); loadingIds = new HashMap<K,Integer>(); for (i = startIndex; i <= endIndex; i++) { if (i >= 0 && i <= listSize - 1) { itemData = list.get(i); if (itemData.getReload()) { ids.add(itemData.getId()); loadingIds.put(itemData.getId(), i); } } } if (ids.size() > 0) { // Load items from multi item finder loadingItems = pageByIdsFinder.getItems(ids); for (T loadingItem: loadingItems) { id = keyExtractor.getKey(loadingItem); index = loadingIds.get(id); itemData.setItem(loadingItem); itemData.setReload(false); itemData.setPageExtensionReload(false); } // Delete items that multi item finder did not load as they no longer exist removalIndices = new ArrayList<Integer>(); for (K loadingId: loadingIds.keySet()) { index = loadingIds.get(loadingId); item = list.get(index).getItem(); if (item == null) { removalIndices.add(index); loadFailed = true; } } Collections.sort(removalIndices, Collections.reverseOrder()); for (int removalIndex: removalIndices) { list.remove(removalIndex); } } } // If there any failed items, which are removed, higher entries may // shift to take their place in the requested page and some may be unloaded. while (loadFailed); }
Client code using pagination shouldn't read the entire list at once but reading the entire list is shown for completeness. Like retrieving a page, the ids for deleted records must be discarded.
private void checkEntireListData() throws Exception { switch (paginationMode) { case BASE_RECORD_LIST: checkEntireListDataByBaseRecords(); break; case FULL_LIST: // Entire list already loaded break; case PAGE_BY_IDS: checkEntireListDataByIds(); break; } } /** * Loads any missing records from their ids. */ private void checkEntireListDataByIds() throws Exception { ItemData<K,T> itemData; K id; T item; Collection<K> ids; Collection<T> loadingItems; List<Integer> removalIndices; Map<K,Integer> loadingIds; int i, index, size; size = list.size(); // Find items that need to be loaded ids = new ArrayList<K>(); loadingIds = new HashMap<K,Integer>(); for (i = 0; i < size; i++) { itemData = list.get(i); if (itemData.getReload()) { ids.add(itemData.getId()); loadingIds.put(itemData.getId(), i); } } // Load items from page-by-id finder loadingItems = pageByIdsFinder.getItems(ids); for (T loadingItem: loadingItems) { id = keyExtractor.getKey(loadingItem); index = loadingIds.get(id); itemData = list.get(index); itemData.setItem(loadingItem); } // Delete items that page-by-id finder did not load as they no longer exist removalIndices = new ArrayList<Integer>(); for (K loadingId: loadingIds.keySet()) { index = loadingIds.get(loadingId); item = list.get(index).getItem(); if (item == null) { removalIndices.add(index); } } Collections.sort(removalIndices, Collections.reverseOrder()); for (int removeIndex: removalIndices) { list.remove(removeIndex); } }
The existence of new pagination modes means other functions must be updated.
private void loadList() throws Exception { List<K> idList; List<T> itemList; int listSize; switch (paginationMode) { case BASE_RECORD_LIST: itemList = baseRecordListFinder.getList(); if (itemList == null) { itemList = new ArrayList<T>(); } setList(itemList); break; case FULL_LIST: itemList = listFinder.getList(); if (itemList == null) { itemList = new ArrayList<T>(); } setList(itemList, listFinder.getLoadsDetails()); break; case PAGE_BY_IDS: idList = idListFinder.getIds(); if (idList == null) { idList = new ArrayList<K>(); } setIdList(idList); break; } } /** * Caches page-by-id record markers, which are usually the record primary keys. */ private synchronized void setIdList(List<K> ids) { ItemData<K,T> itemData; reload = false; list = new ArrayList<ItemData<K,T>>(ids.size()); for (K id: ids) { itemData = new ItemData<K,T>(); itemData.setId(id); itemData.setItem(null); itemData.setPageExtensionReload(false); itemData.setReload(true); list.add(itemData); } checkSelectedIndex(); } private synchronized void setList(List<T> value, boolean pageExtensionsLoaded) { ItemData<K,T> itemData; reload = false; list = new ArrayList<ItemData<K,T>>(value.size()); for (T item: value) { itemData = new ItemData<K,T>(); itemData.setId(keyExtractor.getKey(item)); itemData.setItem(item); itemData.setPageExtensionReload(!pageExtensionsLoaded); itemData.setReload(false); list.add(itemData); } checkSelectedIndex(); }
Setting id list
Like other list setters, the id list cannot be set without its finder and resets other finders.
/**
* Caches id list already loaded, sets id list finder Strategy used to load it, and sets page by ids pagination mode.
*/
public synchronized void setIdListAndFinder(List<K> idList, IdListFinder
Notes and Limitations
The use of the term 'id' is a convenience but is a bit misleading. Despite what the term suggests, the key need not be a serial number and can be any unique key, as well as the record's primary key.
The relationship between key and record is fixed, so the page by ids finder is also fixed. In contrast, different search criteria must set their own id list finders.
Keys are in whatever order the database returns, which may not be what the user prefers, such as alphabetical
name order or descending creation date. Unless the page query includes table joins or on-the-fly calculations,
expanding the id query with an ORDER BY
clause will mean reading the table that pagination is
trying to lazy load.
Next part
Continued in By Index.