001 package hirondelle.web4j.ui.tag; 002 003 import java.util.regex.Pattern; 004 import java.util.regex.Matcher; 005 import java.util.logging.Logger; 006 007 import hirondelle.web4j.database.SqlId; 008 import hirondelle.web4j.Controller; 009 import hirondelle.web4j.util.Consts; 010 import hirondelle.web4j.util.Regex; 011 import hirondelle.web4j.util.Util; 012 import hirondelle.web4j.util.EscapeChars; 013 014 /** 015 Generate links for paging through a long list of records. 016 017 <P>With this class, paging is implemented in two steps : 018 <ul> 019 <li>emitting the proper links (with the proper request parameters) to identify each page 020 <li>extracting the data from the database, using the given request parameters, and perhaps subsetting the <tt>ResultSet</tt> 021 </ul> 022 023 The first step is performed by this tag, but the second step is not (see below). 024 For an example of paging, please see the 'Discussion' feature of the <i>Fish & Chips Club</i> application. 025 026 <h3>Emitting Links For Paging</h3> 027 <P>This tag works by emitting various links which <i>are modifications of the current URI</i>. 028 029 <P><b>Example</b> 030 <PRE> 031 <w:pager pageFrom="PageIndex"> 032 <a href="placeholder_first_link">First</a> | 033 <a href="placeholder_next_link">Next</a> | 034 <a href="placeholder_previous_link">Previous</a> | 035 </w:pager> 036 </PRE> 037 038 <P>The <tt>placeholder_xxx</tt> items act as placeholders. 039 <em>They are replaced by this tag with modified values of the current URI.</em> 040 The <w:pager> tag has a single <tt>pageFrom</tt> attribute. 041 It tells this tag which request parameter it will need to modify, in order to generate the various links. 042 This modified request parameter represents the page index, in the range <tt>1..9999</tt> : for example, <tt>PageIndex=1</tt>, 043 <tt>PageIndex=2</tt>, and so on. 044 045 <P>For example, if the following URI displays "page 5" : 046 <PRE>http://www.blah.com/main/SomeAction.do?PageIndex=5&Criteria=Blah</PRE> 047 then this tag will let you emit the following links, derived from the above URI simply by replacing the value of the <tt>PageIndex</tt> request parameter : 048 <PRE>http://www.blah.com/main/SomeAction.do?PageIndex=1&Criteria=Blah 049 http://www.blah.com/main/SomeAction.do?PageIndex=6&Criteria=Blah 050 http://www.blah.com/main/SomeAction.do?PageIndex=4&Criteria=Blah</PRE> 051 052 <P>Of course, these generated links don't do the actual subsetting of the data. 053 Rather, these links simply <i>alter the current URI to use the desired value for the page index request parameter.</i> 054 The resulting request parameters are then used elsewhere to extract the desired data (as described below). 055 056 <P><b>Link Suppression</b> 057 <P>If the emission of a link would create a 'link to self', then this tag will not emit the link. 058 For example, if you are already on the first page, then the First and Previous links will not be emitted as links - only the bare text, without the link, is emitted. 059 060 061 <h3>Extracting and Subsetting the Data</h3> 062 There are three alternatives to implementing the subsetting of the <tt>ResultSet</tt> : 063 <ul> 064 <li>in the JSP itself 065 <li>in the Data Access Object (DAO) 066 <li>directly in the underlying <tt>SELECT</tt> statement 067 </ul> 068 069 <P><b>Subsetting in the JSP</b><br> 070 <ul> 071 <li>this is the simplest style 072 <li>it can be applied quickly, to add paging to any existing listing 073 <li>the DAO performs a single, unchanging <tt>SELECT</tt> that always returns the <i>same</i> set of records for all pages 074 <li>it requires only a single change to the {@link hirondelle.web4j.action.Action} - the addition of a 075 <tt>public static final</tt> {@link hirondelle.web4j.request.RequestParameter} field for the page index parameter 076 <li>the JSP performs the subsetting to display a single page at a time, simply using <tt><c:out></tt> 077 </ul> 078 079 The JSP performs the subsetting with simple JSTL. 080 In the following example, the page size is hard-coded to 25 items per page. 081 The <tt><c:out></tt> tag has <tt>begin</tt> and <tt>end</tt> attributes which can control the range of rendered items : 082 <PRE> 083 <c:forEach 084 var="item" 085 items="${items}" 086 begin="${25 * (param.PageIndex - 1)}" 087 end="${25 * param.PageIndex - 1}" 088 > 089 ...display each item... 090 </c:forEach> 091 </PRE> 092 093 Note the different expressions for begin and end : 094 <PRE>begin = 25 * (PageIndex - 1) 095 end = (25 * PageIndex) - 1 096 </PRE> 097 098 which give the following values : 099 <P> 100 <table border='1' cellspacing='0' cellpadding='3'> 101 <tr> 102 <th>Page</th> 103 <th>Begin</th> 104 <th>End</th> 105 </tr> 106 <tr> 107 <td>1</td> 108 <td>0</td> 109 <td>24</td> 110 </tr> 111 <tr> 112 <td>2</td> 113 <td>25</td> 114 <td>49</td> 115 </tr> 116 <tr> 117 <td>...</td> 118 <td>...</td> 119 <td>...</td> 120 </tr> 121 </table> 122 123 <P>An alternate variation would be to allow the page <i>size</i> to also come from a request parameter : 124 <PRE> 125 <c:forEach 126 var="item" 127 items="${items}" 128 begin="${param.PageSize * (param.PageIndex - 1)}" 129 end="${param.PageSize * param.PageIndex - 1}" 130 > 131 ...display each line... 132 </c:forEach> 133 </PRE> 134 135 136 <P><b>Subsetting in the DAO</b><br> 137 <ul> 138 <li>the full <tt>ResultSet</tt> is still returned by the <tt>SELECT</tt>, as in the JSP case above 139 <li>however, this time the DAO performs the subsetting, using 140 {@link hirondelle.web4j.database.Db#listRange(Class, SqlId, Integer, Integer, Object[])}. This avoids 141 the cost of unnecessarily parsing records into Model Objects. 142 <li>the <tt><c:out></tt> tag does not use any <tt>begin</tt> and <tt>end</tt> attributes, since the subsetting 143 has already been done. 144 </ul> 145 146 <P><b>Subsetting in the SELECT</b><br> 147 <ul> 148 <li>the underlying <tt>SELECT</tt> is constructed to return only the desired records. (Such a <tt>SELECT</tt> 149 may be difficult to construct, according to the capabilities of the target database.) 150 <li>the <tt>SELECT</tt> will likely need two request parameters to form the proper query: a page index and 151 a page size, from which <tt>start</tt> and <tt>end</tt> indices may be calculated 152 <li>the DAO and JSP are implemented as in any regular listing 153 </ul> 154 155 <P>Here are the kinds of calculations you may need when constructing such a SELECT statement 156 <P>For items enumerated with a 0-based index : 157 <PRE> 158 start_index = page_size * (page_index - 1) 159 end_index = page_size * page_index - 1 160 </PRE> 161 162 <P>For items enumerated with a 1-based index : 163 <PRE> 164 start_index = page_size * (page_index - 1) + 1 165 end_index = page_size * page_index 166 </PRE> 167 168 <h3>Setting In <tt>web.xml</tt></h3> 169 Note that there is a <tt>MaxRows</tt> setting in <tt>web.xml</tt> which controls the maximum number of records returned by 170 a <tt>SELECT</tt>. 171 */ 172 public final class Pager extends TagHelper { 173 174 /** 175 Name of request parameter that holds the index of the current page. 176 177 <P>This request parameter takes values in the range <tt>1..9999</tt>. 178 179 <P>(The name of this method is confusing. It should rather be named <tt>setPageIndex</tt>.) 180 */ 181 public void setPageFrom(String aPageIndexParamName) { 182 checkForContent("PageFrom", aPageIndexParamName); 183 fPageIndexParamName = aPageIndexParamName.trim(); 184 fPageIdxPattern = Pattern.compile(fPageIndexParamName + "=(?:\\d){1,4}"); 185 } 186 187 /** See class comment. */ 188 @Override protected String getEmittedText(String aOriginalBody) { 189 fCurrentPageIdx = getNumericParamValue(fPageIndexParamName); 190 fCurrentURI = getCurrentURI(); 191 fLogger.fine("Current URI: " + fCurrentURI); 192 fLogger.fine("Current Page : " + fCurrentPageIdx); 193 String firstURI = getFirstURI(); 194 String nextURI = getNextURI(); 195 String previousURI = getPreviousURI(); 196 String result = getBodyWithUpdatedPlaceholders(aOriginalBody, firstURI, nextURI, previousURI); 197 return result; 198 } 199 200 // PRIVATE // 201 202 private String fPageIndexParamName; 203 private Pattern fPageIdxPattern; 204 private String fCurrentURI; 205 private int fCurrentPageIdx; 206 207 private static final String NO_LINK = Consts.EMPTY_STRING; 208 private static final int FIRST_PAGE = 1; 209 private static final String PLACEHOLDER_FIRST_LINK = "placeholder_first_link"; 210 private static final String PLACEHOLDER_NEXT_LINK = "placeholder_next_link"; 211 private static final String PLACEHOLDER_PREVIOUS_LINK = "placeholder_previous_link"; 212 /** Regex for an anchor tag 'A'. */ 213 private static final Pattern LINK_PATTERN = Pattern.compile(Regex.LINK, Pattern.CASE_INSENSITIVE); 214 private static final Logger fLogger = Util.getLogger(Pager.class); 215 216 private String getCurrentURI() { 217 return (String)getRequest().getAttribute(Controller.CURRENT_URI); 218 } 219 220 private boolean isFirstPage() { 221 return fCurrentPageIdx == FIRST_PAGE; 222 } 223 224 private int getNumericParamValue(String aParamName) { 225 String value = getRequest().getParameter(aParamName); 226 Integer result = Integer.valueOf(value); 227 if (result < 1) { 228 throw new IllegalArgumentException("Param named " + Util.quote(aParamName) + " must be >= 1. Value: " + Util.quote(result) + ". Page Name: " + getPageName()); 229 } 230 return result.intValue(); 231 } 232 233 private String getFirstURI() { 234 return isFirstPage() ? NO_LINK : forPage(FIRST_PAGE); 235 } 236 237 private String getNextURI() { 238 return forPage(fCurrentPageIdx + 1); 239 } 240 241 private String getPreviousURI() { 242 return isFirstPage() ? NO_LINK : forPage(fCurrentPageIdx - 1); 243 } 244 245 private String forPage(int aNewPageIndex){ 246 String result = null; 247 StringBuffer uri = new StringBuffer(); 248 Matcher matcher = fPageIdxPattern.matcher(fCurrentURI); 249 while ( matcher.find() ){ 250 matcher.appendReplacement(uri, getReplacement(aNewPageIndex)); 251 } 252 matcher.appendTail(uri); 253 result = getResponse().encodeURL(uri.toString()); 254 return EscapeChars.forHTML(result); 255 } 256 257 private String getReplacement(int aNewPageIdx){ 258 return EscapeChars.forReplacementString(fPageIndexParamName + "=" + aNewPageIdx); 259 } 260 261 private String getBodyWithUpdatedPlaceholders(String aOriginalBody, String aFirstURI, String aNextURI, String aPreviousURI){ 262 fLogger.fine("First URI: " + aFirstURI); 263 fLogger.fine("Next URI: " + aNextURI); 264 fLogger.fine("Previous URI: " + aPreviousURI); 265 266 StringBuffer result = new StringBuffer(); 267 Matcher matcher = LINK_PATTERN.matcher(aOriginalBody); 268 while ( matcher.find() ){ 269 fLogger.finest("Getting href as first group."); 270 String href = matcher.group(Regex.FIRST_GROUP); 271 String replacement = null; 272 if ( PLACEHOLDER_FIRST_LINK.equals(href) ){ 273 replacement = aFirstURI; 274 } 275 else if ( PLACEHOLDER_NEXT_LINK.equals(href) ){ 276 replacement = aNextURI; 277 } 278 else if ( PLACEHOLDER_PREVIOUS_LINK.equals(href) ){ 279 replacement = aPreviousURI; 280 } 281 else { 282 String message = "Body of pager tag can only contain links to special placeholder text. Page Name "+ getPageName(); 283 fLogger.severe(message); 284 throw new IllegalArgumentException(message); 285 } 286 matcher.appendReplacement(result, getReplacementHref(matcher, replacement)); 287 } 288 matcher.appendTail(result); 289 return result.toString(); 290 } 291 292 private String getReplacementHref(Matcher aMatcher, String aReplacementHref){ 293 String result = null; 294 int LINK_BODY = 3; 295 int ATTRS_AFTER_HREF = 2; 296 if ( Util.textHasContent(aReplacementHref) ){ 297 result = "<A HREF=\"" + aReplacementHref + "\" " + aMatcher.group(ATTRS_AFTER_HREF)+" >" + aMatcher.group(LINK_BODY) + "</A>"; 298 } 299 else { 300 result = aMatcher.group(LINK_BODY); 301 } 302 return EscapeChars.forReplacementString(result); 303 } 304 }