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 }