Merge "Revert "Consolidate parts of WebStart.php and doMaintenance.php into Initializ...
[lhc/web/wiklou.git] / includes / pager / IndexPager.php
1 <?php
2 /**
3 * Efficient paging for SQL queries.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup Pager
22 */
23
24 /**
25 * IndexPager is an efficient pager which uses a (roughly unique) index in the
26 * data set to implement paging, rather than a "LIMIT offset,limit" clause.
27 * In MySQL, such a limit/offset clause requires counting through the
28 * specified number of offset rows to find the desired data, which can be
29 * expensive for large offsets.
30 *
31 * ReverseChronologicalPager is a child class of the abstract IndexPager, and
32 * contains some formatting and display code which is specific to the use of
33 * timestamps as indexes. Here is a synopsis of its operation:
34 *
35 * * The query is specified by the offset, limit and direction (dir)
36 * parameters, in addition to any subclass-specific parameters.
37 * * The offset is the non-inclusive start of the DB query. A row with an
38 * index value equal to the offset will never be shown.
39 * * The query may either be done backwards, where the rows are returned by
40 * the database in the opposite order to which they are displayed to the
41 * user, or forwards. This is specified by the "dir" parameter, dir=prev
42 * means backwards, anything else means forwards. The offset value
43 * specifies the start of the database result set, which may be either
44 * the start or end of the displayed data set. This allows "previous"
45 * links to be implemented without knowledge of the index value at the
46 * start of the previous page.
47 * * An additional row beyond the user-specified limit is always requested.
48 * This allows us to tell whether we should display a "next" link in the
49 * case of forwards mode, or a "previous" link in the case of backwards
50 * mode. Determining whether to display the other link (the one for the
51 * page before the start of the database result set) can be done
52 * heuristically by examining the offset.
53 *
54 * * An empty offset indicates that the offset condition should be omitted
55 * from the query. This naturally produces either the first page or the
56 * last page depending on the dir parameter.
57 *
58 * Subclassing the pager to implement concrete functionality should be fairly
59 * simple, please see the examples in HistoryAction.php and
60 * SpecialBlockList.php. You just need to override formatRow(),
61 * getQueryInfo() and getIndexField(). Don't forget to call the parent
62 * constructor if you override it.
63 *
64 * @ingroup Pager
65 */
66 abstract class IndexPager extends ContextSource implements Pager {
67 public $mRequest;
68 public $mLimitsShown = array( 20, 50, 100, 250, 500 );
69 public $mDefaultLimit = 50;
70 public $mOffset, $mLimit;
71 public $mQueryDone = false;
72 public $mDb;
73 public $mPastTheEndRow;
74
75 /**
76 * The index to actually be used for ordering. This is a single column,
77 * for one ordering, even if multiple orderings are supported.
78 */
79 protected $mIndexField;
80 /**
81 * An array of secondary columns to order by. These fields are not part of the offset.
82 * This is a column list for one ordering, even if multiple orderings are supported.
83 */
84 protected $mExtraSortFields;
85 /** For pages that support multiple types of ordering, which one to use.
86 */
87 protected $mOrderType;
88 /**
89 * $mDefaultDirection gives the direction to use when sorting results:
90 * false for ascending, true for descending. If $mIsBackwards is set, we
91 * start from the opposite end, but we still sort the page itself according
92 * to $mDefaultDirection. E.g., if $mDefaultDirection is false but we're
93 * going backwards, we'll display the last page of results, but the last
94 * result will be at the bottom, not the top.
95 *
96 * Like $mIndexField, $mDefaultDirection will be a single value even if the
97 * class supports multiple default directions for different order types.
98 */
99 public $mDefaultDirection;
100 public $mIsBackwards;
101
102 /** True if the current result set is the first one */
103 public $mIsFirst;
104 public $mIsLast;
105
106 protected $mLastShown, $mFirstShown, $mPastTheEndIndex, $mDefaultQuery, $mNavigationBar;
107
108 /**
109 * Whether to include the offset in the query
110 */
111 protected $mIncludeOffset = false;
112
113 /**
114 * Result object for the query. Warning: seek before use.
115 *
116 * @var ResultWrapper
117 */
118 public $mResult;
119
120 public function __construct( IContextSource $context = null ) {
121 if ( $context ) {
122 $this->setContext( $context );
123 }
124
125 $this->mRequest = $this->getRequest();
126
127 # NB: the offset is quoted, not validated. It is treated as an
128 # arbitrary string to support the widest variety of index types. Be
129 # careful outputting it into HTML!
130 $this->mOffset = $this->mRequest->getText( 'offset' );
131
132 # Use consistent behavior for the limit options
133 $this->mDefaultLimit = $this->getUser()->getIntOption( 'rclimit' );
134 if ( !$this->mLimit ) {
135 // Don't override if a subclass calls $this->setLimit() in its constructor.
136 list( $this->mLimit, /* $offset */ ) = $this->mRequest->getLimitOffset();
137 }
138
139 $this->mIsBackwards = ( $this->mRequest->getVal( 'dir' ) == 'prev' );
140 # Let the subclass set the DB here; otherwise use a slave DB for the current wiki
141 $this->mDb = $this->mDb ?: wfGetDB( DB_SLAVE );
142
143 $index = $this->getIndexField(); // column to sort on
144 $extraSort = $this->getExtraSortFields(); // extra columns to sort on for query planning
145 $order = $this->mRequest->getVal( 'order' );
146 if ( is_array( $index ) && isset( $index[$order] ) ) {
147 $this->mOrderType = $order;
148 $this->mIndexField = $index[$order];
149 $this->mExtraSortFields = isset( $extraSort[$order] )
150 ? (array)$extraSort[$order]
151 : array();
152 } elseif ( is_array( $index ) ) {
153 # First element is the default
154 reset( $index );
155 list( $this->mOrderType, $this->mIndexField ) = each( $index );
156 $this->mExtraSortFields = isset( $extraSort[$this->mOrderType] )
157 ? (array)$extraSort[$this->mOrderType]
158 : array();
159 } else {
160 # $index is not an array
161 $this->mOrderType = null;
162 $this->mIndexField = $index;
163 $this->mExtraSortFields = (array)$extraSort;
164 }
165
166 if ( !isset( $this->mDefaultDirection ) ) {
167 $dir = $this->getDefaultDirections();
168 $this->mDefaultDirection = is_array( $dir )
169 ? $dir[$this->mOrderType]
170 : $dir;
171 }
172 }
173
174 /**
175 * Get the Database object in use
176 *
177 * @return DatabaseBase
178 */
179 public function getDatabase() {
180 return $this->mDb;
181 }
182
183 /**
184 * Do the query, using information from the object context. This function
185 * has been kept minimal to make it overridable if necessary, to allow for
186 * result sets formed from multiple DB queries.
187 */
188 public function doQuery() {
189 # Use the child class name for profiling
190 $fname = __METHOD__ . ' (' . get_class( $this ) . ')';
191 wfProfileIn( $fname );
192
193 $descending = ( $this->mIsBackwards == $this->mDefaultDirection );
194 # Plus an extra row so that we can tell the "next" link should be shown
195 $queryLimit = $this->mLimit + 1;
196
197 if ( $this->mOffset == '' ) {
198 $isFirst = true;
199 } else {
200 // If there's an offset, we may or may not be at the first entry.
201 // The only way to tell is to run the query in the opposite
202 // direction see if we get a row.
203 $oldIncludeOffset = $this->mIncludeOffset;
204 $this->mIncludeOffset = !$this->mIncludeOffset;
205 $isFirst = !$this->reallyDoQuery( $this->mOffset, 1, !$descending )->numRows();
206 $this->mIncludeOffset = $oldIncludeOffset;
207 }
208
209 $this->mResult = $this->reallyDoQuery(
210 $this->mOffset,
211 $queryLimit,
212 $descending
213 );
214
215 $this->extractResultInfo( $isFirst, $queryLimit, $this->mResult );
216 $this->mQueryDone = true;
217
218 $this->preprocessResults( $this->mResult );
219 $this->mResult->rewind(); // Paranoia
220
221 wfProfileOut( $fname );
222 }
223
224 /**
225 * @return ResultWrapper The result wrapper.
226 */
227 function getResult() {
228 return $this->mResult;
229 }
230
231 /**
232 * Set the offset from an other source than the request
233 *
234 * @param int|string $offset
235 */
236 function setOffset( $offset ) {
237 $this->mOffset = $offset;
238 }
239
240 /**
241 * Set the limit from an other source than the request
242 *
243 * Verifies limit is between 1 and 5000
244 *
245 * @param int|string $limit
246 */
247 function setLimit( $limit ) {
248 $limit = (int)$limit;
249 // WebRequest::getLimitOffset() puts a cap of 5000, so do same here.
250 if ( $limit > 5000 ) {
251 $limit = 5000;
252 }
253 if ( $limit > 0 ) {
254 $this->mLimit = $limit;
255 }
256 }
257
258 /**
259 * Get the current limit
260 *
261 * @return int
262 */
263 function getLimit() {
264 return $this->mLimit;
265 }
266
267 /**
268 * Set whether a row matching exactly the offset should be also included
269 * in the result or not. By default this is not the case, but when the
270 * offset is user-supplied this might be wanted.
271 *
272 * @param bool $include
273 */
274 public function setIncludeOffset( $include ) {
275 $this->mIncludeOffset = $include;
276 }
277
278 /**
279 * Extract some useful data from the result object for use by
280 * the navigation bar, put it into $this
281 *
282 * @param bool $isFirst False if there are rows before those fetched (i.e.
283 * if a "previous" link would make sense)
284 * @param int $limit Exact query limit
285 * @param ResultWrapper $res
286 */
287 function extractResultInfo( $isFirst, $limit, ResultWrapper $res ) {
288 $numRows = $res->numRows();
289 if ( $numRows ) {
290 # Remove any table prefix from index field
291 $parts = explode( '.', $this->mIndexField );
292 $indexColumn = end( $parts );
293
294 $row = $res->fetchRow();
295 $firstIndex = $row[$indexColumn];
296
297 # Discard the extra result row if there is one
298 if ( $numRows > $this->mLimit && $numRows > 1 ) {
299 $res->seek( $numRows - 1 );
300 $this->mPastTheEndRow = $res->fetchObject();
301 $this->mPastTheEndIndex = $this->mPastTheEndRow->$indexColumn;
302 $res->seek( $numRows - 2 );
303 $row = $res->fetchRow();
304 $lastIndex = $row[$indexColumn];
305 } else {
306 $this->mPastTheEndRow = null;
307 # Setting indexes to an empty string means that they will be
308 # omitted if they would otherwise appear in URLs. It just so
309 # happens that this is the right thing to do in the standard
310 # UI, in all the relevant cases.
311 $this->mPastTheEndIndex = '';
312 $res->seek( $numRows - 1 );
313 $row = $res->fetchRow();
314 $lastIndex = $row[$indexColumn];
315 }
316 } else {
317 $firstIndex = '';
318 $lastIndex = '';
319 $this->mPastTheEndRow = null;
320 $this->mPastTheEndIndex = '';
321 }
322
323 if ( $this->mIsBackwards ) {
324 $this->mIsFirst = ( $numRows < $limit );
325 $this->mIsLast = $isFirst;
326 $this->mLastShown = $firstIndex;
327 $this->mFirstShown = $lastIndex;
328 } else {
329 $this->mIsFirst = $isFirst;
330 $this->mIsLast = ( $numRows < $limit );
331 $this->mLastShown = $lastIndex;
332 $this->mFirstShown = $firstIndex;
333 }
334 }
335
336 /**
337 * Get some text to go in brackets in the "function name" part of the SQL comment
338 *
339 * @return string
340 */
341 function getSqlComment() {
342 return get_class( $this );
343 }
344
345 /**
346 * Do a query with specified parameters, rather than using the object
347 * context
348 *
349 * @param string $offset Index offset, inclusive
350 * @param int $limit Exact query limit
351 * @param bool $descending Query direction, false for ascending, true for descending
352 * @return ResultWrapper
353 */
354 public function reallyDoQuery( $offset, $limit, $descending ) {
355 list( $tables, $fields, $conds, $fname, $options, $join_conds ) =
356 $this->buildQueryInfo( $offset, $limit, $descending );
357
358 return $this->mDb->select( $tables, $fields, $conds, $fname, $options, $join_conds );
359 }
360
361 /**
362 * Build variables to use by the database wrapper.
363 *
364 * @param string $offset Index offset, inclusive
365 * @param int $limit Exact query limit
366 * @param bool $descending Query direction, false for ascending, true for descending
367 * @return array
368 */
369 protected function buildQueryInfo( $offset, $limit, $descending ) {
370 $fname = __METHOD__ . ' (' . $this->getSqlComment() . ')';
371 $info = $this->getQueryInfo();
372 $tables = $info['tables'];
373 $fields = $info['fields'];
374 $conds = isset( $info['conds'] ) ? $info['conds'] : array();
375 $options = isset( $info['options'] ) ? $info['options'] : array();
376 $join_conds = isset( $info['join_conds'] ) ? $info['join_conds'] : array();
377 $sortColumns = array_merge( array( $this->mIndexField ), $this->mExtraSortFields );
378 if ( $descending ) {
379 $options['ORDER BY'] = $sortColumns;
380 $operator = $this->mIncludeOffset ? '>=' : '>';
381 } else {
382 $orderBy = array();
383 foreach ( $sortColumns as $col ) {
384 $orderBy[] = $col . ' DESC';
385 }
386 $options['ORDER BY'] = $orderBy;
387 $operator = $this->mIncludeOffset ? '<=' : '<';
388 }
389 if ( $offset != '' ) {
390 $conds[] = $this->mIndexField . $operator . $this->mDb->addQuotes( $offset );
391 }
392 $options['LIMIT'] = intval( $limit );
393 return array( $tables, $fields, $conds, $fname, $options, $join_conds );
394 }
395
396 /**
397 * Pre-process results; useful for performing batch existence checks, etc.
398 *
399 * @param ResultWrapper $result
400 */
401 protected function preprocessResults( $result ) {
402 }
403
404 /**
405 * Get the formatted result list. Calls getStartBody(), formatRow() and
406 * getEndBody(), concatenates the results and returns them.
407 *
408 * @return string
409 */
410 public function getBody() {
411 if ( !$this->mQueryDone ) {
412 $this->doQuery();
413 }
414
415 if ( $this->mResult->numRows() ) {
416 # Do any special query batches before display
417 $this->doBatchLookups();
418 }
419
420 # Don't use any extra rows returned by the query
421 $numRows = min( $this->mResult->numRows(), $this->mLimit );
422
423 $s = $this->getStartBody();
424 if ( $numRows ) {
425 if ( $this->mIsBackwards ) {
426 for ( $i = $numRows - 1; $i >= 0; $i-- ) {
427 $this->mResult->seek( $i );
428 $row = $this->mResult->fetchObject();
429 $s .= $this->formatRow( $row );
430 }
431 } else {
432 $this->mResult->seek( 0 );
433 for ( $i = 0; $i < $numRows; $i++ ) {
434 $row = $this->mResult->fetchObject();
435 $s .= $this->formatRow( $row );
436 }
437 }
438 } else {
439 $s .= $this->getEmptyBody();
440 }
441 $s .= $this->getEndBody();
442 return $s;
443 }
444
445 /**
446 * Make a self-link
447 *
448 * @param string $text Text displayed on the link
449 * @param array $query Associative array of parameter to be in the query string
450 * @param string $type Value of the "rel" attribute
451 *
452 * @return string HTML fragment
453 */
454 function makeLink( $text, array $query = null, $type = null ) {
455 if ( $query === null ) {
456 return $text;
457 }
458
459 $attrs = array();
460 if ( in_array( $type, array( 'first', 'prev', 'next', 'last' ) ) ) {
461 # HTML5 rel attributes
462 $attrs['rel'] = $type;
463 }
464
465 if ( $type ) {
466 $attrs['class'] = "mw-{$type}link";
467 }
468
469 return Linker::linkKnown(
470 $this->getTitle(),
471 $text,
472 $attrs,
473 $query + $this->getDefaultQuery()
474 );
475 }
476
477 /**
478 * Called from getBody(), before getStartBody() is called and
479 * after doQuery() was called. This will be called only if there
480 * are rows in the result set.
481 *
482 * @return void
483 */
484 protected function doBatchLookups() {
485 }
486
487 /**
488 * Hook into getBody(), allows text to be inserted at the start. This
489 * will be called even if there are no rows in the result set.
490 *
491 * @return string
492 */
493 protected function getStartBody() {
494 return '';
495 }
496
497 /**
498 * Hook into getBody() for the end of the list
499 *
500 * @return string
501 */
502 protected function getEndBody() {
503 return '';
504 }
505
506 /**
507 * Hook into getBody(), for the bit between the start and the
508 * end when there are no rows
509 *
510 * @return string
511 */
512 protected function getEmptyBody() {
513 return '';
514 }
515
516 /**
517 * Get an array of query parameters that should be put into self-links.
518 * By default, all parameters passed in the URL are used, except for a
519 * short blacklist.
520 *
521 * @return array Associative array
522 */
523 function getDefaultQuery() {
524 if ( !isset( $this->mDefaultQuery ) ) {
525 $this->mDefaultQuery = $this->getRequest()->getQueryValues();
526 unset( $this->mDefaultQuery['title'] );
527 unset( $this->mDefaultQuery['dir'] );
528 unset( $this->mDefaultQuery['offset'] );
529 unset( $this->mDefaultQuery['limit'] );
530 unset( $this->mDefaultQuery['order'] );
531 unset( $this->mDefaultQuery['month'] );
532 unset( $this->mDefaultQuery['year'] );
533 }
534 return $this->mDefaultQuery;
535 }
536
537 /**
538 * Get the number of rows in the result set
539 *
540 * @return int
541 */
542 function getNumRows() {
543 if ( !$this->mQueryDone ) {
544 $this->doQuery();
545 }
546 return $this->mResult->numRows();
547 }
548
549 /**
550 * Get a URL query array for the prev, next, first and last links.
551 *
552 * @return array
553 */
554 function getPagingQueries() {
555 if ( !$this->mQueryDone ) {
556 $this->doQuery();
557 }
558
559 # Don't announce the limit everywhere if it's the default
560 $urlLimit = $this->mLimit == $this->mDefaultLimit ? null : $this->mLimit;
561
562 if ( $this->mIsFirst ) {
563 $prev = false;
564 $first = false;
565 } else {
566 $prev = array(
567 'dir' => 'prev',
568 'offset' => $this->mFirstShown,
569 'limit' => $urlLimit
570 );
571 $first = array( 'limit' => $urlLimit );
572 }
573 if ( $this->mIsLast ) {
574 $next = false;
575 $last = false;
576 } else {
577 $next = array( 'offset' => $this->mLastShown, 'limit' => $urlLimit );
578 $last = array( 'dir' => 'prev', 'limit' => $urlLimit );
579 }
580 return array(
581 'prev' => $prev,
582 'next' => $next,
583 'first' => $first,
584 'last' => $last
585 );
586 }
587
588 /**
589 * Returns whether to show the "navigation bar"
590 *
591 * @return bool
592 */
593 function isNavigationBarShown() {
594 if ( !$this->mQueryDone ) {
595 $this->doQuery();
596 }
597 // Hide navigation by default if there is nothing to page
598 return !( $this->mIsFirst && $this->mIsLast );
599 }
600
601 /**
602 * Get paging links. If a link is disabled, the item from $disabledTexts
603 * will be used. If there is no such item, the unlinked text from
604 * $linkTexts will be used. Both $linkTexts and $disabledTexts are arrays
605 * of HTML.
606 *
607 * @param array $linkTexts
608 * @param array $disabledTexts
609 * @return array
610 */
611 function getPagingLinks( $linkTexts, $disabledTexts = array() ) {
612 $queries = $this->getPagingQueries();
613 $links = array();
614
615 foreach ( $queries as $type => $query ) {
616 if ( $query !== false ) {
617 $links[$type] = $this->makeLink(
618 $linkTexts[$type],
619 $queries[$type],
620 $type
621 );
622 } elseif ( isset( $disabledTexts[$type] ) ) {
623 $links[$type] = $disabledTexts[$type];
624 } else {
625 $links[$type] = $linkTexts[$type];
626 }
627 }
628
629 return $links;
630 }
631
632 function getLimitLinks() {
633 $links = array();
634 if ( $this->mIsBackwards ) {
635 $offset = $this->mPastTheEndIndex;
636 } else {
637 $offset = $this->mOffset;
638 }
639 foreach ( $this->mLimitsShown as $limit ) {
640 $links[] = $this->makeLink(
641 $this->getLanguage()->formatNum( $limit ),
642 array( 'offset' => $offset, 'limit' => $limit ),
643 'num'
644 );
645 }
646 return $links;
647 }
648
649 /**
650 * Abstract formatting function. This should return an HTML string
651 * representing the result row $row. Rows will be concatenated and
652 * returned by getBody()
653 *
654 * @param array|stdClass $row Database row
655 * @return string
656 */
657 abstract function formatRow( $row );
658
659 /**
660 * This function should be overridden to provide all parameters
661 * needed for the main paged query. It returns an associative
662 * array with the following elements:
663 * tables => Table(s) for passing to Database::select()
664 * fields => Field(s) for passing to Database::select(), may be *
665 * conds => WHERE conditions
666 * options => option array
667 * join_conds => JOIN conditions
668 *
669 * @return array
670 */
671 abstract function getQueryInfo();
672
673 /**
674 * This function should be overridden to return the name of the index fi-
675 * eld. If the pager supports multiple orders, it may return an array of
676 * 'querykey' => 'indexfield' pairs, so that a request with &count=querykey
677 * will use indexfield to sort. In this case, the first returned key is
678 * the default.
679 *
680 * Needless to say, it's really not a good idea to use a non-unique index
681 * for this! That won't page right.
682 *
683 * @return string|array
684 */
685 abstract function getIndexField();
686
687 /**
688 * This function should be overridden to return the names of secondary columns
689 * to order by in addition to the column in getIndexField(). These fields will
690 * not be used in the pager offset or in any links for users.
691 *
692 * If getIndexField() returns an array of 'querykey' => 'indexfield' pairs then
693 * this must return a corresponding array of 'querykey' => array( fields...) pairs
694 * in order for a request with &count=querykey to use array( fields...) to sort.
695 *
696 * This is useful for pagers that GROUP BY a unique column (say page_id)
697 * and ORDER BY another (say page_len). Using GROUP BY and ORDER BY both on
698 * page_len,page_id avoids temp tables (given a page_len index). This would
699 * also work if page_id was non-unique but we had a page_len,page_id index.
700 *
701 * @return array
702 */
703 protected function getExtraSortFields() {
704 return array();
705 }
706
707 /**
708 * Return the default sorting direction: false for ascending, true for
709 * descending. You can also have an associative array of ordertype => dir,
710 * if multiple order types are supported. In this case getIndexField()
711 * must return an array, and the keys of that must exactly match the keys
712 * of this.
713 *
714 * For backward compatibility, this method's return value will be ignored
715 * if $this->mDefaultDirection is already set when the constructor is
716 * called, for instance if it's statically initialized. In that case the
717 * value of that variable (which must be a boolean) will be used.
718 *
719 * Note that despite its name, this does not return the value of the
720 * $this->mDefaultDirection member variable. That's the default for this
721 * particular instantiation, which is a single value. This is the set of
722 * all defaults for the class.
723 *
724 * @return bool
725 */
726 protected function getDefaultDirections() {
727 return false;
728 }
729 }