3 * Base code for "query" special pages.
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
21 * @ingroup SpecialPage
25 * This is a class for doing query pages; since they're almost all the same,
26 * we factor out some of the functionality into a superclass, and let
27 * subclasses derive from it.
28 * @ingroup SpecialPage
30 abstract class QueryPage
extends SpecialPage
{
31 /** @var bool Whether or not we want plain listoutput rather than an ordered list */
32 protected $listoutput = false;
34 /** @var int The offset and limit in use, as passed to the query() function */
35 protected $offset = 0;
41 * The number of rows returned by the query. Reading this variable
42 * only makes sense in functions that are run after the query has been
43 * done, such as preprocessResults() and formatRow().
47 protected $cachedTimestamp = null;
50 * Whether to show prev/next links
52 protected $shownavigation = true;
55 * Get a list of query page classes and their associated special pages,
56 * for periodic updates.
58 * DO NOT CHANGE THIS LIST without testing that
59 * maintenance/updateSpecialPages.php still works.
62 public static function getPages() {
63 global $wgDisableCounters;
67 // QueryPage subclass, Special page name
69 array( 'AncientPagesPage', 'Ancientpages' ),
70 array( 'BrokenRedirectsPage', 'BrokenRedirects' ),
71 array( 'DeadendPagesPage', 'Deadendpages' ),
72 array( 'DoubleRedirectsPage', 'DoubleRedirects' ),
73 array( 'FileDuplicateSearchPage', 'FileDuplicateSearch' ),
74 array( 'ListDuplicatedFilesPage', 'ListDuplicatedFiles'),
75 array( 'LinkSearchPage', 'LinkSearch' ),
76 array( 'ListredirectsPage', 'Listredirects' ),
77 array( 'LonelyPagesPage', 'Lonelypages' ),
78 array( 'LongPagesPage', 'Longpages' ),
79 array( 'MediaStatisticsPage', 'MediaStatistics' ),
80 array( 'MIMEsearchPage', 'MIMEsearch' ),
81 array( 'MostcategoriesPage', 'Mostcategories' ),
82 array( 'MostimagesPage', 'Mostimages' ),
83 array( 'MostinterwikisPage', 'Mostinterwikis' ),
84 array( 'MostlinkedCategoriesPage', 'Mostlinkedcategories' ),
85 array( 'MostlinkedtemplatesPage', 'Mostlinkedtemplates' ),
86 array( 'MostlinkedPage', 'Mostlinked' ),
87 array( 'MostrevisionsPage', 'Mostrevisions' ),
88 array( 'FewestrevisionsPage', 'Fewestrevisions' ),
89 array( 'ShortPagesPage', 'Shortpages' ),
90 array( 'UncategorizedCategoriesPage', 'Uncategorizedcategories' ),
91 array( 'UncategorizedPagesPage', 'Uncategorizedpages' ),
92 array( 'UncategorizedImagesPage', 'Uncategorizedimages' ),
93 array( 'UncategorizedTemplatesPage', 'Uncategorizedtemplates' ),
94 array( 'UnusedCategoriesPage', 'Unusedcategories' ),
95 array( 'UnusedimagesPage', 'Unusedimages' ),
96 array( 'WantedCategoriesPage', 'Wantedcategories' ),
97 array( 'WantedFilesPage', 'Wantedfiles' ),
98 array( 'WantedPagesPage', 'Wantedpages' ),
99 array( 'WantedTemplatesPage', 'Wantedtemplates' ),
100 array( 'UnwatchedPagesPage', 'Unwatchedpages' ),
101 array( 'UnusedtemplatesPage', 'Unusedtemplates' ),
102 array( 'WithoutInterwikiPage', 'Withoutinterwiki' ),
104 wfRunHooks( 'wgQueryPages', array( &$qp ) );
106 if ( !$wgDisableCounters ) {
107 $qp[] = array( 'PopularPagesPage', 'Popularpages' );
115 * A mutator for $this->listoutput;
119 function setListoutput( $bool ) {
120 $this->listoutput
= $bool;
124 * Subclasses return an SQL query here, formatted as an array with the
126 * tables => Table(s) for passing to Database::select()
127 * fields => Field(s) for passing to Database::select(), may be *
128 * conds => WHERE conditions
130 * join_conds => JOIN conditions
132 * Note that the query itself should return the following three columns:
133 * 'namespace', 'title', and 'value'. 'value' is used for sorting.
135 * These may be stored in the querycache table for expensive queries,
136 * and that cached data will be returned sometimes, so the presence of
137 * extra fields can't be relied upon. The cached 'value' column will be
138 * an integer; non-numeric values are useful only for sorting the
139 * initial query (except if they're timestamps, see usesTimestamps()).
141 * Don't include an ORDER or LIMIT clause, they will be added.
143 * If this function is not overridden or returns something other than
144 * an array, getSQL() will be used instead. This is for backwards
145 * compatibility only and is strongly deprecated.
149 function getQueryInfo() {
154 * For back-compat, subclasses may return a raw SQL query here, as a string.
155 * This is strongly deprecated; getQueryInfo() should be overridden instead.
156 * @throws MWException
160 /* Implement getQueryInfo() instead */
161 throw new MWException( "Bug in a QueryPage: doesn't implement getQueryInfo() nor "
162 . "getQuery() properly" );
166 * Subclasses return an array of fields to order by here. Don't append
167 * DESC to the field names, that'll be done automatically if
168 * sortDescending() returns true.
172 function getOrderFields() {
173 return array( 'value' );
177 * Does this query return timestamps rather than integers in its
178 * 'value' field? If true, this class will convert 'value' to a
179 * UNIX timestamp for caching.
180 * NOTE: formatRow() may get timestamps in TS_MW (mysql), TS_DB (pgsql)
181 * or TS_UNIX (querycache) format, so be sure to always run them
182 * through wfTimestamp()
186 function usesTimestamps() {
191 * Override to sort by increasing values
195 function sortDescending() {
200 * Is this query expensive (for some definition of expensive)? Then we
201 * don't let it run in miser mode. $wgDisableQueryPages causes all query
202 * pages to be declared expensive. Some query pages are always expensive.
206 function isExpensive() {
207 return $this->getConfig()->get( 'DisableQueryPages' );
211 * Is the output of this query cacheable? Non-cacheable expensive pages
212 * will be disabled in miser mode and will not have their results written
213 * to the querycache table.
217 public function isCacheable() {
222 * Whether or not the output of the page in question is retrieved from
223 * the database cache.
227 function isCached() {
228 return $this->isExpensive() && $this->getConfig()->get( 'MiserMode' );
232 * Sometime we don't want to build rss / atom feeds.
236 function isSyndicated() {
241 * Formats the results of the query for display. The skin is the current
242 * skin; you can use it for making links. The result is a single row of
243 * result data. You should be able to grab SQL results off of it.
244 * If the function returns false, the line output will be skipped.
246 * @param object $result Result row
247 * @return string|bool String or false to skip
249 abstract function formatResult( $skin, $result );
252 * The content returned by this function will be output before any result
256 function getPageHeader() {
261 * If using extra form wheely-dealies, return a set of parameters here
262 * as an associative array. They will be encoded and added to the paging
263 * links (prev/next/lengths).
267 function linkParameters() {
272 * Some special pages (for example SpecialListusers) might not return the
273 * current object formatted, but return the previous one instead.
274 * Setting this to return true will ensure formatResult() is called
275 * one more time to make sure that the very last result is formatted
279 function tryLastResult() {
284 * Clear the cache and save new results
286 * @param int|bool $limit Limit for SQL statement
287 * @param bool $ignoreErrors Whether to ignore database errors
288 * @throws DBError|Exception
291 function recache( $limit, $ignoreErrors = true ) {
292 if ( !$this->isCacheable() ) {
296 $fname = get_class( $this ) . '::recache';
297 $dbw = wfGetDB( DB_MASTER
);
304 $res = $this->reallyDoQuery( $limit, false );
307 $num = $res->numRows();
310 foreach ( $res as $row ) {
311 if ( isset( $row->value
) ) {
312 if ( $this->usesTimestamps() ) {
313 $value = wfTimestamp( TS_UNIX
,
316 $value = intval( $row->value
); // @bug 14414
322 $vals[] = array( 'qc_type' => $this->getName(),
323 'qc_namespace' => $row->namespace,
324 'qc_title' => $row->title
,
325 'qc_value' => $value );
328 $dbw->startAtomic( __METHOD__
);
329 # Clear out any old cached data
330 $dbw->delete( 'querycache', array( 'qc_type' => $this->getName() ), $fname );
331 # Save results into the querycache table on the master
332 if ( count( $vals ) ) {
333 $dbw->insert( 'querycache', $vals, __METHOD__
);
335 # Update the querycache_info record for the page
336 $dbw->delete( 'querycache_info', array( 'qci_type' => $this->getName() ), $fname );
337 $dbw->insert( 'querycache_info',
338 array( 'qci_type' => $this->getName(), 'qci_timestamp' => $dbw->timestamp() ),
340 $dbw->endAtomic( __METHOD__
);
342 } catch ( DBError
$e ) {
343 if ( !$ignoreErrors ) {
344 throw $e; // report query error
346 $num = false; // set result to false to indicate error
353 * Get a DB connection to be used for slow recache queries
354 * @return DatabaseBase
356 function getRecacheDB() {
357 return wfGetDB( DB_SLAVE
, array( $this->getName(), 'QueryPage::recache', 'vslow' ) );
361 * Run the query and return the result
362 * @param int|bool $limit Numerical limit or false for no limit
363 * @param int|bool $offset Numerical offset or false for no offset
364 * @return ResultWrapper
367 function reallyDoQuery( $limit, $offset = false ) {
368 $fname = get_class( $this ) . "::reallyDoQuery";
369 $dbr = $this->getRecacheDB();
370 $query = $this->getQueryInfo();
371 $order = $this->getOrderFields();
373 if ( $this->sortDescending() ) {
374 foreach ( $order as &$field ) {
379 if ( is_array( $query ) ) {
380 $tables = isset( $query['tables'] ) ?
(array)$query['tables'] : array();
381 $fields = isset( $query['fields'] ) ?
(array)$query['fields'] : array();
382 $conds = isset( $query['conds'] ) ?
(array)$query['conds'] : array();
383 $options = isset( $query['options'] ) ?
(array)$query['options'] : array();
384 $join_conds = isset( $query['join_conds'] ) ?
(array)$query['join_conds'] : array();
386 if ( count( $order ) ) {
387 $options['ORDER BY'] = $order;
390 if ( $limit !== false ) {
391 $options['LIMIT'] = intval( $limit );
394 if ( $offset !== false ) {
395 $options['OFFSET'] = intval( $offset );
398 $res = $dbr->select( $tables, $fields, $conds, $fname,
399 $options, $join_conds
402 // Old-fashioned raw SQL style, deprecated
403 $sql = $this->getSQL();
404 $sql .= ' ORDER BY ' . implode( ', ', $order );
405 $sql = $dbr->limitResult( $sql, $limit, $offset );
406 $res = $dbr->query( $sql, $fname );
409 return $dbr->resultObject( $res );
413 * Somewhat deprecated, you probably want to be using execute()
414 * @param int|bool $offset
415 * @param int|bool $limit
416 * @return ResultWrapper
418 function doQuery( $offset = false, $limit = false ) {
419 if ( $this->isCached() && $this->isCacheable() ) {
420 return $this->fetchFromCache( $limit, $offset );
422 return $this->reallyDoQuery( $limit, $offset );
427 * Fetch the query results from the query cache
428 * @param int|bool $limit Numerical limit or false for no limit
429 * @param int|bool $offset Numerical offset or false for no offset
430 * @return ResultWrapper
433 function fetchFromCache( $limit, $offset = false ) {
434 $dbr = wfGetDB( DB_SLAVE
);
436 if ( $limit !== false ) {
437 $options['LIMIT'] = intval( $limit );
439 if ( $offset !== false ) {
440 $options['OFFSET'] = intval( $offset );
442 if ( $this->sortDescending() ) {
443 $options['ORDER BY'] = 'qc_value DESC';
445 $options['ORDER BY'] = 'qc_value ASC';
447 $res = $dbr->select( 'querycache', array( 'qc_type',
448 'namespace' => 'qc_namespace',
449 'title' => 'qc_title',
450 'value' => 'qc_value' ),
451 array( 'qc_type' => $this->getName() ),
454 return $dbr->resultObject( $res );
457 public function getCachedTimestamp() {
458 if ( is_null( $this->cachedTimestamp
) ) {
459 $dbr = wfGetDB( DB_SLAVE
);
460 $fname = get_class( $this ) . '::getCachedTimestamp';
461 $this->cachedTimestamp
= $dbr->selectField( 'querycache_info', 'qci_timestamp',
462 array( 'qci_type' => $this->getName() ), $fname );
464 return $this->cachedTimestamp
;
468 * This is the actual workhorse. It does everything needed to make a
469 * real, honest-to-gosh query page.
472 function execute( $par ) {
473 $user = $this->getUser();
474 if ( !$this->userCanExecute( $user ) ) {
475 $this->displayRestrictionError();
480 $this->outputHeader();
482 $out = $this->getOutput();
484 if ( $this->isCached() && !$this->isCacheable() ) {
485 $out->addWikiMsg( 'querypage-disabled' );
489 $out->setSyndicated( $this->isSyndicated() );
491 if ( $this->limit
== 0 && $this->offset
== 0 ) {
492 list( $this->limit
, $this->offset
) = $this->getRequest()->getLimitOffset();
495 // @todo Use doQuery()
496 if ( !$this->isCached() ) {
497 # select one extra row for navigation
498 $res = $this->reallyDoQuery( $this->limit +
1, $this->offset
);
500 # Get the cached result, select one extra row for navigation
501 $res = $this->fetchFromCache( $this->limit +
1, $this->offset
);
502 if ( !$this->listoutput
) {
504 # Fetch the timestamp of this update
505 $ts = $this->getCachedTimestamp();
506 $lang = $this->getLanguage();
507 $maxResults = $lang->formatNum( $this->getConfig()->get( 'QueryCacheLimit' ) );
510 $updated = $lang->userTimeAndDate( $ts, $user );
511 $updateddate = $lang->userDate( $ts, $user );
512 $updatedtime = $lang->userTime( $ts, $user );
513 $out->addMeta( 'Data-Cache-Time', $ts );
514 $out->addJsConfigVars( 'dataCacheTime', $ts );
515 $out->addWikiMsg( 'perfcachedts', $updated, $updateddate, $updatedtime, $maxResults );
517 $out->addWikiMsg( 'perfcached', $maxResults );
520 # If updates on this page have been disabled, let the user know
521 # that the data set won't be refreshed for now
522 if ( is_array( $this->getConfig()->get( 'DisableQueryPageUpdate' ) )
523 && in_array( $this->getName(), $this->getConfig()->get( 'DisableQueryPageUpdate' ) )
526 "<div class=\"mw-querypage-no-updates\">\n$1\n</div>",
527 'querypage-no-updates'
533 $this->numRows
= $res->numRows();
535 $dbr = wfGetDB( DB_SLAVE
);
536 $this->preprocessResults( $dbr, $res );
538 $out->addHTML( Xml
::openElement( 'div', array( 'class' => 'mw-spcontent' ) ) );
540 # Top header and navigation
541 if ( $this->shownavigation
) {
542 $out->addHTML( $this->getPageHeader() );
543 if ( $this->numRows
> 0 ) {
544 $out->addHTML( $this->msg( 'showingresultsinrange' )->numParams(
545 min( $this->numRows
, $this->limit
), # do not show the one extra row, if exist
546 $this->offset +
1, ( min( $this->numRows
, $this->limit
) +
$this->offset
) )->parseAsBlock() );
547 # Disable the "next" link when we reach the end
548 $paging = $this->getLanguage()->viewPrevNext( $this->getPageTitle( $par ), $this->offset
,
549 $this->limit
, $this->linkParameters(), ( $this->numRows
<= $this->limit
) );
550 $out->addHTML( '<p>' . $paging . '</p>' );
552 # No results to show, so don't bother with "showing X of Y" etc.
553 # -- just let the user know and give up now
554 $out->addWikiMsg( 'specialpage-empty' );
555 $out->addHTML( Xml
::closeElement( 'div' ) );
560 # The actual results; specialist subclasses will want to handle this
561 # with more than a straight list, so we hand them the info, plus
562 # an OutputPage, and let them get on with it
563 $this->outputResults( $out,
565 $dbr, # Should use a ResultWrapper for this
567 min( $this->numRows
, $this->limit
), # do not format the one extra row, if exist
570 # Repeat the paging links at the bottom
571 if ( $this->shownavigation
) {
572 $out->addHTML( '<p>' . $paging . '</p>' );
575 $out->addHTML( Xml
::closeElement( 'div' ) );
579 * Format and output report results using the given information plus
582 * @param OutputPage $out OutputPage to print to
583 * @param Skin $skin User skin to use
584 * @param DatabaseBase $dbr Database (read) connection to use
585 * @param ResultWrapper $res Result pointer
586 * @param int $num Number of available result rows
587 * @param int $offset Paging offset
589 protected function outputResults( $out, $skin, $dbr, $res, $num, $offset ) {
594 if ( !$this->listoutput
) {
595 $html[] = $this->openList( $offset );
598 # $res might contain the whole 1,000 rows, so we read up to
599 # $num [should update this to use a Pager]
600 // @codingStandardsIgnoreStart Generic.CodeAnalysis.ForLoopWithTestFunctionCall.NotAllowed
601 for ( $i = 0; $i < $num && $row = $res->fetchObject(); $i++
) {
602 // @codingStandardsIgnoreEnd
603 $line = $this->formatResult( $skin, $row );
605 $attr = ( isset( $row->usepatrol
) && $row->usepatrol
&& $row->patrolled
== 0 )
606 ?
' class="not-patrolled"'
608 $html[] = $this->listoutput
610 : "<li{$attr}>{$line}</li>\n";
614 # Flush the final result
615 if ( $this->tryLastResult() ) {
617 $line = $this->formatResult( $skin, $row );
619 $attr = ( isset( $row->usepatrol
) && $row->usepatrol
&& $row->patrolled
== 0 )
620 ?
' class="not-patrolled"'
622 $html[] = $this->listoutput
624 : "<li{$attr}>{$line}</li>\n";
628 if ( !$this->listoutput
) {
629 $html[] = $this->closeList();
632 $html = $this->listoutput
633 ?
$wgContLang->listToText( $html )
634 : implode( '', $html );
636 $out->addHTML( $html );
644 function openList( $offset ) {
645 return "\n<ol start='" . ( $offset +
1 ) . "' class='special'>\n";
651 function closeList() {
656 * Do any necessary preprocessing of the result object.
657 * @param DatabaseBase $db
658 * @param ResultWrapper $res
660 function preprocessResults( $db, $res ) {
664 * Similar to above, but packaging in a syndicated feed instead of a web page
665 * @param string $class
669 function doFeed( $class = '', $limit = 50 ) {
670 if ( !$this->getConfig()->get( 'Feed' ) ) {
671 $this->getOutput()->addWikiMsg( 'feed-unavailable' );
675 $limit = min( $limit, $this->getConfig()->get( 'FeedLimit' ) );
677 $feedClasses = $this->getConfig()->get( 'FeedClasses' );
678 if ( isset( $feedClasses[$class] ) ) {
679 /** @var RSSFeed|AtomFeed $feed */
680 $feed = new $feedClasses[$class](
686 $res = $this->reallyDoQuery( $limit, 0 );
687 foreach ( $res as $obj ) {
688 $item = $this->feedResult( $obj );
690 $feed->outItem( $item );
702 * Override for custom handling. If the titles/links are ok, just do
705 * @return FeedItem|null
707 function feedResult( $row ) {
708 if ( !isset( $row->title
) ) {
711 $title = Title
::makeTitle( intval( $row->namespace ), $row->title
);
713 $date = isset( $row->timestamp
) ?
$row->timestamp
: '';
716 $talkpage = $title->getTalkPage();
717 $comments = $talkpage->getFullURL();
721 $title->getPrefixedText(),
722 $this->feedItemDesc( $row ),
723 $title->getFullURL(),
725 $this->feedItemAuthor( $row ),
732 function feedItemDesc( $row ) {
733 return isset( $row->comment
) ?
htmlspecialchars( $row->comment
) : '';
736 function feedItemAuthor( $row ) {
737 return isset( $row->user_text
) ?
$row->user_text
: '';
740 function feedTitle() {
741 $desc = $this->getDescription();
742 $code = $this->getConfig()->get( 'LanguageCode' );
743 $sitename = $this->getConfig()->get( 'Sitename' );
744 return "$sitename - $desc [$code]";
747 function feedDesc() {
748 return $this->msg( 'tagline' )->text();
752 return $this->getPageTitle()->getFullURL();