Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
6.64% |
17 / 256 |
|
0.00% |
0 / 34 |
CRAP | |
0.00% |
0 / 1 |
IndexPager | |
6.67% |
17 / 255 |
|
0.00% |
0 / 34 |
6823.76 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 42 |
|
0.00% |
0 / 1 |
182 | |||
getDatabase | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
doQuery | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
20 | |||
oppositeOrder | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
getResult | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getResultOffset | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setOffset | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setLimit | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
getLimit | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setIncludeOffset | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
extractResultInfo | |
0.00% |
0 / 36 |
|
0.00% |
0 / 1 |
90 | |||
getSqlComment | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
reallyDoQuery | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
buildQueryInfo | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
42 | |||
buildOffsetConds | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
preprocessResults | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getRow | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getBody | |
80.95% |
17 / 21 |
|
0.00% |
0 / 1 |
7.34 | |||
getModuleStyles | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getFooter | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
makeLink | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
56 | |||
doBatchLookups | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getStartBody | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getEndBody | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getEmptyBody | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getDefaultQuery | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
6 | |||
getNumRows | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
getPagingQueries | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
30 | |||
getOffsetQuery | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
getNavigationBuilder | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
30 | |||
isNavigationBarShown | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
formatRow | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
getQueryInfo | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
getIndexField | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
getExtraSortFields | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getDefaultDirections | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getLinkRenderer | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | */ |
20 | |
21 | namespace MediaWiki\Pager; |
22 | |
23 | use HtmlArmor; |
24 | use MediaWiki\Context\ContextSource; |
25 | use MediaWiki\Context\IContextSource; |
26 | use MediaWiki\Html\Html; |
27 | use MediaWiki\Linker\LinkRenderer; |
28 | use MediaWiki\MediaWikiServices; |
29 | use MediaWiki\Navigation\PagerNavigationBuilder; |
30 | use MediaWiki\Request\WebRequest; |
31 | use stdClass; |
32 | use Wikimedia\Rdbms\IReadableDatabase; |
33 | use Wikimedia\Rdbms\IResultWrapper; |
34 | |
35 | /** |
36 | * Efficient paging for SQL queries that use a (roughly unique) index. |
37 | * |
38 | * This is for paging through data sets stored in tables with a unique |
39 | * index, instead of a naive "LIMIT offset,limit" clause. |
40 | * |
41 | * In MySQL, such a limit/offset clause requires counting through the |
42 | * specified number of offset rows to find the desired data, which can be |
43 | * expensive for large offsets. |
44 | * |
45 | * ReverseChronologicalPager is a child class of the abstract IndexPager, and |
46 | * contains some formatting and display code which is specific to the use of |
47 | * timestamps as indexes. Here is a synopsis of its operation: |
48 | * |
49 | * * The query is specified by the offset, limit and direction (dir) |
50 | * parameters, in addition to any subclass-specific parameters. |
51 | * * The offset is the non-inclusive start of the DB query. A row with an |
52 | * index value equal to the offset will never be shown. |
53 | * * The query may either be done backwards, where the rows are returned by |
54 | * the database in the opposite order to which they are displayed to the |
55 | * user, or forwards. This is specified by the "dir" parameter, dir=prev |
56 | * means backwards, anything else means forwards. The offset value |
57 | * specifies the start of the database result set, which may be either |
58 | * the start or end of the displayed data set. This allows "previous" |
59 | * links to be implemented without knowledge of the index value at the |
60 | * start of the previous page. |
61 | * * An additional row beyond the user-specified limit is always requested. |
62 | * This allows us to tell whether we should display a "next" link in the |
63 | * case of forwards mode, or a "previous" link in the case of backwards |
64 | * mode. Determining whether to display the other link (the one for the |
65 | * page before the start of the database result set) can be done |
66 | * heuristically by examining the offset. |
67 | * |
68 | * * An empty offset indicates that the offset condition should be omitted |
69 | * from the query. This naturally produces either the first page or the |
70 | * last page depending on the dir parameter. |
71 | * |
72 | * Subclassing the pager to implement concrete functionality should be fairly |
73 | * simple, please see the examples in HistoryAction.php and |
74 | * SpecialBlockList.php. You just need to override formatRow(), |
75 | * getQueryInfo() and getIndexField(). Don't forget to call the parent |
76 | * constructor if you override it. |
77 | * |
78 | * @stable to extend |
79 | * @ingroup Pager |
80 | */ |
81 | abstract class IndexPager extends ContextSource implements Pager { |
82 | |
83 | /** Backwards-compatible constant for $mDefaultDirection field (do not change) */ |
84 | public const DIR_ASCENDING = false; |
85 | /** Backwards-compatible constant for $mDefaultDirection field (do not change) */ |
86 | public const DIR_DESCENDING = true; |
87 | |
88 | /** Backwards-compatible constant for reallyDoQuery() (do not change) */ |
89 | public const QUERY_ASCENDING = true; |
90 | /** Backwards-compatible constant for reallyDoQuery() (do not change) */ |
91 | public const QUERY_DESCENDING = false; |
92 | |
93 | /** @var WebRequest */ |
94 | public $mRequest; |
95 | /** @var int[] List of default entry limit options to be presented to clients */ |
96 | public $mLimitsShown = [ 20, 50, 100, 250, 500 ]; |
97 | /** @var int The default entry limit choosen for clients */ |
98 | public $mDefaultLimit = 50; |
99 | /** @var mixed The starting point to enumerate entries */ |
100 | public $mOffset; |
101 | /** @var int The maximum number of entries to show */ |
102 | public $mLimit; |
103 | /** @var bool Whether the listing query completed */ |
104 | public $mQueryDone = false; |
105 | /** @var IReadableDatabase */ |
106 | public $mDb; |
107 | /** @var stdClass|bool|null Extra row fetched at the end to see if the end was reached */ |
108 | public $mPastTheEndRow; |
109 | |
110 | /** |
111 | * The index to actually be used for ordering. This can be a single column, |
112 | * an array of single columns, or an array of arrays of columns. See getIndexField |
113 | * for more details. |
114 | * @var string|string[] |
115 | */ |
116 | protected $mIndexField; |
117 | /** |
118 | * An array of secondary columns to order by. These fields are not part of the offset. |
119 | * This is a column list for one ordering, even if multiple orderings are supported. |
120 | * @var string[] |
121 | */ |
122 | protected $mExtraSortFields; |
123 | /** For pages that support multiple types of ordering, which one to use. |
124 | * @var string|null |
125 | */ |
126 | protected $mOrderType; |
127 | /** |
128 | * $mDefaultDirection gives the direction to use when sorting results: |
129 | * DIR_ASCENDING or DIR_DESCENDING. If $mIsBackwards is set, we start from |
130 | * the opposite end, but we still sort the page itself according to |
131 | * $mDefaultDirection. For example, if $mDefaultDirection is DIR_ASCENDING |
132 | * but we're going backwards, we'll display the last page of results, but |
133 | * the last result will be at the bottom, not the top. |
134 | * |
135 | * Like $mIndexField, $mDefaultDirection will be a single value even if the |
136 | * class supports multiple default directions for different order types. |
137 | * @var bool |
138 | */ |
139 | public $mDefaultDirection; |
140 | /** @var bool */ |
141 | public $mIsBackwards; |
142 | |
143 | /** @var bool True if the current result set is the first one */ |
144 | public $mIsFirst; |
145 | /** @var bool */ |
146 | public $mIsLast; |
147 | |
148 | /** @var array */ |
149 | protected $mLastShown; |
150 | /** @var array */ |
151 | protected $mFirstShown; |
152 | /** @var array */ |
153 | protected $mPastTheEndIndex; |
154 | /** @var array */ |
155 | protected $mDefaultQuery; |
156 | /** @var string */ |
157 | protected $mNavigationBar; |
158 | |
159 | /** |
160 | * Whether to include the offset in the query |
161 | * @var bool |
162 | */ |
163 | protected $mIncludeOffset = false; |
164 | |
165 | /** |
166 | * Result object for the query. Warning: seek before use. |
167 | * |
168 | * @var IResultWrapper |
169 | */ |
170 | public $mResult; |
171 | |
172 | /** @var LinkRenderer */ |
173 | private $linkRenderer; |
174 | |
175 | /** |
176 | * @stable to call |
177 | * |
178 | * @param IContextSource|null $context |
179 | * @param LinkRenderer|null $linkRenderer |
180 | */ |
181 | public function __construct( IContextSource $context = null, LinkRenderer $linkRenderer = null ) { |
182 | if ( $context ) { |
183 | $this->setContext( $context ); |
184 | } |
185 | |
186 | $this->mRequest = $this->getRequest(); |
187 | |
188 | # NB: the offset is quoted, not validated. It is treated as an |
189 | # arbitrary string to support the widest variety of index types. Be |
190 | # careful outputting it into HTML! |
191 | $this->mOffset = $this->mRequest->getText( 'offset' ); |
192 | |
193 | # Use consistent behavior for the limit options |
194 | $this->mDefaultLimit = MediaWikiServices::getInstance() |
195 | ->getUserOptionsLookup() |
196 | ->getIntOption( $this->getUser(), 'rclimit' ); |
197 | if ( !$this->mLimit ) { |
198 | // Don't override if a subclass calls $this->setLimit() in its constructor. |
199 | [ $this->mLimit, /* $offset */ ] = $this->mRequest |
200 | ->getLimitOffsetForUser( $this->getUser() ); |
201 | } |
202 | |
203 | $this->mIsBackwards = ( $this->mRequest->getVal( 'dir' ) == 'prev' ); |
204 | // Let the subclass set the DB here; otherwise use a replica DB for the current wiki |
205 | if ( !$this->mDb ) { |
206 | $this->mDb = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase(); |
207 | } |
208 | |
209 | $index = $this->getIndexField(); // column to sort on |
210 | $extraSort = $this->getExtraSortFields(); // extra columns to sort on for query planning |
211 | $order = $this->mRequest->getVal( 'order' ); |
212 | |
213 | if ( is_array( $index ) && isset( $index[$order] ) ) { |
214 | $this->mOrderType = $order; |
215 | $this->mIndexField = $index[$order]; |
216 | $this->mExtraSortFields = isset( $extraSort[$order] ) |
217 | ? (array)$extraSort[$order] |
218 | : []; |
219 | } elseif ( is_array( $index ) ) { |
220 | # First element is the default |
221 | $this->mIndexField = reset( $index ); |
222 | $this->mOrderType = key( $index ); |
223 | $this->mExtraSortFields = isset( $extraSort[$this->mOrderType] ) |
224 | ? (array)$extraSort[$this->mOrderType] |
225 | : []; |
226 | } else { |
227 | # $index is not an array |
228 | $this->mOrderType = null; |
229 | $this->mIndexField = $index; |
230 | $isSortAssociative = array_values( $extraSort ) !== $extraSort; |
231 | if ( $isSortAssociative ) { |
232 | $this->mExtraSortFields = isset( $extraSort[$index] ) |
233 | ? (array)$extraSort[$index] |
234 | : []; |
235 | } else { |
236 | $this->mExtraSortFields = (array)$extraSort; |
237 | } |
238 | } |
239 | |
240 | if ( !isset( $this->mDefaultDirection ) ) { |
241 | $dir = $this->getDefaultDirections(); |
242 | $this->mDefaultDirection = is_array( $dir ) |
243 | ? $dir[$this->mOrderType] |
244 | : $dir; |
245 | } |
246 | $this->linkRenderer = $linkRenderer; |
247 | } |
248 | |
249 | /** |
250 | * Get the Database object in use |
251 | * |
252 | * @since 1.20 |
253 | * |
254 | * @return IReadableDatabase |
255 | */ |
256 | public function getDatabase() { |
257 | return $this->mDb; |
258 | } |
259 | |
260 | /** |
261 | * Do the query, using information from the object context. This function |
262 | * has been kept minimal to make it overridable if necessary, to allow for |
263 | * result sets formed from multiple DB queries. |
264 | * |
265 | * @stable to override |
266 | */ |
267 | public function doQuery() { |
268 | $defaultOrder = ( $this->mDefaultDirection === self::DIR_ASCENDING ) |
269 | ? self::QUERY_ASCENDING |
270 | : self::QUERY_DESCENDING; |
271 | $order = $this->mIsBackwards ? self::oppositeOrder( $defaultOrder ) : $defaultOrder; |
272 | |
273 | # Plus an extra row so that we can tell the "next" link should be shown |
274 | $queryLimit = $this->mLimit + 1; |
275 | if ( $this->mOffset == '' ) { |
276 | $isFirst = true; |
277 | } else { |
278 | // If there's an offset, we may or may not be at the first entry. |
279 | // The only way to tell is to run the query in the opposite |
280 | // direction see if we get a row. |
281 | $oldIncludeOffset = $this->mIncludeOffset; |
282 | $this->mIncludeOffset = !$this->mIncludeOffset; |
283 | $oppositeOrder = self::oppositeOrder( $order ); |
284 | $isFirst = !$this->reallyDoQuery( $this->mOffset, 1, $oppositeOrder )->numRows(); |
285 | $this->mIncludeOffset = $oldIncludeOffset; |
286 | } |
287 | |
288 | $this->mResult = $this->reallyDoQuery( |
289 | $this->mOffset, |
290 | $queryLimit, |
291 | $order |
292 | ); |
293 | |
294 | $this->extractResultInfo( $isFirst, $queryLimit, $this->mResult ); |
295 | $this->mQueryDone = true; |
296 | |
297 | $this->preprocessResults( $this->mResult ); |
298 | $this->mResult->rewind(); // Paranoia |
299 | } |
300 | |
301 | /** |
302 | * @param bool $order One of the IndexPager::QUERY_* class constants |
303 | * @return bool The opposite query order as an IndexPager::QUERY_ constant |
304 | */ |
305 | final protected static function oppositeOrder( $order ) { |
306 | return ( $order === self::QUERY_ASCENDING ) |
307 | ? self::QUERY_DESCENDING |
308 | : self::QUERY_ASCENDING; |
309 | } |
310 | |
311 | /** |
312 | * @return IResultWrapper The result wrapper. |
313 | */ |
314 | public function getResult() { |
315 | return $this->mResult; |
316 | } |
317 | |
318 | /** |
319 | * @return int The current offset into the result. Valid during formatRow(). |
320 | */ |
321 | public function getResultOffset() { |
322 | return $this->mResult->key(); |
323 | } |
324 | |
325 | /** |
326 | * Set the offset from an other source than the request |
327 | * |
328 | * @param int|string $offset |
329 | */ |
330 | public function setOffset( $offset ) { |
331 | $this->mOffset = $offset; |
332 | } |
333 | |
334 | /** |
335 | * Set the limit from an other source than the request |
336 | * |
337 | * Verifies limit is between 1 and 5000 |
338 | * |
339 | * @stable to override |
340 | * |
341 | * @param int|string $limit |
342 | */ |
343 | public function setLimit( $limit ) { |
344 | $limit = (int)$limit; |
345 | // WebRequest::getLimitOffsetForUser() puts a cap of 5000, so do same here. |
346 | if ( $limit > 5000 ) { |
347 | $limit = 5000; |
348 | } |
349 | if ( $limit > 0 ) { |
350 | $this->mLimit = $limit; |
351 | } |
352 | } |
353 | |
354 | /** |
355 | * Get the current limit |
356 | * |
357 | * @return int |
358 | */ |
359 | public function getLimit() { |
360 | return $this->mLimit; |
361 | } |
362 | |
363 | /** |
364 | * Set whether a row matching exactly the offset should be also included |
365 | * in the result or not. By default this is not the case, but when the |
366 | * offset is user-supplied this might be wanted. |
367 | * |
368 | * @param bool $include |
369 | */ |
370 | public function setIncludeOffset( $include ) { |
371 | $this->mIncludeOffset = $include; |
372 | } |
373 | |
374 | /** |
375 | * Extract some useful data from the result object for use by |
376 | * the navigation bar, put it into $this |
377 | * |
378 | * @stable to override |
379 | * |
380 | * @param bool $isFirst False if there are rows before those fetched (i.e. |
381 | * if a "previous" link would make sense) |
382 | * @param int $limit Exact query limit |
383 | * @param IResultWrapper $res |
384 | */ |
385 | protected function extractResultInfo( $isFirst, $limit, IResultWrapper $res ) { |
386 | $numRows = $res->numRows(); |
387 | |
388 | $firstIndex = []; |
389 | $lastIndex = []; |
390 | $this->mPastTheEndIndex = []; |
391 | $this->mPastTheEndRow = null; |
392 | |
393 | if ( $numRows ) { |
394 | $indexColumns = array_map( static function ( $v ) { |
395 | // Remove any table prefix from index field |
396 | $parts = explode( '.', $v ); |
397 | return end( $parts ); |
398 | }, (array)$this->mIndexField ); |
399 | |
400 | $row = $res->fetchRow(); |
401 | foreach ( $indexColumns as $indexColumn ) { |
402 | $firstIndex[] = $row[$indexColumn]; |
403 | } |
404 | |
405 | # Discard the extra result row if there is one |
406 | if ( $numRows > $this->mLimit && $numRows > 1 ) { |
407 | $res->seek( $numRows - 1 ); |
408 | $this->mPastTheEndRow = $res->fetchObject(); |
409 | foreach ( $indexColumns as $indexColumn ) { |
410 | $this->mPastTheEndIndex[] = $this->mPastTheEndRow->$indexColumn; |
411 | } |
412 | $res->seek( $numRows - 2 ); |
413 | $row = $res->fetchRow(); |
414 | foreach ( $indexColumns as $indexColumn ) { |
415 | $lastIndex[] = $row[$indexColumn]; |
416 | } |
417 | } else { |
418 | $this->mPastTheEndRow = null; |
419 | $res->seek( $numRows - 1 ); |
420 | $row = $res->fetchRow(); |
421 | foreach ( $indexColumns as $indexColumn ) { |
422 | $lastIndex[] = $row[$indexColumn]; |
423 | } |
424 | } |
425 | } |
426 | |
427 | if ( $this->mIsBackwards ) { |
428 | $this->mIsFirst = ( $numRows < $limit ); |
429 | $this->mIsLast = $isFirst; |
430 | $this->mLastShown = $firstIndex; |
431 | $this->mFirstShown = $lastIndex; |
432 | } else { |
433 | $this->mIsFirst = $isFirst; |
434 | $this->mIsLast = ( $numRows < $limit ); |
435 | $this->mLastShown = $lastIndex; |
436 | $this->mFirstShown = $firstIndex; |
437 | } |
438 | } |
439 | |
440 | /** |
441 | * Get some text to go in brackets in the "function name" part of the SQL comment |
442 | * |
443 | * @stable to override |
444 | * |
445 | * @return string |
446 | */ |
447 | protected function getSqlComment() { |
448 | return static::class; |
449 | } |
450 | |
451 | /** |
452 | * Do a query with specified parameters, rather than using the object context |
453 | * |
454 | * @note For b/c, query direction is true for ascending and false for descending |
455 | * |
456 | * @stable to override |
457 | * |
458 | * @param string $offset Index offset, inclusive |
459 | * @param int $limit Exact query limit |
460 | * @param bool $order IndexPager::QUERY_ASCENDING or IndexPager::QUERY_DESCENDING |
461 | * @return IResultWrapper |
462 | */ |
463 | public function reallyDoQuery( $offset, $limit, $order ) { |
464 | [ $tables, $fields, $conds, $fname, $options, $join_conds ] = |
465 | $this->buildQueryInfo( $offset, $limit, $order ); |
466 | |
467 | return $this->mDb->select( $tables, $fields, $conds, $fname, $options, $join_conds ); |
468 | } |
469 | |
470 | /** |
471 | * Build variables to use by the database wrapper. |
472 | * |
473 | * @note For b/c, query direction is true for ascending and false for descending |
474 | * |
475 | * @stable to override |
476 | * |
477 | * @param int|string|null $offset Index offset, inclusive |
478 | * @param int $limit Exact query limit |
479 | * @param bool $order IndexPager::QUERY_ASCENDING or IndexPager::QUERY_DESCENDING |
480 | * @return array |
481 | */ |
482 | protected function buildQueryInfo( $offset, $limit, $order ) { |
483 | $fname = __METHOD__ . ' (' . $this->getSqlComment() . ')'; |
484 | $info = $this->getQueryInfo(); |
485 | $tables = $info['tables']; |
486 | $fields = $info['fields']; |
487 | $conds = $info['conds'] ?? []; |
488 | $options = $info['options'] ?? []; |
489 | $join_conds = $info['join_conds'] ?? []; |
490 | $indexColumns = (array)$this->mIndexField; |
491 | $sortColumns = array_merge( $indexColumns, $this->mExtraSortFields ); |
492 | |
493 | if ( $order === self::QUERY_ASCENDING ) { |
494 | $options['ORDER BY'] = $sortColumns; |
495 | $operator = $this->mIncludeOffset ? '>=' : '>'; |
496 | } else { |
497 | $orderBy = []; |
498 | foreach ( $sortColumns as $col ) { |
499 | $orderBy[] = $col . ' DESC'; |
500 | } |
501 | $options['ORDER BY'] = $orderBy; |
502 | $operator = $this->mIncludeOffset ? '<=' : '<'; |
503 | } |
504 | if ( $offset ) { |
505 | $offsets = explode( '|', $offset, /* Limit to max of indices */ count( $indexColumns ) ); |
506 | |
507 | $conds[] = $this->buildOffsetConds( |
508 | $offsets, |
509 | $indexColumns, |
510 | $operator |
511 | ); |
512 | } |
513 | $options['LIMIT'] = intval( $limit ); |
514 | return [ $tables, $fields, $conds, $fname, $options, $join_conds ]; |
515 | } |
516 | |
517 | /** |
518 | * Build the conditions for the offset, given that we may be paginating on a |
519 | * single column or multiple columns. Where we paginate on multiple columns, |
520 | * the sort order is defined by the order of the columns in $mIndexField. |
521 | * |
522 | * @param string[] $offsets The offset for each index field |
523 | * @param string[] $columns The name of each index field |
524 | * @param string $operator Operator for the final part of each inner |
525 | * condition. This will be '>' if the query order is ascending, or '<' if |
526 | * the query order is descending. If the offset should be included, it will |
527 | * also have '=' appended. |
528 | * @return string The conditions for getting results from the offset |
529 | */ |
530 | private function buildOffsetConds( $offsets, $columns, $operator ) { |
531 | // $offsets may be shorter than $columns, in which case the remaining columns should be ignored |
532 | // (T318080) |
533 | $columns = array_slice( $columns, 0, count( $offsets ) ); |
534 | $conds = array_combine( $columns, $offsets ); |
535 | return $this->mDb->buildComparison( $operator, $conds ); |
536 | } |
537 | |
538 | /** |
539 | * Pre-process results; useful for performing batch existence checks, etc. |
540 | * |
541 | * @stable to override |
542 | * |
543 | * @param IResultWrapper $result |
544 | */ |
545 | protected function preprocessResults( $result ) { |
546 | } |
547 | |
548 | /** |
549 | * Get the HTML of a pager row. |
550 | * |
551 | * @stable to override |
552 | * @since 1.38 |
553 | * @param stdClass $row |
554 | * @return string |
555 | */ |
556 | protected function getRow( $row ): string { |
557 | return $this->formatRow( $row ); |
558 | } |
559 | |
560 | /** |
561 | * Get the formatted result list. Calls getStartBody(), formatRow() and |
562 | * getEndBody(), concatenates the results and returns them. |
563 | * |
564 | * @stable to override |
565 | * |
566 | * @return string |
567 | */ |
568 | public function getBody() { |
569 | $this->getOutput()->addModuleStyles( $this->getModuleStyles() ); |
570 | if ( !$this->mQueryDone ) { |
571 | $this->doQuery(); |
572 | } |
573 | |
574 | if ( $this->mResult->numRows() ) { |
575 | # Do any special query batches before display |
576 | $this->doBatchLookups(); |
577 | } |
578 | |
579 | # Don't use any extra rows returned by the query |
580 | $numRows = min( $this->mResult->numRows(), $this->mLimit ); |
581 | |
582 | $s = $this->getStartBody(); |
583 | if ( $numRows ) { |
584 | if ( $this->mIsBackwards ) { |
585 | for ( $i = $numRows - 1; $i >= 0; $i-- ) { |
586 | $this->mResult->seek( $i ); |
587 | $row = $this->mResult->fetchObject(); |
588 | $s .= $this->getRow( $row ); |
589 | } |
590 | } else { |
591 | $this->mResult->seek( 0 ); |
592 | for ( $i = 0; $i < $numRows; $i++ ) { |
593 | $row = $this->mResult->fetchObject(); |
594 | $s .= $this->getRow( $row ); |
595 | } |
596 | } |
597 | $s .= $this->getFooter(); |
598 | } else { |
599 | $s .= $this->getEmptyBody(); |
600 | } |
601 | $s .= $this->getEndBody(); |
602 | return $s; |
603 | } |
604 | |
605 | /** |
606 | * ResourceLoader modules that must be loaded to provide correct styling for this pager |
607 | * |
608 | * @stable to override |
609 | * @since 1.38 |
610 | * @return string[] |
611 | */ |
612 | public function getModuleStyles() { |
613 | return [ 'mediawiki.pager.styles' ]; |
614 | } |
615 | |
616 | /** |
617 | * Classes can extend to output a footer at the bottom of the pager list. |
618 | * |
619 | * @since 1.38 |
620 | * @return string |
621 | */ |
622 | protected function getFooter(): string { |
623 | return ''; |
624 | } |
625 | |
626 | /** |
627 | * Make a self-link |
628 | * |
629 | * @stable to call (since 1.39) |
630 | * |
631 | * @param string $text Text displayed on the link |
632 | * @param array|null $query Associative array of parameter to be in the query string. |
633 | * If null, no link is generated. |
634 | * @param string|null $type Link type used to create additional attributes, like "rel", "class" or |
635 | * "title". Valid values (non-exhaustive list): 'first', 'last', 'prev', 'next', 'asc', 'desc'. |
636 | * @return string HTML fragment |
637 | */ |
638 | protected function makeLink( $text, array $query = null, $type = null ) { |
639 | $attrs = []; |
640 | if ( $query !== null && in_array( $type, [ 'prev', 'next' ] ) ) { |
641 | $attrs['rel'] = $type; |
642 | } |
643 | |
644 | if ( in_array( $type, [ 'asc', 'desc' ] ) ) { |
645 | $attrs['title'] = $this->msg( $type == 'asc' ? 'sort-ascending' : 'sort-descending' )->text(); |
646 | } |
647 | |
648 | if ( $type ) { |
649 | $attrs['class'] = "mw-{$type}link"; |
650 | } |
651 | |
652 | if ( $query !== null ) { |
653 | return $this->getLinkRenderer()->makeKnownLink( |
654 | $this->getTitle(), |
655 | new HtmlArmor( $text ), |
656 | $attrs, |
657 | $query + $this->getDefaultQuery() |
658 | ); |
659 | } else { |
660 | return Html::rawElement( 'span', $attrs, $text ); |
661 | } |
662 | } |
663 | |
664 | /** |
665 | * Called from getBody(), before getStartBody() is called and |
666 | * after doQuery() was called. This will be called only if there |
667 | * are rows in the result set. |
668 | * |
669 | * @stable to override |
670 | * |
671 | * @return void |
672 | */ |
673 | protected function doBatchLookups() { |
674 | } |
675 | |
676 | /** |
677 | * Hook into getBody(), allows text to be inserted at the start. This |
678 | * will be called even if there are no rows in the result set. |
679 | * |
680 | * @return string |
681 | */ |
682 | protected function getStartBody() { |
683 | return ''; |
684 | } |
685 | |
686 | /** |
687 | * Hook into getBody() for the end of the list |
688 | * |
689 | * @stable to override |
690 | * |
691 | * @return string |
692 | */ |
693 | protected function getEndBody() { |
694 | return ''; |
695 | } |
696 | |
697 | /** |
698 | * Hook into getBody(), for the bit between the start and the |
699 | * end when there are no rows |
700 | * |
701 | * @stable to override |
702 | * |
703 | * @return string |
704 | */ |
705 | protected function getEmptyBody() { |
706 | return ''; |
707 | } |
708 | |
709 | /** |
710 | * Get an array of query parameters that should be put into self-links. |
711 | * By default, all parameters passed in the URL are used, apart from a |
712 | * few exceptions. |
713 | * |
714 | * @stable to override |
715 | * |
716 | * @return array Associative array |
717 | */ |
718 | public function getDefaultQuery() { |
719 | if ( !isset( $this->mDefaultQuery ) ) { |
720 | $this->mDefaultQuery = $this->getRequest()->getQueryValues(); |
721 | unset( $this->mDefaultQuery['title'] ); |
722 | unset( $this->mDefaultQuery['dir'] ); |
723 | unset( $this->mDefaultQuery['offset'] ); |
724 | unset( $this->mDefaultQuery['limit'] ); |
725 | unset( $this->mDefaultQuery['order'] ); |
726 | unset( $this->mDefaultQuery['month'] ); |
727 | unset( $this->mDefaultQuery['year'] ); |
728 | } |
729 | return $this->mDefaultQuery; |
730 | } |
731 | |
732 | /** |
733 | * Get the number of rows in the result set |
734 | * |
735 | * @return int |
736 | */ |
737 | public function getNumRows() { |
738 | if ( !$this->mQueryDone ) { |
739 | $this->doQuery(); |
740 | } |
741 | return $this->mResult->numRows(); |
742 | } |
743 | |
744 | /** |
745 | * Get a URL query array for the prev, next, first and last links. |
746 | * |
747 | * @stable to override |
748 | * |
749 | * @return array |
750 | */ |
751 | public function getPagingQueries() { |
752 | if ( !$this->mQueryDone ) { |
753 | $this->doQuery(); |
754 | } |
755 | |
756 | # Don't announce the limit everywhere if it's the default |
757 | $urlLimit = $this->mLimit == $this->mDefaultLimit ? null : $this->mLimit; |
758 | |
759 | if ( $this->mIsFirst ) { |
760 | $prev = false; |
761 | $first = false; |
762 | } else { |
763 | $prev = [ |
764 | 'dir' => 'prev', |
765 | 'offset' => implode( '|', (array)$this->mFirstShown ), |
766 | 'limit' => $urlLimit |
767 | ]; |
768 | $first = [ 'offset' => null, 'limit' => $urlLimit ]; |
769 | } |
770 | if ( $this->mIsLast ) { |
771 | $next = false; |
772 | $last = false; |
773 | } else { |
774 | $next = [ 'offset' => implode( '|', (array)$this->mLastShown ), 'limit' => $urlLimit ]; |
775 | $last = [ 'dir' => 'prev', 'offset' => null, 'limit' => $urlLimit ]; |
776 | } |
777 | |
778 | return [ |
779 | 'prev' => $prev, |
780 | 'next' => $next, |
781 | 'first' => $first, |
782 | 'last' => $last |
783 | ]; |
784 | } |
785 | |
786 | /** |
787 | * Get the current offset for the URL query parameter. |
788 | * |
789 | * @stable to override |
790 | * @since 1.39 |
791 | * @return string |
792 | */ |
793 | public function getOffsetQuery() { |
794 | if ( $this->mIsBackwards ) { |
795 | return implode( '|', (array)$this->mPastTheEndIndex ); |
796 | } else { |
797 | return $this->mOffset; |
798 | } |
799 | } |
800 | |
801 | /** |
802 | * @stable to override |
803 | * @since 1.39 |
804 | * @return PagerNavigationBuilder |
805 | */ |
806 | public function getNavigationBuilder(): PagerNavigationBuilder { |
807 | $pagingQueries = $this->getPagingQueries(); |
808 | $baseQuery = array_merge( $this->getDefaultQuery(), [ |
809 | // These query parameters are all defined here, even though some are null, |
810 | // to ensure consistent order of parameters when they're used. |
811 | 'dir' => null, |
812 | 'offset' => $this->getOffsetQuery(), |
813 | 'limit' => null, |
814 | ] ); |
815 | |
816 | $navBuilder = new PagerNavigationBuilder( $this->getContext() ); |
817 | $navBuilder |
818 | ->setPage( $this->getTitle() ) |
819 | ->setLinkQuery( $baseQuery ) |
820 | ->setLimits( $this->mLimitsShown ) |
821 | ->setLimitLinkQueryParam( 'limit' ) |
822 | ->setCurrentLimit( $this->mLimit ) |
823 | ->setPrevLinkQuery( $pagingQueries['prev'] ?: null ) |
824 | ->setNextLinkQuery( $pagingQueries['next'] ?: null ) |
825 | ->setFirstLinkQuery( $pagingQueries['first'] ?: null ) |
826 | ->setLastLinkQuery( $pagingQueries['last'] ?: null ); |
827 | |
828 | return $navBuilder; |
829 | } |
830 | |
831 | /** |
832 | * Returns whether to show the "navigation bar" |
833 | * @stable to override |
834 | * |
835 | * @return bool |
836 | */ |
837 | protected function isNavigationBarShown() { |
838 | if ( !$this->mQueryDone ) { |
839 | $this->doQuery(); |
840 | } |
841 | // Hide navigation by default if there is nothing to page |
842 | return !( $this->mIsFirst && $this->mIsLast ); |
843 | } |
844 | |
845 | /** |
846 | * Returns an HTML string representing the result row $row. |
847 | * Rows will be concatenated and returned by getBody() |
848 | * |
849 | * @param array|stdClass $row Database row |
850 | * @return string |
851 | */ |
852 | abstract public function formatRow( $row ); |
853 | |
854 | /** |
855 | * Provides all parameters needed for the main paged query. It returns |
856 | * an associative array with the following elements: |
857 | * tables => Table(s) for passing to Database::select() |
858 | * fields => Field(s) for passing to Database::select(), may be * |
859 | * conds => WHERE conditions |
860 | * options => option array |
861 | * join_conds => JOIN conditions |
862 | * |
863 | * @return array |
864 | */ |
865 | abstract public function getQueryInfo(); |
866 | |
867 | /** |
868 | * Returns the name of the index field. If the pager supports multiple |
869 | * orders, it may return an array of 'querykey' => 'indexfield' pairs, |
870 | * so that a request with &order=querykey will use indexfield to sort. |
871 | * In this case, the first returned key is the default. |
872 | * |
873 | * Needless to say, it's really not a good idea to use a non-unique index |
874 | * for this! That won't page right. |
875 | * |
876 | * The pager may paginate on multiple fields in combination. If paginating |
877 | * on multiple fields, they should be unique in combination (e.g. when |
878 | * paginating on user and timestamp, rows may have the same user, rows may |
879 | * have the same timestamp, but rows should all have a different combination |
880 | * of user and timestamp). |
881 | * |
882 | * Examples: |
883 | * - Always paginate on the user field: |
884 | * 'user' |
885 | * - Paginate on either the user or the timestamp field (default to user): |
886 | * [ |
887 | * 'name' => 'user', |
888 | * 'time' => 'timestamp', |
889 | * ] |
890 | * - Always paginate on the combination of user and timestamp: |
891 | * [ |
892 | * [ 'user', 'timestamp' ] |
893 | * ] |
894 | * - Paginate on the user then timestamp, or the timestamp then user: |
895 | * [ |
896 | * 'nametime' => [ 'user', 'timestamp' ], |
897 | * 'timename' => [ 'timestamp', 'user' ], |
898 | * ] |
899 | * |
900 | * |
901 | * @return string|string[]|array[] |
902 | */ |
903 | abstract public function getIndexField(); |
904 | |
905 | /** |
906 | * Returns the names of secondary columns to order by in addition to the |
907 | * column in getIndexField(). These fields will not be used in the pager |
908 | * offset or in any links for users. |
909 | * |
910 | * If getIndexField() returns an array of 'querykey' => 'indexfield' pairs then |
911 | * this must return a corresponding array of 'querykey' => [ fields... ] pairs |
912 | * in order for a request with &order=querykey to use [ fields... ] to sort. |
913 | * |
914 | * If getIndexField() returns a string with the field to sort by, this must either: |
915 | * 1 - return an associative array like above, but only the elements for the current |
916 | * field will be used. |
917 | * 2 - return a non-associative array, for secondary keys to use always. |
918 | * |
919 | * This is useful for pagers that GROUP BY a unique column (say page_id) |
920 | * and ORDER BY another (say page_len). Using GROUP BY and ORDER BY both on |
921 | * page_len,page_id avoids temp tables (given a page_len index). This would |
922 | * also work if page_id was non-unique but we had a page_len,page_id index. |
923 | * |
924 | * @stable to override |
925 | * |
926 | * @return string[]|array[] |
927 | */ |
928 | protected function getExtraSortFields() { |
929 | return []; |
930 | } |
931 | |
932 | /** |
933 | * Return the default sorting direction: DIR_ASCENDING or DIR_DESCENDING. |
934 | * You can also have an associative array of ordertype => dir, |
935 | * if multiple order types are supported. In this case getIndexField() |
936 | * must return an array, and the keys of that must exactly match the keys |
937 | * of this. |
938 | * |
939 | * For backward compatibility, this method's return value will be ignored |
940 | * if $this->mDefaultDirection is already set when the constructor is |
941 | * called, for instance if it's statically initialized. In that case the |
942 | * value of that variable (which must be a boolean) will be used. |
943 | * |
944 | * Note that despite its name, this does not return the value of the |
945 | * $this->mDefaultDirection member variable. That's the default for this |
946 | * particular instantiation, which is a single value. This is the set of |
947 | * all defaults for the class. |
948 | * |
949 | * @stable to override |
950 | * |
951 | * @return bool |
952 | */ |
953 | protected function getDefaultDirections() { |
954 | return self::DIR_ASCENDING; |
955 | } |
956 | |
957 | /** |
958 | * @since 1.34 |
959 | * @return LinkRenderer |
960 | */ |
961 | protected function getLinkRenderer() { |
962 | if ( $this->linkRenderer === null ) { |
963 | $this->linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); |
964 | } |
965 | return $this->linkRenderer; |
966 | } |
967 | } |
968 | |
969 | /** @deprecated class alias since 1.41 */ |
970 | class_alias( IndexPager::class, 'IndexPager' ); |