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( 'MIMEsearchPage', 'MIMEsearch' ),
80 array( 'MostcategoriesPage', 'Mostcategories' ),
81 array( 'MostimagesPage', 'Mostimages' ),
82 array( 'MostinterwikisPage', 'Mostinterwikis' ),
83 array( 'MostlinkedCategoriesPage', 'Mostlinkedcategories' ),
84 array( 'MostlinkedtemplatesPage', 'Mostlinkedtemplates' ),
85 array( 'MostlinkedPage', 'Mostlinked' ),
86 array( 'MostrevisionsPage', 'Mostrevisions' ),
87 array( 'FewestrevisionsPage', 'Fewestrevisions' ),
88 array( 'ShortPagesPage', 'Shortpages' ),
89 array( 'UncategorizedCategoriesPage', 'Uncategorizedcategories' ),
90 array( 'UncategorizedPagesPage', 'Uncategorizedpages' ),
91 array( 'UncategorizedImagesPage', 'Uncategorizedimages' ),
92 array( 'UncategorizedTemplatesPage', 'Uncategorizedtemplates' ),
93 array( 'UnusedCategoriesPage', 'Unusedcategories' ),
94 array( 'UnusedimagesPage', 'Unusedimages' ),
95 array( 'WantedCategoriesPage', 'Wantedcategories' ),
96 array( 'WantedFilesPage', 'Wantedfiles' ),
97 array( 'WantedPagesPage', 'Wantedpages' ),
98 array( 'WantedTemplatesPage', 'Wantedtemplates' ),
99 array( 'UnwatchedPagesPage', 'Unwatchedpages' ),
100 array( 'UnusedtemplatesPage', 'Unusedtemplates' ),
101 array( 'WithoutInterwikiPage', 'Withoutinterwiki' ),
103 wfRunHooks( 'wgQueryPages', array( &$qp ) );
105 if ( !$wgDisableCounters ) {
106 $qp[] = array( 'PopularPagesPage', 'Popularpages' );
114 * A mutator for $this->listoutput;
118 function setListoutput( $bool ) {
119 $this->listoutput
= $bool;
123 * Subclasses return an SQL query here, formatted as an array with the
125 * tables => Table(s) for passing to Database::select()
126 * fields => Field(s) for passing to Database::select(), may be *
127 * conds => WHERE conditions
129 * join_conds => JOIN conditions
131 * Note that the query itself should return the following three columns:
132 * 'namespace', 'title', and 'value'. 'value' is used for sorting.
134 * These may be stored in the querycache table for expensive queries,
135 * and that cached data will be returned sometimes, so the presence of
136 * extra fields can't be relied upon. The cached 'value' column will be
137 * an integer; non-numeric values are useful only for sorting the
138 * initial query (except if they're timestamps, see usesTimestamps()).
140 * Don't include an ORDER or LIMIT clause, they will be added.
142 * If this function is not overridden or returns something other than
143 * an array, getSQL() will be used instead. This is for backwards
144 * compatibility only and is strongly deprecated.
148 function getQueryInfo() {
153 * For back-compat, subclasses may return a raw SQL query here, as a string.
154 * This is strongly deprecated; getQueryInfo() should be overridden instead.
155 * @throws MWException
159 /* Implement getQueryInfo() instead */
160 throw new MWException( "Bug in a QueryPage: doesn't implement getQueryInfo() nor "
161 . "getQuery() properly" );
165 * Subclasses return an array of fields to order by here. Don't append
166 * DESC to the field names, that'll be done automatically if
167 * sortDescending() returns true.
171 function getOrderFields() {
172 return array( 'value' );
176 * Does this query return timestamps rather than integers in its
177 * 'value' field? If true, this class will convert 'value' to a
178 * UNIX timestamp for caching.
179 * NOTE: formatRow() may get timestamps in TS_MW (mysql), TS_DB (pgsql)
180 * or TS_UNIX (querycache) format, so be sure to always run them
181 * through wfTimestamp()
185 function usesTimestamps() {
190 * Override to sort by increasing values
194 function sortDescending() {
199 * Is this query expensive (for some definition of expensive)? Then we
200 * don't let it run in miser mode. $wgDisableQueryPages causes all query
201 * pages to be declared expensive. Some query pages are always expensive.
205 function isExpensive() {
206 return $this->getConfig()->get( 'DisableQueryPages' );
210 * Is the output of this query cacheable? Non-cacheable expensive pages
211 * will be disabled in miser mode and will not have their results written
212 * to the querycache table.
216 public function isCacheable() {
221 * Whether or not the output of the page in question is retrieved from
222 * the database cache.
226 function isCached() {
227 return $this->isExpensive() && $this->getConfig()->get( 'MiserMode' );
231 * Sometime we don't want to build rss / atom feeds.
235 function isSyndicated() {
240 * Formats the results of the query for display. The skin is the current
241 * skin; you can use it for making links. The result is a single row of
242 * result data. You should be able to grab SQL results off of it.
243 * If the function returns false, the line output will be skipped.
245 * @param object $result Result row
246 * @return string|bool String or false to skip
248 abstract function formatResult( $skin, $result );
251 * The content returned by this function will be output before any result
255 function getPageHeader() {
260 * If using extra form wheely-dealies, return a set of parameters here
261 * as an associative array. They will be encoded and added to the paging
262 * links (prev/next/lengths).
266 function linkParameters() {
271 * Some special pages (for example SpecialListusers) might not return the
272 * current object formatted, but return the previous one instead.
273 * Setting this to return true will ensure formatResult() is called
274 * one more time to make sure that the very last result is formatted
278 function tryLastResult() {
283 * Clear the cache and save new results
285 * @param int|bool $limit Limit for SQL statement
286 * @param bool $ignoreErrors Whether to ignore database errors
287 * @throws DBError|Exception
290 function recache( $limit, $ignoreErrors = true ) {
291 if ( !$this->isCacheable() ) {
295 $fname = get_class( $this ) . '::recache';
296 $dbw = wfGetDB( DB_MASTER
);
303 $res = $this->reallyDoQuery( $limit, false );
306 $num = $res->numRows();
309 foreach ( $res as $row ) {
310 if ( isset( $row->value
) ) {
311 if ( $this->usesTimestamps() ) {
312 $value = wfTimestamp( TS_UNIX
,
315 $value = intval( $row->value
); // @bug 14414
321 $vals[] = array( 'qc_type' => $this->getName(),
322 'qc_namespace' => $row->namespace,
323 'qc_title' => $row->title
,
324 'qc_value' => $value );
327 $dbw->begin( __METHOD__
);
328 # Clear out any old cached data
329 $dbw->delete( 'querycache', array( 'qc_type' => $this->getName() ), $fname );
330 # Save results into the querycache table on the master
331 if ( count( $vals ) ) {
332 $dbw->insert( 'querycache', $vals, __METHOD__
);
334 # Update the querycache_info record for the page
335 $dbw->delete( 'querycache_info', array( 'qci_type' => $this->getName() ), $fname );
336 $dbw->insert( 'querycache_info',
337 array( 'qci_type' => $this->getName(), 'qci_timestamp' => $dbw->timestamp() ),
339 $dbw->commit( __METHOD__
);
341 } catch ( DBError
$e ) {
342 if ( !$ignoreErrors ) {
343 throw $e; // report query error
345 $num = false; // set result to false to indicate error
352 * Get a DB connection to be used for slow recache queries
353 * @return DatabaseBase
355 function getRecacheDB() {
356 return wfGetDB( DB_SLAVE
, array( $this->getName(), 'QueryPage::recache', 'vslow' ) );
360 * Run the query and return the result
361 * @param int|bool $limit Numerical limit or false for no limit
362 * @param int|bool $offset Numerical offset or false for no offset
363 * @return ResultWrapper
366 function reallyDoQuery( $limit, $offset = false ) {
367 $fname = get_class( $this ) . "::reallyDoQuery";
368 $dbr = $this->getRecacheDB();
369 $query = $this->getQueryInfo();
370 $order = $this->getOrderFields();
372 if ( $this->sortDescending() ) {
373 foreach ( $order as &$field ) {
378 if ( is_array( $query ) ) {
379 $tables = isset( $query['tables'] ) ?
(array)$query['tables'] : array();
380 $fields = isset( $query['fields'] ) ?
(array)$query['fields'] : array();
381 $conds = isset( $query['conds'] ) ?
(array)$query['conds'] : array();
382 $options = isset( $query['options'] ) ?
(array)$query['options'] : array();
383 $join_conds = isset( $query['join_conds'] ) ?
(array)$query['join_conds'] : array();
385 if ( count( $order ) ) {
386 $options['ORDER BY'] = $order;
389 if ( $limit !== false ) {
390 $options['LIMIT'] = intval( $limit );
393 if ( $offset !== false ) {
394 $options['OFFSET'] = intval( $offset );
397 $res = $dbr->select( $tables, $fields, $conds, $fname,
398 $options, $join_conds
401 // Old-fashioned raw SQL style, deprecated
402 $sql = $this->getSQL();
403 $sql .= ' ORDER BY ' . implode( ', ', $order );
404 $sql = $dbr->limitResult( $sql, $limit, $offset );
405 $res = $dbr->query( $sql, $fname );
408 return $dbr->resultObject( $res );
412 * Somewhat deprecated, you probably want to be using execute()
413 * @param int|bool $offset
414 * @param int|bool $limit
415 * @return ResultWrapper
417 function doQuery( $offset = false, $limit = false ) {
418 if ( $this->isCached() && $this->isCacheable() ) {
419 return $this->fetchFromCache( $limit, $offset );
421 return $this->reallyDoQuery( $limit, $offset );
426 * Fetch the query results from the query cache
427 * @param int|bool $limit Numerical limit or false for no limit
428 * @param int|bool $offset Numerical offset or false for no offset
429 * @return ResultWrapper
432 function fetchFromCache( $limit, $offset = false ) {
433 $dbr = wfGetDB( DB_SLAVE
);
435 if ( $limit !== false ) {
436 $options['LIMIT'] = intval( $limit );
438 if ( $offset !== false ) {
439 $options['OFFSET'] = intval( $offset );
441 if ( $this->sortDescending() ) {
442 $options['ORDER BY'] = 'qc_value DESC';
444 $options['ORDER BY'] = 'qc_value ASC';
446 $res = $dbr->select( 'querycache', array( 'qc_type',
447 'namespace' => 'qc_namespace',
448 'title' => 'qc_title',
449 'value' => 'qc_value' ),
450 array( 'qc_type' => $this->getName() ),
453 return $dbr->resultObject( $res );
456 public function getCachedTimestamp() {
457 if ( is_null( $this->cachedTimestamp
) ) {
458 $dbr = wfGetDB( DB_SLAVE
);
459 $fname = get_class( $this ) . '::getCachedTimestamp';
460 $this->cachedTimestamp
= $dbr->selectField( 'querycache_info', 'qci_timestamp',
461 array( 'qci_type' => $this->getName() ), $fname );
463 return $this->cachedTimestamp
;
467 * This is the actual workhorse. It does everything needed to make a
468 * real, honest-to-gosh query page.
471 function execute( $par ) {
472 $user = $this->getUser();
473 if ( !$this->userCanExecute( $user ) ) {
474 $this->displayRestrictionError();
479 $this->outputHeader();
481 $out = $this->getOutput();
483 if ( $this->isCached() && !$this->isCacheable() ) {
484 $out->addWikiMsg( 'querypage-disabled' );
488 $out->setSyndicated( $this->isSyndicated() );
490 if ( $this->limit
== 0 && $this->offset
== 0 ) {
491 list( $this->limit
, $this->offset
) = $this->getRequest()->getLimitOffset();
494 // @todo Use doQuery()
495 if ( !$this->isCached() ) {
496 # select one extra row for navigation
497 $res = $this->reallyDoQuery( $this->limit +
1, $this->offset
);
499 # Get the cached result, select one extra row for navigation
500 $res = $this->fetchFromCache( $this->limit +
1, $this->offset
);
501 if ( !$this->listoutput
) {
503 # Fetch the timestamp of this update
504 $ts = $this->getCachedTimestamp();
505 $lang = $this->getLanguage();
506 $maxResults = $lang->formatNum( $this->getConfig()->get( 'QueryCacheLimit' ) );
509 $updated = $lang->userTimeAndDate( $ts, $user );
510 $updateddate = $lang->userDate( $ts, $user );
511 $updatedtime = $lang->userTime( $ts, $user );
512 $out->addMeta( 'Data-Cache-Time', $ts );
513 $out->addJsConfigVars( 'dataCacheTime', $ts );
514 $out->addWikiMsg( 'perfcachedts', $updated, $updateddate, $updatedtime, $maxResults );
516 $out->addWikiMsg( 'perfcached', $maxResults );
519 # If updates on this page have been disabled, let the user know
520 # that the data set won't be refreshed for now
521 if ( is_array( $this->getConfig()->get( 'DisableQueryPageUpdate' ) )
522 && in_array( $this->getName(), $this->getConfig()->get( 'DisableQueryPageUpdate' ) )
525 "<div class=\"mw-querypage-no-updates\">\n$1\n</div>",
526 'querypage-no-updates'
532 $this->numRows
= $res->numRows();
534 $dbr = wfGetDB( DB_SLAVE
);
535 $this->preprocessResults( $dbr, $res );
537 $out->addHTML( Xml
::openElement( 'div', array( 'class' => 'mw-spcontent' ) ) );
539 # Top header and navigation
540 if ( $this->shownavigation
) {
541 $out->addHTML( $this->getPageHeader() );
542 if ( $this->numRows
> 0 ) {
543 $out->addHTML( $this->msg( 'showingresultsinrange' )->numParams(
544 min( $this->numRows
, $this->limit
), # do not show the one extra row, if exist
545 $this->offset +
1, ( min( $this->numRows
, $this->limit
) +
$this->offset
) )->parseAsBlock() );
546 # Disable the "next" link when we reach the end
547 $paging = $this->getLanguage()->viewPrevNext( $this->getPageTitle( $par ), $this->offset
,
548 $this->limit
, $this->linkParameters(), ( $this->numRows
<= $this->limit
) );
549 $out->addHTML( '<p>' . $paging . '</p>' );
551 # No results to show, so don't bother with "showing X of Y" etc.
552 # -- just let the user know and give up now
553 $out->addWikiMsg( 'specialpage-empty' );
554 $out->addHTML( Xml
::closeElement( 'div' ) );
559 # The actual results; specialist subclasses will want to handle this
560 # with more than a straight list, so we hand them the info, plus
561 # an OutputPage, and let them get on with it
562 $this->outputResults( $out,
564 $dbr, # Should use a ResultWrapper for this
566 min( $this->numRows
, $this->limit
), # do not format the one extra row, if exist
569 # Repeat the paging links at the bottom
570 if ( $this->shownavigation
) {
571 $out->addHTML( '<p>' . $paging . '</p>' );
574 $out->addHTML( Xml
::closeElement( 'div' ) );
578 * Format and output report results using the given information plus
581 * @param OutputPage $out OutputPage to print to
582 * @param Skin $skin User skin to use
583 * @param DatabaseBase $dbr Database (read) connection to use
584 * @param ResultWrapper $res Result pointer
585 * @param int $num Number of available result rows
586 * @param int $offset Paging offset
588 protected function outputResults( $out, $skin, $dbr, $res, $num, $offset ) {
593 if ( !$this->listoutput
) {
594 $html[] = $this->openList( $offset );
597 # $res might contain the whole 1,000 rows, so we read up to
598 # $num [should update this to use a Pager]
599 // @codingStandardsIgnoreStart Generic.CodeAnalysis.ForLoopWithTestFunctionCall.NotAllowed
600 for ( $i = 0; $i < $num && $row = $res->fetchObject(); $i++
) {
601 // @codingStandardsIgnoreEnd
602 $line = $this->formatResult( $skin, $row );
604 $attr = ( isset( $row->usepatrol
) && $row->usepatrol
&& $row->patrolled
== 0 )
605 ?
' class="not-patrolled"'
607 $html[] = $this->listoutput
609 : "<li{$attr}>{$line}</li>\n";
613 # Flush the final result
614 if ( $this->tryLastResult() ) {
616 $line = $this->formatResult( $skin, $row );
618 $attr = ( isset( $row->usepatrol
) && $row->usepatrol
&& $row->patrolled
== 0 )
619 ?
' class="not-patrolled"'
621 $html[] = $this->listoutput
623 : "<li{$attr}>{$line}</li>\n";
627 if ( !$this->listoutput
) {
628 $html[] = $this->closeList();
631 $html = $this->listoutput
632 ?
$wgContLang->listToText( $html )
633 : implode( '', $html );
635 $out->addHTML( $html );
643 function openList( $offset ) {
644 return "\n<ol start='" . ( $offset +
1 ) . "' class='special'>\n";
650 function closeList() {
655 * Do any necessary preprocessing of the result object.
656 * @param DatabaseBase $db
657 * @param ResultWrapper $res
659 function preprocessResults( $db, $res ) {
663 * Similar to above, but packaging in a syndicated feed instead of a web page
664 * @param string $class
668 function doFeed( $class = '', $limit = 50 ) {
669 if ( !$this->getConfig()->get( 'Feed' ) ) {
670 $this->getOutput()->addWikiMsg( 'feed-unavailable' );
674 $limit = min( $limit, $this->getConfig()->get( 'FeedLimit' ) );
676 $feedClasses = $this->getConfig()->get( 'FeedClasses' );
677 if ( isset( $feedClasses[$class] ) ) {
678 /** @var RSSFeed|AtomFeed $feed */
679 $feed = new $feedClasses[$class](
685 $res = $this->reallyDoQuery( $limit, 0 );
686 foreach ( $res as $obj ) {
687 $item = $this->feedResult( $obj );
689 $feed->outItem( $item );
701 * Override for custom handling. If the titles/links are ok, just do
704 * @return FeedItem|null
706 function feedResult( $row ) {
707 if ( !isset( $row->title
) ) {
710 $title = Title
::makeTitle( intval( $row->namespace ), $row->title
);
712 $date = isset( $row->timestamp
) ?
$row->timestamp
: '';
715 $talkpage = $title->getTalkPage();
716 $comments = $talkpage->getFullURL();
720 $title->getPrefixedText(),
721 $this->feedItemDesc( $row ),
722 $title->getFullURL(),
724 $this->feedItemAuthor( $row ),
731 function feedItemDesc( $row ) {
732 return isset( $row->comment
) ?
htmlspecialchars( $row->comment
) : '';
735 function feedItemAuthor( $row ) {
736 return isset( $row->user_text
) ?
$row->user_text
: '';
739 function feedTitle() {
740 $desc = $this->getDescription();
741 $code = $this->getConfig()->get( 'LanguageCode' );
742 $sitename = $this->getConfig()->get( 'Sitename' );
743 return "$sitename - $desc [$code]";
746 function feedDesc() {
747 return $this->msg( 'tagline' )->text();
751 return $this->getPageTitle()->getFullURL();