MediaWiki REL1_35
IndexPager.php
Go to the documentation of this file.
1<?php
24use MediaWiki\HookContainer\ProtectedHookAccessorTrait;
30
74abstract class IndexPager extends ContextSource implements Pager {
75 use ProtectedHookAccessorTrait;
76
78 public const DIR_ASCENDING = false;
80 public const DIR_DESCENDING = true;
81
83 public const QUERY_ASCENDING = true;
85 public const QUERY_DESCENDING = false;
86
88 public $mRequest;
90 public $mLimitsShown = [ 20, 50, 100, 250, 500 ];
92 public $mDefaultLimit = 50;
94 public $mOffset;
96 public $mLimit;
98 public $mQueryDone = false;
100 public $mDb;
103
110 protected $mIndexField;
120 protected $mOrderType;
136
138 public $mIsFirst;
140 public $mIsLast;
141
143 protected $mLastShown;
145 protected $mFirstShown;
149 protected $mDefaultQuery;
152
157 protected $mIncludeOffset = false;
158
164 public $mResult;
165
168
176 if ( $context ) {
177 $this->setContext( $context );
178 }
179
180 $this->mRequest = $this->getRequest();
181
182 # NB: the offset is quoted, not validated. It is treated as an
183 # arbitrary string to support the widest variety of index types. Be
184 # careful outputting it into HTML!
185 $this->mOffset = $this->mRequest->getText( 'offset' );
186
187 # Use consistent behavior for the limit options
188 $this->mDefaultLimit = $this->getUser()->getIntOption( 'rclimit' );
189 if ( !$this->mLimit ) {
190 // Don't override if a subclass calls $this->setLimit() in its constructor.
191 list( $this->mLimit, /* $offset */ ) = $this->mRequest
192 ->getLimitOffsetForUser( $this->getUser() );
193 }
194
195 $this->mIsBackwards = ( $this->mRequest->getVal( 'dir' ) == 'prev' );
196 # Let the subclass set the DB here; otherwise use a replica DB for the current wiki
197 $this->mDb = $this->mDb ?: wfGetDB( DB_REPLICA );
198
199 $index = $this->getIndexField(); // column to sort on
200 $extraSort = $this->getExtraSortFields(); // extra columns to sort on for query planning
201 $order = $this->mRequest->getVal( 'order' );
202
203 if ( is_array( $index ) && isset( $index[$order] ) ) {
204 $this->mOrderType = $order;
205 $this->mIndexField = $index[$order];
206 $this->mExtraSortFields = isset( $extraSort[$order] )
207 ? (array)$extraSort[$order]
208 : [];
209 } elseif ( is_array( $index ) ) {
210 # First element is the default
211 $this->mIndexField = reset( $index );
212 $this->mOrderType = key( $index );
213 $this->mExtraSortFields = isset( $extraSort[$this->mOrderType] )
214 ? (array)$extraSort[$this->mOrderType]
215 : [];
216 } else {
217 # $index is not an array
218 $this->mOrderType = null;
219 $this->mIndexField = $index;
220 $isSortAssociative = array_values( $extraSort ) !== $extraSort;
221 if ( $isSortAssociative ) {
222 $this->mExtraSortFields = isset( $extraSort[$index] )
223 ? (array)$extraSort[$index]
224 : [];
225 } else {
226 $this->mExtraSortFields = (array)$extraSort;
227 }
228 }
229
230 if ( !isset( $this->mDefaultDirection ) ) {
231 $dir = $this->getDefaultDirections();
232 $this->mDefaultDirection = is_array( $dir )
233 ? $dir[$this->mOrderType]
234 : $dir;
235 }
236 $this->linkRenderer = $linkRenderer;
237 }
238
244 public function getDatabase() {
245 return $this->mDb;
246 }
247
255 public function doQuery() {
256 $defaultOrder = ( $this->mDefaultDirection === self::DIR_ASCENDING )
257 ? self::QUERY_ASCENDING
259 $order = $this->mIsBackwards ? self::oppositeOrder( $defaultOrder ) : $defaultOrder;
260
261 # Plus an extra row so that we can tell the "next" link should be shown
262 $queryLimit = $this->mLimit + 1;
263
264 if ( $this->mOffset == '' ) {
265 $isFirst = true;
266 } else {
267 // If there's an offset, we may or may not be at the first entry.
268 // The only way to tell is to run the query in the opposite
269 // direction see if we get a row.
270 $oldIncludeOffset = $this->mIncludeOffset;
271 $this->mIncludeOffset = !$this->mIncludeOffset;
272 $oppositeOrder = self::oppositeOrder( $order );
273 $isFirst = !$this->reallyDoQuery( $this->mOffset, 1, $oppositeOrder )->numRows();
274 $this->mIncludeOffset = $oldIncludeOffset;
275 }
276
277 $this->mResult = $this->reallyDoQuery(
278 $this->mOffset,
279 $queryLimit,
280 $order
281 );
282
283 $this->extractResultInfo( $isFirst, $queryLimit, $this->mResult );
284 $this->mQueryDone = true;
285
286 $this->preprocessResults( $this->mResult );
287 $this->mResult->rewind(); // Paranoia
288 }
289
294 final protected static function oppositeOrder( $order ) {
295 return ( $order === self::QUERY_ASCENDING )
298 }
299
303 public function getResult() {
304 return $this->mResult;
305 }
306
312 public function setOffset( $offset ) {
313 $this->mOffset = $offset;
314 }
315
325 public function setLimit( $limit ) {
326 $limit = (int)$limit;
327 // WebRequest::getLimitOffsetForUser() puts a cap of 5000, so do same here.
328 if ( $limit > 5000 ) {
329 $limit = 5000;
330 }
331 if ( $limit > 0 ) {
332 $this->mLimit = $limit;
333 }
334 }
335
341 public function getLimit() {
342 return $this->mLimit;
343 }
344
352 public function setIncludeOffset( $include ) {
353 $this->mIncludeOffset = $include;
354 }
355
367 protected function extractResultInfo( $isFirst, $limit, IResultWrapper $res ) {
368 $numRows = $res->numRows();
369
370 $firstIndex = [];
371 $lastIndex = [];
372 $this->mPastTheEndIndex = [];
373 $this->mPastTheEndRow = null;
374
375 if ( $numRows ) {
376 $indexColumns = array_map( function ( $v ) {
377 // Remove any table prefix from index field
378 $parts = explode( '.', $v );
379 return end( $parts );
380 }, (array)$this->mIndexField );
381
382 $row = $res->fetchRow();
383 foreach ( $indexColumns as $indexColumn ) {
384 $firstIndex[] = $row[$indexColumn];
385 }
386
387 # Discard the extra result row if there is one
388 if ( $numRows > $this->mLimit && $numRows > 1 ) {
389 $res->seek( $numRows - 1 );
390 $this->mPastTheEndRow = $res->fetchObject();
391 foreach ( $indexColumns as $indexColumn ) {
392 $this->mPastTheEndIndex[] = $this->mPastTheEndRow->$indexColumn;
393 }
394 $res->seek( $numRows - 2 );
395 $row = $res->fetchRow();
396 foreach ( $indexColumns as $indexColumn ) {
397 $lastIndex[] = $row[$indexColumn];
398 }
399 } else {
400 $this->mPastTheEndRow = null;
401 $res->seek( $numRows - 1 );
402 $row = $res->fetchRow();
403 foreach ( $indexColumns as $indexColumn ) {
404 $lastIndex[] = $row[$indexColumn];
405 }
406 }
407 }
408
409 if ( $this->mIsBackwards ) {
410 $this->mIsFirst = ( $numRows < $limit );
411 $this->mIsLast = $isFirst;
412 $this->mLastShown = $firstIndex;
413 $this->mFirstShown = $lastIndex;
414 } else {
415 $this->mIsFirst = $isFirst;
416 $this->mIsLast = ( $numRows < $limit );
417 $this->mLastShown = $lastIndex;
418 $this->mFirstShown = $firstIndex;
419 }
420 }
421
429 protected function getSqlComment() {
430 return static::class;
431 }
432
445 public function reallyDoQuery( $offset, $limit, $order ) {
446 list( $tables, $fields, $conds, $fname, $options, $join_conds ) =
447 $this->buildQueryInfo( $offset, $limit, $order );
448
449 return $this->mDb->select( $tables, $fields, $conds, $fname, $options, $join_conds );
450 }
451
464 protected function buildQueryInfo( $offset, $limit, $order ) {
465 $fname = __METHOD__ . ' (' . $this->getSqlComment() . ')';
466 $info = $this->getQueryInfo();
467 $tables = $info['tables'];
468 $fields = $info['fields'];
469 $conds = $info['conds'] ?? [];
470 $options = $info['options'] ?? [];
471 $join_conds = $info['join_conds'] ?? [];
472 $indexColumns = (array)$this->mIndexField;
473 $sortColumns = array_merge( $indexColumns, $this->mExtraSortFields );
474
475 if ( $order === self::QUERY_ASCENDING ) {
476 $options['ORDER BY'] = $sortColumns;
477 $operator = $this->mIncludeOffset ? '>=' : '>';
478 } else {
479 $orderBy = [];
480 foreach ( $sortColumns as $col ) {
481 $orderBy[] = $col . ' DESC';
482 }
483 $options['ORDER BY'] = $orderBy;
484 $operator = $this->mIncludeOffset ? '<=' : '<';
485 }
486 if ( $offset ) {
487 $offsets = explode( '|', $offset, /* Limit to max of indices */ count( $indexColumns ) );
488
489 $conds[] = $this->buildOffsetConds(
490 $offsets,
491 $indexColumns,
492 $operator
493 );
494 }
495 $options['LIMIT'] = intval( $limit );
496 return [ $tables, $fields, $conds, $fname, $options, $join_conds ];
497 }
498
535 private function buildOffsetConds( $offsets, $columns, $operator ) {
536 $innerConds = [];
537 // $offsets and $columns are the same length
538 for ( $i = 1; $i <= count( $offsets ); $i++ ) {
539 $innerConds[] = $this->buildOffsetInnerConds(
540 array_slice( $offsets, 0, $i ),
541 array_slice( $columns, 0, $i ),
542 $operator
543 );
544 }
545 return $this->mDb->makeList( $innerConds, IDatabase::LIST_OR );
546 }
547
557 private function buildOffsetInnerConds( $offsets, $columns, $operator ) {
558 $conds = [];
559 while ( count( $offsets ) > 1 ) {
560 $conds[] = $columns[0] . '=' . $this->mDb->addQuotes( $offsets[0] );
561 array_shift( $columns );
562 array_shift( $offsets );
563 }
564 $conds[] = $columns[0] . $operator . $this->mDb->addQuotes( $offsets[0] );
565 return $this->mDb->makeList( $conds, IDatabase::LIST_AND );
566 }
567
575 protected function preprocessResults( $result ) {
576 }
577
586 public function getBody() {
587 if ( !$this->mQueryDone ) {
588 $this->doQuery();
589 }
590
591 if ( $this->mResult->numRows() ) {
592 # Do any special query batches before display
593 $this->doBatchLookups();
594 }
595
596 # Don't use any extra rows returned by the query
597 $numRows = min( $this->mResult->numRows(), $this->mLimit );
598
599 $s = $this->getStartBody();
600 if ( $numRows ) {
601 if ( $this->mIsBackwards ) {
602 for ( $i = $numRows - 1; $i >= 0; $i-- ) {
603 $this->mResult->seek( $i );
604 $row = $this->mResult->fetchObject();
605 $s .= $this->formatRow( $row );
606 }
607 } else {
608 $this->mResult->seek( 0 );
609 for ( $i = 0; $i < $numRows; $i++ ) {
610 $row = $this->mResult->fetchObject();
611 $s .= $this->formatRow( $row );
612 }
613 }
614 } else {
615 $s .= $this->getEmptyBody();
616 }
617 $s .= $this->getEndBody();
618 return $s;
619 }
620
632 protected function makeLink( $text, array $query = null, $type = null ) {
633 if ( $query === null ) {
634 return $text;
635 }
636
637 $attrs = [];
638 if ( in_array( $type, [ 'prev', 'next' ] ) ) {
639 $attrs['rel'] = $type;
640 }
641
642 if ( in_array( $type, [ 'asc', 'desc' ] ) ) {
643 $attrs['title'] = $this->msg( $type == 'asc' ? 'sort-ascending' : 'sort-descending' )->text();
644 }
645
646 if ( $type ) {
647 $attrs['class'] = "mw-{$type}link";
648 }
649
650 return $this->getLinkRenderer()->makeKnownLink(
651 $this->getTitle(),
652 new HtmlArmor( $text ),
653 $attrs,
654 $query + $this->getDefaultQuery()
655 );
656 }
657
667 protected function doBatchLookups() {
668 }
669
676 protected function getStartBody() {
677 return '';
678 }
679
687 protected function getEndBody() {
688 return '';
689 }
690
699 protected function getEmptyBody() {
700 return '';
701 }
702
712 public function getDefaultQuery() {
713 if ( !isset( $this->mDefaultQuery ) ) {
714 $this->mDefaultQuery = $this->getRequest()->getQueryValues();
715 unset( $this->mDefaultQuery['title'] );
716 unset( $this->mDefaultQuery['dir'] );
717 unset( $this->mDefaultQuery['offset'] );
718 unset( $this->mDefaultQuery['limit'] );
719 unset( $this->mDefaultQuery['order'] );
720 unset( $this->mDefaultQuery['month'] );
721 unset( $this->mDefaultQuery['year'] );
722 }
724 }
725
731 public function getNumRows() {
732 if ( !$this->mQueryDone ) {
733 $this->doQuery();
734 }
735 return $this->mResult->numRows();
736 }
737
745 public function getPagingQueries() {
746 if ( !$this->mQueryDone ) {
747 $this->doQuery();
748 }
749
750 # Don't announce the limit everywhere if it's the default
751 $urlLimit = $this->mLimit == $this->mDefaultLimit ? null : $this->mLimit;
752
753 if ( $this->mIsFirst ) {
754 $prev = false;
755 $first = false;
756 } else {
757 $prev = [
758 'dir' => 'prev',
759 'offset' => implode( '|', (array)$this->mFirstShown ),
760 'limit' => $urlLimit
761 ];
762 $first = [ 'limit' => $urlLimit ];
763 }
764 if ( $this->mIsLast ) {
765 $next = false;
766 $last = false;
767 } else {
768 $next = [ 'offset' => implode( '|', (array)$this->mLastShown ), 'limit' => $urlLimit ];
769 $last = [ 'dir' => 'prev', 'limit' => $urlLimit ];
770 }
771
772 return [
773 'prev' => $prev,
774 'next' => $next,
775 'first' => $first,
776 'last' => $last
777 ];
778 }
779
786 protected function isNavigationBarShown() {
787 if ( !$this->mQueryDone ) {
788 $this->doQuery();
789 }
790 // Hide navigation by default if there is nothing to page
791 return !( $this->mIsFirst && $this->mIsLast );
792 }
793
804 protected function getPagingLinks( $linkTexts, $disabledTexts = [] ) {
805 $queries = $this->getPagingQueries();
806 $links = [];
807
808 foreach ( $queries as $type => $query ) {
809 if ( $query !== false ) {
810 $links[$type] = $this->makeLink(
811 $linkTexts[$type],
812 $query,
813 $type
814 );
815 } elseif ( isset( $disabledTexts[$type] ) ) {
816 $links[$type] = $disabledTexts[$type];
817 } else {
818 $links[$type] = $linkTexts[$type];
819 }
820 }
821
822 return $links;
823 }
824
825 protected function getLimitLinks() {
826 $links = [];
827 if ( $this->mIsBackwards ) {
828 $offset = implode( '|', (array)$this->mPastTheEndIndex );
829 } else {
830 $offset = $this->mOffset;
831 }
832 foreach ( $this->mLimitsShown as $limit ) {
833 $links[] = $this->makeLink(
834 $this->getLanguage()->formatNum( $limit ),
835 [ 'offset' => $offset, 'limit' => $limit ],
836 'num'
837 );
838 }
839 return $links;
840 }
841
849 abstract public function formatRow( $row );
850
862 abstract public function getQueryInfo();
863
900 abstract public function getIndexField();
901
925 protected function getExtraSortFields() {
926 return [];
927 }
928
950 protected function getDefaultDirections() {
951 return self::DIR_ASCENDING;
952 }
953
964 protected function buildPrevNextNavigation(
966 $offset,
967 $limit,
968 array $query = [],
969 $atend = false
970 ) {
971 $prevNext = new PrevNextNavigationRenderer( $this );
972
973 return $prevNext->buildPrevNextNavigation( $title, $offset, $limit, $query, $atend );
974 }
975
976 protected function getLinkRenderer() {
977 if ( $this->linkRenderer === null ) {
978 $this->linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
979 }
980 return $this->linkRenderer;
981 }
982}
wfGetDB( $db, $groups=[], $wiki=false)
Get a Database object.
The simplest way of implementing IContextSource is to hold a RequestContext as a member variable and ...
getUser()
Stable to override.
msg( $key,... $params)
Get a Message object with context set Parameters are the same as wfMessage()
IContextSource $context
setContext(IContextSource $context)
Marks HTML that shouldn't be escaped.
Definition HtmlArmor.php:30
IndexPager is an efficient pager which uses a (roughly unique) index in the data set to implement pag...
getQueryInfo()
Provides all parameters needed for the main paged query.
getEndBody()
Hook into getBody() for the end of the list.
const QUERY_DESCENDING
Backwards-compatible constant for reallyDoQuery() (do not change)
string[] $mExtraSortFields
An array of secondary columns to order by.
array $mDefaultQuery
int $mLimit
The maximum number of entries to show.
bool $mDefaultDirection
$mDefaultDirection gives the direction to use when sorting results: DIR_ASCENDING or DIR_DESCENDING.
WebRequest $mRequest
getDefaultDirections()
Return the default sorting direction: DIR_ASCENDING or DIR_DESCENDING.
setOffset( $offset)
Set the offset from an other source than the request.
makeLink( $text, array $query=null, $type=null)
Make a self-link.
const DIR_ASCENDING
Backwards-compatible constant for $mDefaultDirection field (do not change)
IResultWrapper $mResult
Result object for the query.
getEmptyBody()
Hook into getBody(), for the bit between the start and the end when there are no rows.
setIncludeOffset( $include)
Set whether a row matching exactly the offset should be also included in the result or not.
IDatabase $mDb
bool $mQueryDone
Whether the listing query completed.
extractResultInfo( $isFirst, $limit, IResultWrapper $res)
Extract some useful data from the result object for use by the navigation bar, put it into $this.
getLimit()
Get the current limit.
getSqlComment()
Get some text to go in brackets in the "function name" part of the SQL comment.
string $mNavigationBar
bool $mIncludeOffset
Whether to include the offset in the query.
static oppositeOrder( $order)
getPagingLinks( $linkTexts, $disabledTexts=[])
Get paging links.
mixed $mOffset
The starting point to enumerate entries.
int[] $mLimitsShown
List of default entry limit options to be presented to clients.
buildQueryInfo( $offset, $limit, $order)
Build variables to use by the database wrapper.
string null $mOrderType
For pages that support multiple types of ordering, which one to use.
buildOffsetConds( $offsets, $columns, $operator)
Build the conditions for the offset, given that we may be paginating on a single column or multiple c...
getPagingQueries()
Get a URL query array for the prev, next, first and last links.
string string[] $mIndexField
The index to actually be used for ordering.
getStartBody()
Hook into getBody(), allows text to be inserted at the start.
__construct(IContextSource $context=null, LinkRenderer $linkRenderer=null)
Stable to call.
bool $mIsFirst
True if the current result set is the first one.
getDatabase()
Get the Database object in use.
array $mLastShown
formatRow( $row)
Returns an HTML string representing the result row $row.
array $mFirstShown
getBody()
Get the formatted result list.
LinkRenderer $linkRenderer
buildPrevNextNavigation(Title $title, $offset, $limit, array $query=[], $atend=false)
Generate (prev x| next x) (20|50|100...) type links for paging.
int $mDefaultLimit
The default entry limit choosen for clients.
doQuery()
Do the query, using information from the object context.
getNumRows()
Get the number of rows in the result set.
isNavigationBarShown()
Returns whether to show the "navigation bar" Stable to override.
const DIR_DESCENDING
Backwards-compatible constant for $mDefaultDirection field (do not change)
getIndexField()
Returns the name of the index field.
doBatchLookups()
Called from getBody(), before getStartBody() is called and after doQuery() was called.
stdClass bool null $mPastTheEndRow
Extra row fetched at the end to see if the end was reached.
const QUERY_ASCENDING
Backwards-compatible constant for reallyDoQuery() (do not change)
bool $mIsBackwards
array $mPastTheEndIndex
preprocessResults( $result)
Pre-process results; useful for performing batch existence checks, etc.
getExtraSortFields()
Returns the names of secondary columns to order by in addition to the column in getIndexField().
buildOffsetInnerConds( $offsets, $columns, $operator)
Build an inner part of an offset condition, consisting of inequalities joined by AND,...
getDefaultQuery()
Get an array of query parameters that should be put into self-links.
setLimit( $limit)
Set the limit from an other source than the request.
reallyDoQuery( $offset, $limit, $order)
Do a query with specified parameters, rather than using the object context.
Class that generates HTML links for pages.
MediaWikiServices is the service locator for the application scope of MediaWiki.
Helper class for generating prev/next links for paging.
Represents a title within MediaWiki.
Definition Title.php:42
The WebRequest class encapsulates getting at data passed in the URL or via a POSTed form stripping il...
Interface for objects which can provide a MediaWiki context on request.
Basic pager interface.
Definition Pager.php:34
Basic database interface for live and lazy-loaded relation database handles.
Definition IDatabase.php:38
Result wrapper for grabbing data queried from an IDatabase object.
const DB_REPLICA
Definition defines.php:25