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() {
66 // QueryPage subclass, Special page name
68 array( 'AncientPagesPage', 'Ancientpages' ),
69 array( 'BrokenRedirectsPage', 'BrokenRedirects' ),
70 array( 'DeadendPagesPage', 'Deadendpages' ),
71 array( 'DoubleRedirectsPage', 'DoubleRedirects' ),
72 array( 'FileDuplicateSearchPage', 'FileDuplicateSearch' ),
73 array( 'ListDuplicatedFilesPage', 'ListDuplicatedFiles' ),
74 array( 'LinkSearchPage', 'LinkSearch' ),
75 array( 'ListredirectsPage', 'Listredirects' ),
76 array( 'LonelyPagesPage', 'Lonelypages' ),
77 array( 'LongPagesPage', 'Longpages' ),
78 array( 'MediaStatisticsPage', 'MediaStatistics' ),
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 Hooks
::run( 'wgQueryPages', array( &$qp ) );
110 * A mutator for $this->listoutput;
114 function setListoutput( $bool ) {
115 $this->listoutput
= $bool;
119 * Subclasses return an SQL query here, formatted as an array with the
121 * tables => Table(s) for passing to Database::select()
122 * fields => Field(s) for passing to Database::select(), may be *
123 * conds => WHERE conditions
125 * join_conds => JOIN conditions
127 * Note that the query itself should return the following three columns:
128 * 'namespace', 'title', and 'value'. 'value' is used for sorting.
130 * These may be stored in the querycache table for expensive queries,
131 * and that cached data will be returned sometimes, so the presence of
132 * extra fields can't be relied upon. The cached 'value' column will be
133 * an integer; non-numeric values are useful only for sorting the
134 * initial query (except if they're timestamps, see usesTimestamps()).
136 * Don't include an ORDER or LIMIT clause, they will be added.
138 * If this function is not overridden or returns something other than
139 * an array, getSQL() will be used instead. This is for backwards
140 * compatibility only and is strongly deprecated.
144 public function getQueryInfo() {
149 * For back-compat, subclasses may return a raw SQL query here, as a string.
150 * This is strongly deprecated; getQueryInfo() should be overridden instead.
151 * @throws MWException
155 /* Implement getQueryInfo() instead */
156 throw new MWException( "Bug in a QueryPage: doesn't implement getQueryInfo() nor "
157 . "getQuery() properly" );
161 * Subclasses return an array of fields to order by here. Don't append
162 * DESC to the field names, that'll be done automatically if
163 * sortDescending() returns true.
167 function getOrderFields() {
168 return array( 'value' );
172 * Does this query return timestamps rather than integers in its
173 * 'value' field? If true, this class will convert 'value' to a
174 * UNIX timestamp for caching.
175 * NOTE: formatRow() may get timestamps in TS_MW (mysql), TS_DB (pgsql)
176 * or TS_UNIX (querycache) format, so be sure to always run them
177 * through wfTimestamp()
181 public function usesTimestamps() {
186 * Override to sort by increasing values
190 function sortDescending() {
195 * Is this query expensive (for some definition of expensive)? Then we
196 * don't let it run in miser mode. $wgDisableQueryPages causes all query
197 * pages to be declared expensive. Some query pages are always expensive.
201 public function isExpensive() {
202 return $this->getConfig()->get( 'DisableQueryPages' );
206 * Is the output of this query cacheable? Non-cacheable expensive pages
207 * will be disabled in miser mode and will not have their results written
208 * to the querycache table.
212 public function isCacheable() {
217 * Whether or not the output of the page in question is retrieved from
218 * the database cache.
222 public function isCached() {
223 return $this->isExpensive() && $this->getConfig()->get( 'MiserMode' );
227 * Sometime we don't want to build rss / atom feeds.
231 function isSyndicated() {
236 * Formats the results of the query for display. The skin is the current
237 * skin; you can use it for making links. The result is a single row of
238 * result data. You should be able to grab SQL results off of it.
239 * If the function returns false, the line output will be skipped.
241 * @param object $result Result row
242 * @return string|bool String or false to skip
244 abstract function formatResult( $skin, $result );
247 * The content returned by this function will be output before any result
251 function getPageHeader() {
256 * Outputs some kind of an informative message (via OutputPage) to let the
257 * user know that the query returned nothing and thus there's nothing to
262 protected function showEmptyText() {
263 $this->getOutput()->addWikiMsg( 'specialpage-empty' );
267 * If using extra form wheely-dealies, return a set of parameters here
268 * as an associative array. They will be encoded and added to the paging
269 * links (prev/next/lengths).
273 function linkParameters() {
278 * Some special pages (for example SpecialListusers) might not return the
279 * current object formatted, but return the previous one instead.
280 * Setting this to return true will ensure formatResult() is called
281 * one more time to make sure that the very last result is formatted
285 function tryLastResult() {
290 * Clear the cache and save new results
292 * @param int|bool $limit Limit for SQL statement
293 * @param bool $ignoreErrors Whether to ignore database errors
294 * @throws DBError|Exception
297 public function recache( $limit, $ignoreErrors = true ) {
298 if ( !$this->isCacheable() ) {
302 $fname = get_class( $this ) . '::recache';
303 $dbw = wfGetDB( DB_MASTER
);
310 $res = $this->reallyDoQuery( $limit, false );
313 $num = $res->numRows();
316 foreach ( $res as $row ) {
317 if ( isset( $row->value
) ) {
318 if ( $this->usesTimestamps() ) {
319 $value = wfTimestamp( TS_UNIX
,
322 $value = intval( $row->value
); // @bug 14414
328 $vals[] = array( 'qc_type' => $this->getName(),
329 'qc_namespace' => $row->namespace,
330 'qc_title' => $row->title
,
331 'qc_value' => $value );
334 $dbw->startAtomic( __METHOD__
);
335 # Clear out any old cached data
336 $dbw->delete( 'querycache', array( 'qc_type' => $this->getName() ), $fname );
337 # Save results into the querycache table on the master
338 if ( count( $vals ) ) {
339 $dbw->insert( 'querycache', $vals, __METHOD__
);
341 # Update the querycache_info record for the page
342 $dbw->delete( 'querycache_info', array( 'qci_type' => $this->getName() ), $fname );
343 $dbw->insert( 'querycache_info',
344 array( 'qci_type' => $this->getName(), 'qci_timestamp' => $dbw->timestamp() ),
346 $dbw->endAtomic( __METHOD__
);
348 } catch ( DBError
$e ) {
349 if ( !$ignoreErrors ) {
350 throw $e; // report query error
352 $num = false; // set result to false to indicate error
359 * Get a DB connection to be used for slow recache queries
362 function getRecacheDB() {
363 return wfGetDB( DB_SLAVE
, array( $this->getName(), 'QueryPage::recache', 'vslow' ) );
367 * Run the query and return the result
368 * @param int|bool $limit Numerical limit or false for no limit
369 * @param int|bool $offset Numerical offset or false for no offset
370 * @return ResultWrapper
373 public function reallyDoQuery( $limit, $offset = false ) {
374 $fname = get_class( $this ) . "::reallyDoQuery";
375 $dbr = $this->getRecacheDB();
376 $query = $this->getQueryInfo();
377 $order = $this->getOrderFields();
379 if ( $this->sortDescending() ) {
380 foreach ( $order as &$field ) {
385 if ( is_array( $query ) ) {
386 $tables = isset( $query['tables'] ) ?
(array)$query['tables'] : array();
387 $fields = isset( $query['fields'] ) ?
(array)$query['fields'] : array();
388 $conds = isset( $query['conds'] ) ?
(array)$query['conds'] : array();
389 $options = isset( $query['options'] ) ?
(array)$query['options'] : array();
390 $join_conds = isset( $query['join_conds'] ) ?
(array)$query['join_conds'] : array();
392 if ( count( $order ) ) {
393 $options['ORDER BY'] = $order;
396 if ( $limit !== false ) {
397 $options['LIMIT'] = intval( $limit );
400 if ( $offset !== false ) {
401 $options['OFFSET'] = intval( $offset );
404 $res = $dbr->select( $tables, $fields, $conds, $fname,
405 $options, $join_conds
408 // Old-fashioned raw SQL style, deprecated
409 $sql = $this->getSQL();
410 $sql .= ' ORDER BY ' . implode( ', ', $order );
411 $sql = $dbr->limitResult( $sql, $limit, $offset );
412 $res = $dbr->query( $sql, $fname );
419 * Somewhat deprecated, you probably want to be using execute()
420 * @param int|bool $offset
421 * @param int|bool $limit
422 * @return ResultWrapper
424 public function doQuery( $offset = false, $limit = false ) {
425 if ( $this->isCached() && $this->isCacheable() ) {
426 return $this->fetchFromCache( $limit, $offset );
428 return $this->reallyDoQuery( $limit, $offset );
433 * Fetch the query results from the query cache
434 * @param int|bool $limit Numerical limit or false for no limit
435 * @param int|bool $offset Numerical offset or false for no offset
436 * @return ResultWrapper
439 public function fetchFromCache( $limit, $offset = false ) {
440 $dbr = wfGetDB( DB_SLAVE
);
442 if ( $limit !== false ) {
443 $options['LIMIT'] = intval( $limit );
445 if ( $offset !== false ) {
446 $options['OFFSET'] = intval( $offset );
448 if ( $this->sortDescending() ) {
449 $options['ORDER BY'] = 'qc_value DESC';
451 $options['ORDER BY'] = 'qc_value ASC';
453 return $dbr->select( 'querycache', array( 'qc_type',
454 'namespace' => 'qc_namespace',
455 'title' => 'qc_title',
456 'value' => 'qc_value' ),
457 array( 'qc_type' => $this->getName() ),
462 public function getCachedTimestamp() {
463 if ( is_null( $this->cachedTimestamp
) ) {
464 $dbr = wfGetDB( DB_SLAVE
);
465 $fname = get_class( $this ) . '::getCachedTimestamp';
466 $this->cachedTimestamp
= $dbr->selectField( 'querycache_info', 'qci_timestamp',
467 array( 'qci_type' => $this->getName() ), $fname );
469 return $this->cachedTimestamp
;
473 * Returns limit and offset, as returned by $this->getRequest()->getLimitOffset().
474 * Subclasses may override this to further restrict or modify limit and offset.
476 * @note Restricts the offset parameter, as most query pages have inefficient paging
479 * @return int[] list( $limit, $offset )
481 protected function getLimitOffset() {
482 list( $limit, $offset ) = $this->getRequest()->getLimitOffset();
483 if ( !$this->getConfig()->get( 'MiserMode' ) ) {
484 $maxResults = $this->getMaxResults();
485 // Can't display more than max results on a page
486 $limit = min( $limit, $maxResults );
487 // Can't skip over more than $maxResults
488 $offset = min( $offset, $maxResults );
489 // Can't let $offset + $limit > $maxResults
490 $limit = min( $limit, $maxResults - $offset );
492 return array( $limit, $offset );
496 * Get max number of results we can return in miser mode.
498 * Most QueryPage subclasses use inefficient paging, so limit the max amount we return
499 * This matters for uncached query pages that might otherwise accept an offset of 3 million
504 protected function getMaxResults() {
505 // Max of 10000, unless we store more than 5000 in query cache.
506 return max( $this->getConfig()->get( 'QueryCacheLimit' ), 10000 );
510 * This is the actual workhorse. It does everything needed to make a
511 * real, honest-to-gosh query page.
514 public function execute( $par ) {
515 $user = $this->getUser();
516 if ( !$this->userCanExecute( $user ) ) {
517 $this->displayRestrictionError();
522 $this->outputHeader();
524 $out = $this->getOutput();
526 if ( $this->isCached() && !$this->isCacheable() ) {
527 $out->addWikiMsg( 'querypage-disabled' );
531 $out->setSyndicated( $this->isSyndicated() );
533 if ( $this->limit
== 0 && $this->offset
== 0 ) {
534 list( $this->limit
, $this->offset
) = $this->getLimitOffset();
537 // @todo Use doQuery()
538 if ( !$this->isCached() ) {
539 # select one extra row for navigation
540 $res = $this->reallyDoQuery( $this->limit +
1, $this->offset
);
542 # Get the cached result, select one extra row for navigation
543 $res = $this->fetchFromCache( $this->limit +
1, $this->offset
);
544 if ( !$this->listoutput
) {
546 # Fetch the timestamp of this update
547 $ts = $this->getCachedTimestamp();
548 $lang = $this->getLanguage();
549 $maxResults = $lang->formatNum( $this->getConfig()->get( 'QueryCacheLimit' ) );
552 $updated = $lang->userTimeAndDate( $ts, $user );
553 $updateddate = $lang->userDate( $ts, $user );
554 $updatedtime = $lang->userTime( $ts, $user );
555 $out->addMeta( 'Data-Cache-Time', $ts );
556 $out->addJsConfigVars( 'dataCacheTime', $ts );
557 $out->addWikiMsg( 'perfcachedts', $updated, $updateddate, $updatedtime, $maxResults );
559 $out->addWikiMsg( 'perfcached', $maxResults );
562 # If updates on this page have been disabled, let the user know
563 # that the data set won't be refreshed for now
564 if ( is_array( $this->getConfig()->get( 'DisableQueryPageUpdate' ) )
565 && in_array( $this->getName(), $this->getConfig()->get( 'DisableQueryPageUpdate' ) )
568 "<div class=\"mw-querypage-no-updates\">\n$1\n</div>",
569 'querypage-no-updates'
575 $this->numRows
= $res->numRows();
577 $dbr = $this->getRecacheDB();
578 $this->preprocessResults( $dbr, $res );
580 $out->addHTML( Xml
::openElement( 'div', array( 'class' => 'mw-spcontent' ) ) );
582 # Top header and navigation
583 if ( $this->shownavigation
) {
584 $out->addHTML( $this->getPageHeader() );
585 if ( $this->numRows
> 0 ) {
586 $out->addHTML( $this->msg( 'showingresultsinrange' )->numParams(
587 min( $this->numRows
, $this->limit
), # do not show the one extra row, if exist
588 $this->offset +
1, ( min( $this->numRows
, $this->limit
) +
$this->offset
) )->parseAsBlock() );
589 # Disable the "next" link when we reach the end
590 $atEnd = ( $this->numRows
<= $this->limit
)
591 ||
( $this->offset +
$this-> limit
>= $this->getMaxResults() );
592 $paging = $this->getLanguage()->viewPrevNext( $this->getPageTitle( $par ), $this->offset
,
593 $this->limit
, $this->linkParameters(), $atEnd );
594 $out->addHTML( '<p>' . $paging . '</p>' );
596 # No results to show, so don't bother with "showing X of Y" etc.
597 # -- just let the user know and give up now
598 $this->showEmptyText();
599 $out->addHTML( Xml
::closeElement( 'div' ) );
604 # The actual results; specialist subclasses will want to handle this
605 # with more than a straight list, so we hand them the info, plus
606 # an OutputPage, and let them get on with it
607 $this->outputResults( $out,
609 $dbr, # Should use a ResultWrapper for this
611 min( $this->numRows
, $this->limit
), # do not format the one extra row, if exist
614 # Repeat the paging links at the bottom
615 if ( $this->shownavigation
) {
616 $out->addHTML( '<p>' . $paging . '</p>' );
619 $out->addHTML( Xml
::closeElement( 'div' ) );
623 * Format and output report results using the given information plus
626 * @param OutputPage $out OutputPage to print to
627 * @param Skin $skin User skin to use
628 * @param IDatabase $dbr Database (read) connection to use
629 * @param ResultWrapper $res Result pointer
630 * @param int $num Number of available result rows
631 * @param int $offset Paging offset
633 protected function outputResults( $out, $skin, $dbr, $res, $num, $offset ) {
638 if ( !$this->listoutput
) {
639 $html[] = $this->openList( $offset );
642 # $res might contain the whole 1,000 rows, so we read up to
643 # $num [should update this to use a Pager]
644 // @codingStandardsIgnoreStart Generic.CodeAnalysis.ForLoopWithTestFunctionCall.NotAllowed
645 for ( $i = 0; $i < $num && $row = $res->fetchObject(); $i++
) {
646 // @codingStandardsIgnoreEnd
647 $line = $this->formatResult( $skin, $row );
649 $attr = ( isset( $row->usepatrol
) && $row->usepatrol
&& $row->patrolled
== 0 )
650 ?
' class="not-patrolled"'
652 $html[] = $this->listoutput
654 : "<li{$attr}>{$line}</li>\n";
658 # Flush the final result
659 if ( $this->tryLastResult() ) {
661 $line = $this->formatResult( $skin, $row );
663 $attr = ( isset( $row->usepatrol
) && $row->usepatrol
&& $row->patrolled
== 0 )
664 ?
' class="not-patrolled"'
666 $html[] = $this->listoutput
668 : "<li{$attr}>{$line}</li>\n";
672 if ( !$this->listoutput
) {
673 $html[] = $this->closeList();
676 $html = $this->listoutput
677 ?
$wgContLang->listToText( $html )
678 : implode( '', $html );
680 $out->addHTML( $html );
688 function openList( $offset ) {
689 return "\n<ol start='" . ( $offset +
1 ) . "' class='special'>\n";
695 function closeList() {
700 * Do any necessary preprocessing of the result object.
701 * @param IDatabase $db
702 * @param ResultWrapper $res
704 function preprocessResults( $db, $res ) {
708 * Similar to above, but packaging in a syndicated feed instead of a web page
709 * @param string $class
713 function doFeed( $class = '', $limit = 50 ) {
714 if ( !$this->getConfig()->get( 'Feed' ) ) {
715 $this->getOutput()->addWikiMsg( 'feed-unavailable' );
719 $limit = min( $limit, $this->getConfig()->get( 'FeedLimit' ) );
721 $feedClasses = $this->getConfig()->get( 'FeedClasses' );
722 if ( isset( $feedClasses[$class] ) ) {
723 /** @var RSSFeed|AtomFeed $feed */
724 $feed = new $feedClasses[$class](
730 $res = $this->reallyDoQuery( $limit, 0 );
731 foreach ( $res as $obj ) {
732 $item = $this->feedResult( $obj );
734 $feed->outItem( $item );
746 * Override for custom handling. If the titles/links are ok, just do
749 * @return FeedItem|null
751 function feedResult( $row ) {
752 if ( !isset( $row->title
) ) {
755 $title = Title
::makeTitle( intval( $row->namespace ), $row->title
);
757 $date = isset( $row->timestamp
) ?
$row->timestamp
: '';
760 $talkpage = $title->getTalkPage();
761 $comments = $talkpage->getFullURL();
765 $title->getPrefixedText(),
766 $this->feedItemDesc( $row ),
767 $title->getFullURL(),
769 $this->feedItemAuthor( $row ),
776 function feedItemDesc( $row ) {
777 return isset( $row->comment
) ?
htmlspecialchars( $row->comment
) : '';
780 function feedItemAuthor( $row ) {
781 return isset( $row->user_text
) ?
$row->user_text
: '';
784 function feedTitle() {
785 $desc = $this->getDescription();
786 $code = $this->getConfig()->get( 'LanguageCode' );
787 $sitename = $this->getConfig()->get( 'Sitename' );
788 return "$sitename - $desc [$code]";
791 function feedDesc() {
792 return $this->msg( 'tagline' )->text();
796 return $this->getPageTitle()->getFullURL();