Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
6.46% |
17 / 263 |
|
0.00% |
0 / 34 |
CRAP | |
0.00% |
0 / 1 |
IndexPager | |
6.49% |
17 / 262 |
|
0.00% |
0 / 34 |
6862.38 | |
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 / 10 |
|
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 MediaWiki\Context\ContextSource; |
24 | use MediaWiki\Context\IContextSource; |
25 | use MediaWiki\Html\Html; |
26 | use MediaWiki\Linker\LinkRenderer; |
27 | use MediaWiki\MediaWikiServices; |
28 | use MediaWiki\Navigation\PagerNavigationBuilder; |
29 | use MediaWiki\Request\WebRequest; |
30 | use stdClass; |
31 | use Wikimedia\HtmlArmor\HtmlArmor; |
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|null |
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|null */ |
155 | protected $mDefaultQuery; |
156 | /** @var string|null */ |
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->getRawVal( '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 ( $this->mDefaultDirection === null ) { |
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->newSelectQueryBuilder() |
468 | ->rawTables( $tables ) |
469 | ->fields( $fields ) |
470 | ->conds( $conds ) |
471 | ->caller( $fname ) |
472 | ->options( $options ) |
473 | ->joinConds( $join_conds ) |
474 | ->fetchResultSet(); |
475 | } |
476 | |
477 | /** |
478 | * Build variables to use by the database wrapper. |
479 | * |
480 | * @note For b/c, query direction is true for ascending and false for descending |
481 | * |
482 | * @stable to override |
483 | * |
484 | * @param int|string|null $offset Index offset, inclusive |
485 | * @param int $limit Exact query limit |
486 | * @param bool $order IndexPager::QUERY_ASCENDING or IndexPager::QUERY_DESCENDING |
487 | * @return array |
488 | */ |
489 | protected function buildQueryInfo( $offset, $limit, $order ) { |
490 | $fname = __METHOD__ . ' (' . $this->getSqlComment() . ')'; |
491 | $info = $this->getQueryInfo(); |
492 | $tables = $info['tables']; |
493 | $fields = $info['fields']; |
494 | $conds = $info['conds'] ?? []; |
495 | $options = $info['options'] ?? []; |
496 | $join_conds = $info['join_conds'] ?? []; |
497 | $indexColumns = (array)$this->mIndexField; |
498 | $sortColumns = array_merge( $indexColumns, $this->mExtraSortFields ); |
499 | |
500 | if ( $order === self::QUERY_ASCENDING ) { |
501 | $options['ORDER BY'] = $sortColumns; |
502 | $operator = $this->mIncludeOffset ? '>=' : '>'; |
503 | } else { |
504 | $orderBy = []; |
505 | foreach ( $sortColumns as $col ) { |
506 | $orderBy[] = $col . ' DESC'; |
507 | } |
508 | $options['ORDER BY'] = $orderBy; |
509 | $operator = $this->mIncludeOffset ? '<=' : '<'; |
510 | } |
511 | if ( $offset ) { |
512 | $offsets = explode( '|', $offset, /* Limit to max of indices */ count( $indexColumns ) ); |
513 | |
514 | $conds[] = $this->buildOffsetConds( |
515 | $offsets, |
516 | $indexColumns, |
517 | $operator |
518 | ); |
519 | } |
520 | $options['LIMIT'] = intval( $limit ); |
521 | return [ $tables, $fields, $conds, $fname, $options, $join_conds ]; |
522 | } |
523 | |
524 | /** |
525 | * Build the conditions for the offset, given that we may be paginating on a |
526 | * single column or multiple columns. Where we paginate on multiple columns, |
527 | * the sort order is defined by the order of the columns in $mIndexField. |
528 | * |
529 | * @param string[] $offsets The offset for each index field |
530 | * @param string[] $columns The name of each index field |
531 | * @param string $operator Operator for the final part of each inner |
532 | * condition. This will be '>' if the query order is ascending, or '<' if |
533 | * the query order is descending. If the offset should be included, it will |
534 | * also have '=' appended. |
535 | * @return string The conditions for getting results from the offset |
536 | */ |
537 | private function buildOffsetConds( $offsets, $columns, $operator ) { |
538 | // $offsets may be shorter than $columns, in which case the remaining columns should be ignored |
539 | // (T318080) |
540 | $columns = array_slice( $columns, 0, count( $offsets ) ); |
541 | $conds = array_combine( $columns, $offsets ); |
542 | return $this->mDb->buildComparison( $operator, $conds ); |
543 | } |
544 | |
545 | /** |
546 | * Pre-process results; useful for performing batch existence checks, etc. |
547 | * |
548 | * @stable to override |
549 | * |
550 | * @param IResultWrapper $result |
551 | */ |
552 | protected function preprocessResults( $result ) { |
553 | } |
554 | |
555 | /** |
556 | * Get the HTML of a pager row. |
557 | * |
558 | * @stable to override |
559 | * @since 1.38 |
560 | * @param stdClass $row |
561 | * @return string |
562 | */ |
563 | protected function getRow( $row ): string { |
564 | return $this->formatRow( $row ); |
565 | } |
566 | |
567 | /** |
568 | * Get the formatted result list. Calls getStartBody(), formatRow() and |
569 | * getEndBody(), concatenates the results and returns them. |
570 | * |
571 | * @stable to override |
572 | * |
573 | * @return string |
574 | */ |
575 | public function getBody() { |
576 | $this->getOutput()->addModuleStyles( $this->getModuleStyles() ); |
577 | if ( !$this->mQueryDone ) { |
578 | $this->doQuery(); |
579 | } |
580 | |
581 | if ( $this->mResult->numRows() ) { |
582 | # Do any special query batches before display |
583 | $this->doBatchLookups(); |
584 | } |
585 | |
586 | # Don't use any extra rows returned by the query |
587 | $numRows = min( $this->mResult->numRows(), $this->mLimit ); |
588 | |
589 | $s = $this->getStartBody(); |
590 | if ( $numRows ) { |
591 | if ( $this->mIsBackwards ) { |
592 | for ( $i = $numRows - 1; $i >= 0; $i-- ) { |
593 | $this->mResult->seek( $i ); |
594 | $row = $this->mResult->fetchObject(); |
595 | $s .= $this->getRow( $row ); |
596 | } |
597 | } else { |
598 | $this->mResult->seek( 0 ); |
599 | for ( $i = 0; $i < $numRows; $i++ ) { |
600 | $row = $this->mResult->fetchObject(); |
601 | $s .= $this->getRow( $row ); |
602 | } |
603 | } |
604 | $s .= $this->getFooter(); |
605 | } else { |
606 | $s .= $this->getEmptyBody(); |
607 | } |
608 | $s .= $this->getEndBody(); |
609 | return $s; |
610 | } |
611 | |
612 | /** |
613 | * ResourceLoader modules that must be loaded to provide correct styling for this pager |
614 | * |
615 | * @stable to override |
616 | * @since 1.38 |
617 | * @return string[] |
618 | */ |
619 | public function getModuleStyles() { |
620 | return [ 'mediawiki.pager.styles' ]; |
621 | } |
622 | |
623 | /** |
624 | * Classes can extend to output a footer at the bottom of the pager list. |
625 | * |
626 | * @since 1.38 |
627 | * @return string |
628 | */ |
629 | protected function getFooter(): string { |
630 | return ''; |
631 | } |
632 | |
633 | /** |
634 | * Make a self-link |
635 | * |
636 | * @stable to call (since 1.39) |
637 | * |
638 | * @param string $text Text displayed on the link |
639 | * @param array|null $query Associative array of parameter to be in the query string. |
640 | * If null, no link is generated. |
641 | * @param string|null $type Link type used to create additional attributes, like "rel", "class" or |
642 | * "title". Valid values (non-exhaustive list): 'first', 'last', 'prev', 'next', 'asc', 'desc'. |
643 | * @return string HTML fragment |
644 | */ |
645 | protected function makeLink( $text, ?array $query = null, $type = null ) { |
646 | $attrs = []; |
647 | if ( $query !== null && in_array( $type, [ 'prev', 'next' ] ) ) { |
648 | $attrs['rel'] = $type; |
649 | } |
650 | |
651 | if ( in_array( $type, [ 'asc', 'desc' ] ) ) { |
652 | $attrs['title'] = $this->msg( $type == 'asc' ? 'sort-ascending' : 'sort-descending' )->text(); |
653 | } |
654 | |
655 | if ( $type ) { |
656 | $attrs['class'] = "mw-{$type}link"; |
657 | } |
658 | |
659 | if ( $query !== null ) { |
660 | return $this->getLinkRenderer()->makeKnownLink( |
661 | $this->getTitle(), |
662 | new HtmlArmor( $text ), |
663 | $attrs, |
664 | $query + $this->getDefaultQuery() |
665 | ); |
666 | } else { |
667 | return Html::rawElement( 'span', $attrs, $text ); |
668 | } |
669 | } |
670 | |
671 | /** |
672 | * Called from getBody(), before getStartBody() is called and |
673 | * after doQuery() was called. This will be called only if there |
674 | * are rows in the result set. |
675 | * |
676 | * @stable to override |
677 | * |
678 | * @return void |
679 | */ |
680 | protected function doBatchLookups() { |
681 | } |
682 | |
683 | /** |
684 | * Hook into getBody(), allows text to be inserted at the start. This |
685 | * will be called even if there are no rows in the result set. |
686 | * |
687 | * @return string |
688 | */ |
689 | protected function getStartBody() { |
690 | return ''; |
691 | } |
692 | |
693 | /** |
694 | * Hook into getBody() for the end of the list |
695 | * |
696 | * @stable to override |
697 | * |
698 | * @return string |
699 | */ |
700 | protected function getEndBody() { |
701 | return ''; |
702 | } |
703 | |
704 | /** |
705 | * Hook into getBody(), for the bit between the start and the |
706 | * end when there are no rows |
707 | * |
708 | * @stable to override |
709 | * |
710 | * @return string |
711 | */ |
712 | protected function getEmptyBody() { |
713 | return ''; |
714 | } |
715 | |
716 | /** |
717 | * Get an array of query parameters that should be put into self-links. |
718 | * By default, all parameters passed in the URL are used, apart from a |
719 | * few exceptions. |
720 | * |
721 | * @stable to override |
722 | * |
723 | * @return array Associative array |
724 | */ |
725 | public function getDefaultQuery() { |
726 | if ( $this->mDefaultQuery === null ) { |
727 | $this->mDefaultQuery = $this->getRequest()->getQueryValues(); |
728 | unset( $this->mDefaultQuery['title'] ); |
729 | unset( $this->mDefaultQuery['dir'] ); |
730 | unset( $this->mDefaultQuery['offset'] ); |
731 | unset( $this->mDefaultQuery['limit'] ); |
732 | unset( $this->mDefaultQuery['order'] ); |
733 | unset( $this->mDefaultQuery['month'] ); |
734 | unset( $this->mDefaultQuery['year'] ); |
735 | } |
736 | return $this->mDefaultQuery; |
737 | } |
738 | |
739 | /** |
740 | * Get the number of rows in the result set |
741 | * |
742 | * @return int |
743 | */ |
744 | public function getNumRows() { |
745 | if ( !$this->mQueryDone ) { |
746 | $this->doQuery(); |
747 | } |
748 | return $this->mResult->numRows(); |
749 | } |
750 | |
751 | /** |
752 | * Get a URL query array for the prev, next, first and last links. |
753 | * |
754 | * @stable to override |
755 | * |
756 | * @return array |
757 | */ |
758 | public function getPagingQueries() { |
759 | if ( !$this->mQueryDone ) { |
760 | $this->doQuery(); |
761 | } |
762 | |
763 | # Don't announce the limit everywhere if it's the default |
764 | $urlLimit = $this->mLimit == $this->mDefaultLimit ? null : $this->mLimit; |
765 | |
766 | if ( $this->mIsFirst ) { |
767 | $prev = false; |
768 | $first = false; |
769 | } else { |
770 | $prev = [ |
771 | 'dir' => 'prev', |
772 | 'offset' => implode( '|', (array)$this->mFirstShown ), |
773 | 'limit' => $urlLimit |
774 | ]; |
775 | $first = [ 'offset' => null, 'limit' => $urlLimit ]; |
776 | } |
777 | if ( $this->mIsLast ) { |
778 | $next = false; |
779 | $last = false; |
780 | } else { |
781 | $next = [ 'offset' => implode( '|', (array)$this->mLastShown ), 'limit' => $urlLimit ]; |
782 | $last = [ 'dir' => 'prev', 'offset' => null, 'limit' => $urlLimit ]; |
783 | } |
784 | |
785 | return [ |
786 | 'prev' => $prev, |
787 | 'next' => $next, |
788 | 'first' => $first, |
789 | 'last' => $last |
790 | ]; |
791 | } |
792 | |
793 | /** |
794 | * Get the current offset for the URL query parameter. |
795 | * |
796 | * @stable to override |
797 | * @since 1.39 |
798 | * @return string |
799 | */ |
800 | public function getOffsetQuery() { |
801 | if ( $this->mIsBackwards ) { |
802 | return implode( '|', (array)$this->mPastTheEndIndex ); |
803 | } else { |
804 | return $this->mOffset; |
805 | } |
806 | } |
807 | |
808 | /** |
809 | * @stable to override |
810 | * @since 1.39 |
811 | * @return PagerNavigationBuilder |
812 | */ |
813 | public function getNavigationBuilder(): PagerNavigationBuilder { |
814 | $pagingQueries = $this->getPagingQueries(); |
815 | $baseQuery = array_merge( $this->getDefaultQuery(), [ |
816 | // These query parameters are all defined here, even though some are null, |
817 | // to ensure consistent order of parameters when they're used. |
818 | 'dir' => null, |
819 | 'offset' => $this->getOffsetQuery(), |
820 | 'limit' => null, |
821 | ] ); |
822 | |
823 | $navBuilder = new PagerNavigationBuilder( $this->getContext() ); |
824 | $navBuilder |
825 | ->setPage( $this->getTitle() ) |
826 | ->setLinkQuery( $baseQuery ) |
827 | ->setLimits( $this->mLimitsShown ) |
828 | ->setLimitLinkQueryParam( 'limit' ) |
829 | ->setCurrentLimit( $this->mLimit ) |
830 | ->setPrevLinkQuery( $pagingQueries['prev'] ?: null ) |
831 | ->setNextLinkQuery( $pagingQueries['next'] ?: null ) |
832 | ->setFirstLinkQuery( $pagingQueries['first'] ?: null ) |
833 | ->setLastLinkQuery( $pagingQueries['last'] ?: null ); |
834 | |
835 | return $navBuilder; |
836 | } |
837 | |
838 | /** |
839 | * Returns whether to show the "navigation bar" |
840 | * @stable to override |
841 | * |
842 | * @return bool |
843 | */ |
844 | protected function isNavigationBarShown() { |
845 | if ( !$this->mQueryDone ) { |
846 | $this->doQuery(); |
847 | } |
848 | // Hide navigation by default if there is nothing to page |
849 | return !( $this->mIsFirst && $this->mIsLast ); |
850 | } |
851 | |
852 | /** |
853 | * Returns an HTML string representing the result row $row. |
854 | * Rows will be concatenated and returned by getBody() |
855 | * |
856 | * @param array|stdClass $row Database row |
857 | * @return string |
858 | */ |
859 | abstract public function formatRow( $row ); |
860 | |
861 | /** |
862 | * Provides all parameters needed for the main paged query. It returns |
863 | * an associative array with the following elements: |
864 | * tables => Table(s) for passing to Database::select() |
865 | * fields => Field(s) for passing to Database::select(), may be * |
866 | * conds => WHERE conditions |
867 | * options => option array |
868 | * join_conds => JOIN conditions |
869 | * |
870 | * @return array |
871 | */ |
872 | abstract public function getQueryInfo(); |
873 | |
874 | /** |
875 | * Returns the name of the index field. If the pager supports multiple |
876 | * orders, it may return an array of 'querykey' => 'indexfield' pairs, |
877 | * so that a request with &order=querykey will use indexfield to sort. |
878 | * In this case, the first returned key is the default. |
879 | * |
880 | * Needless to say, it's really not a good idea to use a non-unique index |
881 | * for this! That won't page right. |
882 | * |
883 | * The pager may paginate on multiple fields in combination. If paginating |
884 | * on multiple fields, they should be unique in combination (e.g. when |
885 | * paginating on user and timestamp, rows may have the same user, rows may |
886 | * have the same timestamp, but rows should all have a different combination |
887 | * of user and timestamp). |
888 | * |
889 | * Examples: |
890 | * - Always paginate on the user field: |
891 | * 'user' |
892 | * - Paginate on either the user or the timestamp field (default to user): |
893 | * [ |
894 | * 'name' => 'user', |
895 | * 'time' => 'timestamp', |
896 | * ] |
897 | * - Always paginate on the combination of user and timestamp: |
898 | * [ |
899 | * [ 'user', 'timestamp' ] |
900 | * ] |
901 | * - Paginate on the user then timestamp, or the timestamp then user: |
902 | * [ |
903 | * 'nametime' => [ 'user', 'timestamp' ], |
904 | * 'timename' => [ 'timestamp', 'user' ], |
905 | * ] |
906 | * |
907 | * |
908 | * @return string|string[]|array[] |
909 | */ |
910 | abstract public function getIndexField(); |
911 | |
912 | /** |
913 | * Returns the names of secondary columns to order by in addition to the |
914 | * column in getIndexField(). These fields will not be used in the pager |
915 | * offset or in any links for users. |
916 | * |
917 | * If getIndexField() returns an array of 'querykey' => 'indexfield' pairs then |
918 | * this must return a corresponding array of 'querykey' => [ fields... ] pairs |
919 | * in order for a request with &order=querykey to use [ fields... ] to sort. |
920 | * |
921 | * If getIndexField() returns a string with the field to sort by, this must either: |
922 | * 1 - return an associative array like above, but only the elements for the current |
923 | * field will be used. |
924 | * 2 - return a non-associative array, for secondary keys to use always. |
925 | * |
926 | * This is useful for pagers that GROUP BY a unique column (say page_id) |
927 | * and ORDER BY another (say page_len). Using GROUP BY and ORDER BY both on |
928 | * page_len,page_id avoids temp tables (given a page_len index). This would |
929 | * also work if page_id was non-unique but we had a page_len,page_id index. |
930 | * |
931 | * @stable to override |
932 | * |
933 | * @return string[]|array[] |
934 | */ |
935 | protected function getExtraSortFields() { |
936 | return []; |
937 | } |
938 | |
939 | /** |
940 | * Return the default sorting direction: DIR_ASCENDING or DIR_DESCENDING. |
941 | * You can also have an associative array of ordertype => dir, |
942 | * if multiple order types are supported. In this case getIndexField() |
943 | * must return an array, and the keys of that must exactly match the keys |
944 | * of this. |
945 | * |
946 | * For backward compatibility, this method's return value will be ignored |
947 | * if $this->mDefaultDirection is already set when the constructor is |
948 | * called, for instance if it's statically initialized. In that case the |
949 | * value of that variable (which must be a boolean) will be used. |
950 | * |
951 | * Note that despite its name, this does not return the value of the |
952 | * $this->mDefaultDirection member variable. That's the default for this |
953 | * particular instantiation, which is a single value. This is the set of |
954 | * all defaults for the class. |
955 | * |
956 | * @stable to override |
957 | * |
958 | * @return bool |
959 | */ |
960 | protected function getDefaultDirections() { |
961 | return self::DIR_ASCENDING; |
962 | } |
963 | |
964 | /** |
965 | * @since 1.34 |
966 | * @return LinkRenderer |
967 | */ |
968 | protected function getLinkRenderer() { |
969 | if ( $this->linkRenderer === null ) { |
970 | $this->linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); |
971 | } |
972 | return $this->linkRenderer; |
973 | } |
974 | } |
975 | |
976 | /** @deprecated class alias since 1.41 */ |
977 | class_alias( IndexPager::class, 'IndexPager' ); |