WMDE-leszek has uploaded a new change for review.

  https://gerrit.wikimedia.org/r/284169

Change subject: [WIP]Refactor database-related code in ApiQueryWatchlist
......................................................................

[WIP]Refactor database-related code in ApiQueryWatchlist

This moves generating of a complex Watchlist and RecentChanges
related query to a separate class which is then called from
WatchedItemStore.
ApiQueryWatchlist class no longer contains any database-related
code.

TODO:
 - tests for anonymous user?
 - WatchedItemQueryIntegrationTest?

Bug: T132565
Change-Id: I5a5cda13f8091baa430ac1a8e2176e0efd1ae192
---
M autoload.php
A includes/WatchedItemQuery.php
M includes/WatchedItemStore.php
M includes/api/ApiQueryWatchlist.php
A tests/phpunit/includes/WatchedItemQueryUnitTest.php
5 files changed, 1,573 insertions(+), 158 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/mediawiki/core 
refs/changes/69/284169/1

diff --git a/autoload.php b/autoload.php
index db543dd..072a88d 100644
--- a/autoload.php
+++ b/autoload.php
@@ -1410,6 +1410,7 @@
        'WantedTemplatesPage' => __DIR__ . 
'/includes/specials/SpecialWantedtemplates.php',
        'WatchAction' => __DIR__ . '/includes/actions/WatchAction.php',
        'WatchedItem' => __DIR__ . '/includes/WatchedItem.php',
+       'WatchedItemQuery' => __DIR__ . '/includes/WatchedItemQuery.php',
        'WatchedItemStore' => __DIR__ . '/includes/WatchedItemStore.php',
        'WatchlistCleanup' => __DIR__ . '/maintenance/cleanupWatchlist.php',
        'WebInstaller' => __DIR__ . '/includes/installer/WebInstaller.php',
diff --git a/includes/WatchedItemQuery.php b/includes/WatchedItemQuery.php
new file mode 100644
index 0000000..9e33ef5
--- /dev/null
+++ b/includes/WatchedItemQuery.php
@@ -0,0 +1,413 @@
+<?php
+
+use Wikimedia\Assert\Assert;
+
+class WatchedItemQuery {
+
+       const DIR_OLDER = 'older';
+       const DIR_NEWER = 'newer';
+
+       const INCLUDE_FLAGS = 'flags';
+       const INCLUDE_USER = 'user';
+       const INCLUDE_USER_ID = 'userid';
+       const INCLUDE_COMMENT = 'comment';
+       const INCLUDE_PATROL_INFO = 'patrol';
+       const INCLUDE_SIZES = 'sizes';
+       const INCLUDE_LOG_INFO = 'loginfo';
+
+       const FILTER_MINOR = 'minor';
+       const FILTER_NOT_MINOR = '!minor';
+       const FILTER_BOT = 'bot';
+       const FILTER_NOT_BOT = '!bot';
+       const FILTER_ANON = 'anon';
+       const FILTER_NOT_ANON = '!anon';
+       const FILTER_PATROLLED = 'patrolled';
+       const FILTER_NOT_PATROLLED = '!patrolled';
+       const FILTER_UNREAD = 'unread';
+       const FILTER_NOT_UNREAD = '!unread';
+
+       /**
+        * @var IDatabase
+        */
+       private $db;
+
+       public function __construct( IDatabase $db ) {
+               $this->db = $db;
+       }
+
+       /**
+        * @param User $user
+        * @param array $options Allowed keys:
+        *        'includeFields' => string[] RecentChange fields to be 
included in the result,
+        *        self::INCLUDE_* constants should be used
+        *        'filters' => string[] optional filters to narrow down 
resulted items
+        *        'namespaceIds' => int[] optional namespace IDs to filter by 
(defaults to all namespaces)
+        *        'allRevisions' => bool return multiple revisions of the same 
page if true, only the most
+        *        recent if false (default)
+        *        'rcTypes' => int[] which types of RecentChanges to include 
(defaults to all types),
+        *        allowed values: RC_EDIT, RC_NEW, RC_LOG, RC_EXTERNAL, 
RC_CATEGORIZE
+        *        'onlyByUser' => string only list changes by a specified user
+        *        'notByUser' => string do not incluide changes by a specified 
user
+        *        'dir' => string in which direction to enumerate, accepted 
values:
+        *        - DIR_OLDER list newest first
+        *        - DIR_NEWER list oldest first
+        *        'start' => string (format accepted by wfTimestamp) requires 
'dir' option, timestamp to
+        *        start enumerating from
+        *        'end' => string (format accepted by wfTimestamp) requires 
'dir' option, timestamp to
+        *        end enumerating
+        *        'startFrom' => [ string $rcTimestamp, int $rcId ] requires 
'dir' option, return items
+        *        starting from the RecentChange specified by this, 
$rcTimestamp should be in the format
+        *        accepted by wfTimestamp
+        *        'watchlistOwner' => User user whose watchlist items should be 
listed if different than
+        *        the one specified with $user param, requires 
'watchlistOwnerToken' option
+        *        'watchlistOwnerToken' => string a watchlist token used to 
access another user's
+        *        watchlist, used with 'watchlistOwnerToken' option
+        *        'limit' => int maximum numbers of items to return
+        *        'usedInGenerator' => bool include only RecentChange id field 
required by the generator
+        *        ('rc_cur_id' or 'rc_this_oldid') if true, or all id fields 
('rc_cur_id', 'rc_this_oldid',
+        *        'rc_last_oldid') if false (default)
+        * @return array of pairs ( WatchedItem $watchedItem, string[] 
$recentChangeInfo ),
+        *         where $recentChangeInfo contains the following keys:
+        *         - 'rc_id',
+        *         - 'rc_namespace',
+        *         - 'rc_title',
+        *         - 'rc_timestamp',
+        *         - 'rc_type',
+        *         - 'rc_deleted',
+        *         Additional keys could be added by specifying the 
'includeFields' option
+        */
+       public function getWatchedItemsWithRecentChangeInfo( User $user, array 
$options = [] ) {
+               $options += [ 'includeFields' => [] ];
+               $options += [ 'namespaceIds' => [] ];
+               $options += [ 'filters' => [] ];
+               $options += [ 'allRevisions' => false ];
+               $options += [ 'usedInGenerator' => false ];
+
+               Assert::parameter(
+                       !isset( $options['rcTypes'] )
+                               || !array_diff( $options['rcTypes'], [ RC_EDIT, 
RC_NEW, RC_LOG, RC_EXTERNAL, RC_CATEGORIZE ] ),
+                       '$options[\'rcTypes\']',
+                       'must be an array containing only: RC_EDIT, RC_NEW, 
RC_LOG, RC_EXTERNAL and/or RC_CATEGORIZE'
+               );
+               Assert::parameter(
+                       !isset( $options['dir'] ) || in_array( $options['dir'], 
[ self::DIR_OLDER, self::DIR_NEWER ] ),
+                       '$options[\'dir\']',
+                       'must be DIR_OLDER or DIR_NEWER'
+               );
+               Assert::parameter(
+                       !isset( $options['start'] ) && !isset( $options['end'] 
) && !isset( $options['startFrom'] )
+                               || isset( $options['dir'] ),
+                       '$options[\'dir\']',
+                       'must be provided when providing any of options: start, 
end, startFrom'
+               );
+               Assert::parameter(
+                       !isset( $options['startFrom'] )
+                               || ( is_array( $options['startFrom'] ) && 
count( $options['startFrom'] ) === 2 ),
+                       '$options[\'startFrom\']',
+                       'must be a two-element array'
+               );
+               if ( array_key_exists( 'watchlistOwner', $options ) ) {
+                       Assert::parameterType(
+                               User::class,
+                               $options['watchlistOwner'],
+                               '$options[\'watchlistOwner\']'
+                       );
+                       Assert::parameter(
+                               isset( $options['watchlistOwnerToken'] ),
+                               '$options[\'watchlistOwnerToken\']',
+                               'must be provided when providing watchlistOwner 
option'
+                       );
+               }
+
+               $tables = [ 'recentchanges', 'watchlist' ];
+               if ( !$options['allRevisions'] ) {
+                       $tables[] = 'page';
+               }
+
+               $fields = $this->getFields( $options );
+               $conds = $this->getConds( $user, $options );
+               $dbOptions = $this->getDbOptions( $options );
+               $joinConds = $this->getJoinConds( $options );
+
+               $res = $this->db->select(
+                       $tables,
+                       $fields,
+                       $conds,
+                       __METHOD__,
+                       $dbOptions,
+                       $joinConds
+               );
+
+               $items = [];
+               foreach ( $res as $row ) {
+                       $items[] = [
+                               new WatchedItem(
+                                       $user,
+                                       new TitleValue( 
(int)$row->rc_namespace, $row->rc_title ),
+                                       $row->wl_notificationtimestamp
+                               ),
+                               $this->getRecentChangeFieldsFromRow( $row )
+                       ];
+               }
+
+               return $items;
+       }
+
+       private function getRecentChangeFieldsFromRow( stdClass $row ) {
+               return array_filter(
+                       get_object_vars( $row ),
+                       function( $key ) {
+                               return substr( $key, 0, 3 ) === 'rc_';
+                       },
+                       ARRAY_FILTER_USE_KEY
+               );
+       }
+
+       private function getFields( array $options ) {
+               $fields = [
+                       'rc_id',
+                       'rc_namespace',
+                       'rc_title',
+                       'rc_timestamp',
+                       'rc_type',
+                       'rc_deleted',
+                       'wl_notificationtimestamp'
+               ];
+
+               $rcIdFields = [
+                       'rc_cur_id',
+                       'rc_this_oldid',
+                       'rc_last_oldid',
+               ];
+               if ( $options['usedInGenerator'] ) {
+                       if ( $options['allRevisions'] ) {
+                               $rcIdFields = [ 'rc_this_oldid' ];
+                       } else {
+                               $rcIdFields = [ 'rc_cur_id' ];
+                       }
+               }
+               $fields = array_merge( $fields, $rcIdFields );
+
+               if ( in_array( self::INCLUDE_FLAGS, $options['includeFields'] ) 
) {
+                       $fields = array_merge( $fields, [ 'rc_type', 
'rc_minor', 'rc_bot' ] );
+               }
+               if ( in_array( self::INCLUDE_USER, $options['includeFields'] ) 
) {
+                       $fields[] = 'rc_user_text';
+               }
+               if ( in_array( self::INCLUDE_USER_ID, $options['includeFields'] 
) ) {
+                       $fields[] = 'rc_user';
+               }
+               if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] 
) ) {
+                       $fields[] = 'rc_comment';
+               }
+               if ( in_array( self::INCLUDE_PATROL_INFO, 
$options['includeFields'] ) ) {
+                       $fields = array_merge( $fields, [ 'rc_patrolled', 
'rc_log_type' ] );
+               }
+               if ( in_array( self::INCLUDE_SIZES, $options['includeFields'] ) 
) {
+                       $fields = array_merge( $fields, [ 'rc_old_len', 
'rc_new_len' ] );
+               }
+               if ( in_array( self::INCLUDE_LOG_INFO, 
$options['includeFields'] ) ) {
+                       $fields = array_merge( $fields, [ 'rc_logid', 
'rc_log_type', 'rc_log_action', 'rc_params' ] );
+               }
+
+               return $fields;
+       }
+
+       private function getConds( User $user, array $options ) {
+               $watchlistOwnerId = $this->getWatchlistOwnerId( $user, $options 
);
+               $conds = [ 'wl_user' => $watchlistOwnerId ];
+
+               if ( !$options['allRevisions'] ) {
+                       $conds[] = $this->db->makeList(
+                               [ 'rc_this_oldid=page_latest', 'rc_type=' . 
RC_LOG ],
+                               LIST_OR
+                       );
+               }
+
+               if ( $options['namespaceIds'] ) {
+                       $conds['wl_namespace'] = array_map( 'intval', 
$options['namespaceIds'] );
+               }
+
+               if ( array_key_exists( 'rcTypes', $options ) ) {
+                       $conds['rc_type'] = array_map( 'intval',  
$options['rcTypes'] );
+               }
+
+               $conds = array_merge( $conds, $this->getFilterConds( $user, 
$options ) );
+
+               if ( isset( $options['start'] ) ) {
+                       $after = $options['dir'] === self::DIR_OLDER ? '<=' : 
'>=';
+                       $conds[] = 'rc_timestamp ' . $after . ' ' . 
$this->db->addQuotes( $options['start'] );
+               }
+               if ( isset( $options['end'] ) ) {
+                       $before = $options['dir'] === self::DIR_OLDER ? '>=' : 
'<=';
+                       $conds[] = 'rc_timestamp ' . $before . ' ' . 
$this->db->addQuotes( $options['end'] );
+               }
+
+               if ( !isset( $options['start'] ) && !isset( $options['end'] ) ) 
{
+                       if ( $this->db->getType() === 'mysql' ) {
+                               // This is an index optimization for mysql
+                               $conds[] = "rc_timestamp > ''";
+                       }
+               }
+
+               if ( array_key_exists( 'onlyByUser', $options ) || 
array_key_exists( 'notByUser', $options ) ) {
+                       $conds = array_merge( $conds, 
$this->getUserRelatedConds( $user, $options ) );
+               }
+
+               $deletedPageLogCond = 
$this->getExtraDeletedPageLogEntryRelatedCond( $user );
+               if ( $deletedPageLogCond ) {
+                       $conds[] = $deletedPageLogCond;
+               }
+
+               if ( array_key_exists( 'startFrom', $options ) ) {
+                       $op = $options['dir'] === self::DIR_OLDER ? '<' : '>';
+                       list( $rcTimestamp, $rcId ) = $options['startFrom'];
+                       $rcTimestamp = $this->db->addQuotes( 
$this->db->timestamp( $rcTimestamp ) );
+                       $rcId = (int)$rcId;
+                       $conds[] = $this->db->makeList(
+                               [
+                                       "rc_timestamp $op $rcTimestamp",
+                                       $this->db->makeList(
+                                               [
+                                                       "rc_timestamp = 
$rcTimestamp",
+                                                       "rc_id $op= $rcId"
+                                               ],
+                                               LIST_AND
+                                       )
+                               ],
+                               LIST_OR
+                       );
+               }
+
+               return $conds;
+       }
+
+       private function getWatchlistOwnerId( User $user, array $options ) {
+               if ( array_key_exists( 'watchlistOwner', $options ) ) {
+                       /** @var User $watchlistOwner */
+                       $watchlistOwner = $options['watchlistOwner'];
+                       $ownersToken = $watchlistOwner->getOption( 
'watchlisttoken' );
+                       $token = $options['watchlistOwnerToken'];
+                       if ( $ownersToken == '' || !hash_equals( $ownersToken, 
$token ) ) {
+                               throw new UsageException(
+                                       'Incorrect watchlist token provided -- 
please set a correct token in Special:Preferences',
+                                       'bad_wltoken'
+                               );
+                       }
+                       return $watchlistOwner->getId();
+               }
+               return $user->getId();
+       }
+
+       private function getFilterConds( User $user, array $options ) {
+               $conds = [];
+
+               if ( in_array( self::FILTER_MINOR, $options['filters'] ) ) {
+                       $conds[] = 'rc_minor != 0';
+               } elseif ( in_array( self::FILTER_NOT_MINOR, 
$options['filters'] ) ) {
+                       $conds[] = 'rc_minor = 0';
+               }
+
+               if ( in_array( self::FILTER_BOT, $options['filters'] ) ) {
+                       $conds[] = 'rc_bot != 0';
+               } elseif ( in_array( self::FILTER_NOT_BOT, $options['filters'] 
) ) {
+                       $conds[] = 'rc_bot = 0';
+               }
+
+               if ( in_array( self::FILTER_ANON, $options['filters'] ) ) {
+                       $conds[] = 'rc_user = 0';
+               } elseif ( in_array( self::FILTER_NOT_ANON, $options['filters'] 
) ) {
+                       $conds[] = 'rc_user != 0';
+               }
+
+               if ( $user->useRCPatrol() || $user->useNPPatrol() ) {
+                       // TODO: not sure if this should simply ignore 
patrolled filters if user does not have the patrol
+                       // right, or maybe rather fail loud at this point, same 
as e.g. ApiQueryWatchlist does?
+                       if ( in_array( self::FILTER_PATROLLED, 
$options['filters'] ) ) {
+                               $conds[] = 'rc_patrolled != 0';
+                       } elseif ( in_array( self::FILTER_NOT_PATROLLED, 
$options['filters'] ) ) {
+                               $conds[] = 'rc_patrolled = 0';
+                       }
+               }
+
+               if ( in_array( self::FILTER_UNREAD, $options['filters'] ) ) {
+                       $conds[] = 'rc_timestamp >= wl_notificationtimestamp';
+               } elseif ( in_array( self::FILTER_NOT_UNREAD, 
$options['filters'] ) ) {
+                       // TODO: should this be changed to use 
Database::makeList?
+                       $conds[] = 'wl_notificationtimestamp IS NULL OR 
rc_timestamp < wl_notificationtimestamp';
+               }
+
+               return $conds;
+       }
+       private function getUserRelatedConds( User $user, array $options ) {
+               $conds = [];
+
+               if ( array_key_exists( 'onlyByUser', $options ) ) {
+                       $conds['rc_user_text'] = $options['onlyByUser'];
+               } elseif ( array_key_exists( 'notByUser', $options ) ) {
+                       $conds[] = 'rc_user_text != ' . $this->db->addQuotes( 
$options['notByUser'] );
+               }
+
+               // Avoid brute force searches (bug 17342)
+               $bitmask = 0;
+               if ( !$user->isAllowed( 'deletedhistory' ) ) {
+                       $bitmask = Revision::DELETED_USER;
+               } elseif ( !$user->isAllowedAny( 'suppressrevision', 
'viewsuppressed' ) ) {
+                       $bitmask = Revision::DELETED_USER | 
Revision::DELETED_RESTRICTED;
+               }
+               if ( $bitmask ) {
+                       $conds[] = $this->db->bitAnd( 'rc_deleted', $bitmask ) 
. " != $bitmask";
+               }
+
+               return $conds;
+       }
+
+       private function getExtraDeletedPageLogEntryRelatedCond( User $user ) {
+               // LogPage::DELETED_ACTION hides the affected page, too. So 
hide those
+               // entirely from the watchlist, or someone could guess the 
title.
+               $bitmask = 0;
+               if ( !$user->isAllowed( 'deletedhistory' ) ) {
+                       $bitmask = LogPage::DELETED_ACTION;
+               } elseif ( !$user->isAllowedAny( 'suppressrevision', 
'viewsuppressed' ) ) {
+                       $bitmask = LogPage::DELETED_ACTION | 
LogPage::DELETED_RESTRICTED;
+               }
+               if ( $bitmask ) {
+                       return $this->db->makeList( [
+                               'rc_type != ' . RC_LOG,
+                               $this->db->bitAnd( 'rc_deleted', $bitmask ) . " 
!= $bitmask",
+                       ], LIST_OR );
+               }
+               return '';
+       }
+
+       private function getDbOptions( array $options ) {
+               $dbOptions = [];
+
+               if ( array_key_exists( 'dir', $options ) ) {
+                       $sort = $options['dir'] === self::DIR_OLDER ? ' DESC' : 
'';
+                       $dbOptions['ORDER BY'] = [ 'rc_timestamp' . $sort, 
'rc_id' . $sort ];
+               }
+
+               if ( array_key_exists( 'limit', $options ) ) {
+                       $dbOptions['LIMIT'] = (int)$options['limit'];
+               }
+
+               return $dbOptions;
+       }
+
+       private function getJoinConds( array $options ) {
+               $joinConds = [
+                       'watchlist' => [ 'INNER JOIN',
+                               [
+                                       'wl_namespace=rc_namespace',
+                                       'wl_title=rc_title'
+                               ]
+                       ]
+               ];
+               if ( !$options['allRevisions'] ) {
+                       $joinConds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' 
];
+               }
+               return $joinConds;
+       }
+
+}
diff --git a/includes/WatchedItemStore.php b/includes/WatchedItemStore.php
index 2b91965..1a4c17c 100644
--- a/includes/WatchedItemStore.php
+++ b/includes/WatchedItemStore.php
@@ -515,6 +515,14 @@
                return $watchedItems;
        }
 
+       public function getWatchedItemsWithRecentChangeInfo( User $user, array 
$options = [] ) {
+               $db = $this->getConnection( DB_SLAVE );
+               $watchedItemQuery = new WatchedItemQuery( $db );
+               $items = 
$watchedItemQuery->getWatchedItemsWithRecentChangeInfo( $user, $options );
+               $this->reuseConnection( $db );
+               return $items;
+       }
+
        /**
         * Must be called separately for Subject & Talk namespaces
         *
diff --git a/includes/api/ApiQueryWatchlist.php 
b/includes/api/ApiQueryWatchlist.php
index db2cf86..a16d999 100644
--- a/includes/api/ApiQueryWatchlist.php
+++ b/includes/api/ApiQueryWatchlist.php
@@ -24,6 +24,8 @@
  * @file
  */
 
+use MediaWiki\MediaWikiServices;
+
 /**
  * This query action allows clients to retrieve a list of recently modified 
pages
  * that are part of the logged-in user's watchlist.
@@ -85,79 +87,43 @@
                        }
                }
 
-               $this->addFields( [
-                       'rc_id',
-                       'rc_namespace',
-                       'rc_title',
-                       'rc_timestamp',
-                       'rc_type',
-                       'rc_deleted',
-               ] );
+               $options = [
+                       'dir' => $params['dir'] === 'older' ? 
WatchedItemQuery::DIR_OLDER : WatchedItemQuery::DIR_NEWER,
+               ];
 
                if ( is_null( $resultPageSet ) ) {
-                       $this->addFields( [
-                               'rc_cur_id',
-                               'rc_this_oldid',
-                               'rc_last_oldid',
-                       ] );
-
-                       $this->addFieldsIf( [ 'rc_type', 'rc_minor', 'rc_bot' 
], $this->fld_flags );
-                       $this->addFieldsIf( 'rc_user', $this->fld_user || 
$this->fld_userid );
-                       $this->addFieldsIf( 'rc_user_text', $this->fld_user );
-                       $this->addFieldsIf( 'rc_comment', $this->fld_comment || 
$this->fld_parsedcomment );
-                       $this->addFieldsIf( [ 'rc_patrolled', 'rc_log_type' ], 
$this->fld_patrol );
-                       $this->addFieldsIf( [ 'rc_old_len', 'rc_new_len' ], 
$this->fld_sizes );
-                       $this->addFieldsIf( 'wl_notificationtimestamp', 
$this->fld_notificationtimestamp );
-                       $this->addFieldsIf(
-                               [ 'rc_logid', 'rc_log_type', 'rc_log_action', 
'rc_params' ],
-                               $this->fld_loginfo
-                       );
-               } elseif ( $params['allrev'] ) {
-                       $this->addFields( 'rc_this_oldid' );
+                       $options['includeFields'] = $this->getFieldsToInclude();
                } else {
-                       $this->addFields( 'rc_cur_id' );
+                       $options['usedInGenerator'] = true;
                }
 
-               $this->addTables( [
-                       'recentchanges',
-                       'watchlist',
-               ] );
-
-               $userId = $wlowner->getId();
-               $this->addJoinConds( [ 'watchlist' => [ 'INNER JOIN',
-                       [
-                               'wl_user' => $userId,
-                               'wl_namespace=rc_namespace',
-                               'wl_title=rc_title'
-                       ]
-               ] ] );
-
-               $db = $this->getDB();
-
-               $this->addTimestampWhereRange( 'rc_timestamp', $params['dir'],
-                       $params['start'], $params['end'] );
-               // Include in ORDER BY for uniqueness
-               $this->addWhereRange( 'rc_id', $params['dir'], null, null );
+               if ( $params['start'] ) {
+                       $options['start'] = $params['start'];
+               }
+               if ( $params['end'] ) {
+                       $options['end'] = $params['end'];
+               }
 
                if ( !is_null( $params['continue'] ) ) {
                        $cont = explode( '|', $params['continue'] );
                        $this->dieContinueUsageIf( count( $cont ) != 2 );
-                       $op = ( $params['dir'] === 'newer' ? '>' : '<' );
-                       $continueTimestamp = $db->addQuotes( $db->timestamp( 
$cont[0] ) );
+                       $continueTimestamp = $cont[0];
                        $continueId = (int)$cont[1];
                        $this->dieContinueUsageIf( $continueId != $cont[1] );
-                       $this->addWhere( "rc_timestamp $op $continueTimestamp 
OR " .
-                               "(rc_timestamp = $continueTimestamp AND " .
-                               "rc_id $op= $continueId)"
-                       );
+                       $options['startFrom'] = [ $continueTimestamp, 
$continueId ];
                }
 
-               $this->addWhereFld( 'wl_namespace', $params['namespace'] );
+               if ( $wlowner !== $user ) {
+                       $options['watchlistOwner'] = $wlowner;
+                       $options['watchlistOwnerToken'] = $params['token'];
+               }
 
-               if ( !$params['allrev'] ) {
-                       $this->addTables( 'page' );
-                       $this->addJoinConds( [ 'page' => [ 'LEFT JOIN', 
'rc_cur_id=page_id' ] ] );
-                       $this->addWhere( 'rc_this_oldid=page_latest OR 
rc_type=' . RC_LOG );
+               if ( !is_null( $params['namespace'] ) ) {
+                       $options['namespaceIds'] = $params['namespace'];
+               }
+
+               if ( $params['allrev'] ) {
+                       $options['allRevisions'] = true;
                }
 
                if ( !is_null( $params['show'] ) ) {
@@ -183,25 +149,12 @@
                                }
                        }
 
-                       /* Add additional conditions to query depending upon 
parameters. */
-                       $this->addWhereIf( 'rc_minor = 0', isset( 
$show['!minor'] ) );
-                       $this->addWhereIf( 'rc_minor != 0', isset( 
$show['minor'] ) );
-                       $this->addWhereIf( 'rc_bot = 0', isset( $show['!bot'] ) 
);
-                       $this->addWhereIf( 'rc_bot != 0', isset( $show['bot'] ) 
);
-                       $this->addWhereIf( 'rc_user = 0', isset( $show['anon'] 
) );
-                       $this->addWhereIf( 'rc_user != 0', isset( 
$show['!anon'] ) );
-                       $this->addWhereIf( 'rc_patrolled = 0', isset( 
$show['!patrolled'] ) );
-                       $this->addWhereIf( 'rc_patrolled != 0', isset( 
$show['patrolled'] ) );
-                       $this->addWhereIf( 'rc_timestamp >= 
wl_notificationtimestamp', isset( $show['unread'] ) );
-                       $this->addWhereIf(
-                               'wl_notificationtimestamp IS NULL OR 
rc_timestamp < wl_notificationtimestamp',
-                               isset( $show['!unread'] )
-                       );
+                       $options['filters'] = $this->getFilters( $show );
                }
 
                if ( !is_null( $params['type'] ) ) {
                        try {
-                               $this->addWhereFld( 'rc_type', 
RecentChange::parseToRCType( $params['type'] ) );
+                               $options['rcTypes'] = 
RecentChange::parseToRCType( $params['type'] );
                        } catch ( Exception $e ) {
                                ApiBase::dieDebug( __METHOD__, $e->getMessage() 
);
                        }
@@ -211,74 +164,45 @@
                        $this->dieUsage( 'user and excludeuser cannot be used 
together', 'user-excludeuser' );
                }
                if ( !is_null( $params['user'] ) ) {
-                       $this->addWhereFld( 'rc_user_text', $params['user'] );
+                       $options['onlyByUser'] = $params['user'];
                }
                if ( !is_null( $params['excludeuser'] ) ) {
-                       $this->addWhere( 'rc_user_text != ' . $db->addQuotes( 
$params['excludeuser'] ) );
+                       $options['notByUser'] = $params['excludeuser'];
                }
 
-               // This is an index optimization for mysql, as done in the 
Special:Watchlist page
-               $this->addWhereIf(
-                       "rc_timestamp > ''",
-                       !isset( $params['start'] ) && !isset( $params['end'] ) 
&& $db->getType() == 'mysql'
-               );
-
-               // Paranoia: avoid brute force searches (bug 17342)
-               if ( !is_null( $params['user'] ) || !is_null( 
$params['excludeuser'] ) ) {
-                       if ( !$user->isAllowed( 'deletedhistory' ) ) {
-                               $bitmask = Revision::DELETED_USER;
-                       } elseif ( !$user->isAllowedAny( 'suppressrevision', 
'viewsuppressed' ) ) {
-                               $bitmask = Revision::DELETED_USER | 
Revision::DELETED_RESTRICTED;
-                       } else {
-                               $bitmask = 0;
-                       }
-                       if ( $bitmask ) {
-                               $this->addWhere( $this->getDB()->bitAnd( 
'rc_deleted', $bitmask ) . " != $bitmask" );
-                       }
-               }
-
-               // LogPage::DELETED_ACTION hides the affected page, too. So 
hide those
-               // entirely from the watchlist, or someone could guess the 
title.
-               if ( !$user->isAllowed( 'deletedhistory' ) ) {
-                       $bitmask = LogPage::DELETED_ACTION;
-               } elseif ( !$user->isAllowedAny( 'suppressrevision', 
'viewsuppressed' ) ) {
-                       $bitmask = LogPage::DELETED_ACTION | 
LogPage::DELETED_RESTRICTED;
-               } else {
-                       $bitmask = 0;
-               }
-               if ( $bitmask ) {
-                       $this->addWhere( $this->getDB()->makeList( [
-                               'rc_type != ' . RC_LOG,
-                               $this->getDB()->bitAnd( 'rc_deleted', $bitmask 
) . " != $bitmask",
-                       ], LIST_OR ) );
-               }
-
-               $this->addOption( 'LIMIT', $params['limit'] + 1 );
+               $options['limit'] = $params['limit'] + 1;
 
                $ids = [];
                $count = 0;
-               $res = $this->select( __METHOD__ );
+               $store = 
MediaWikiServices::getInstance()->getWatchedItemStore();
+               $items = $store->getWatchedItemsWithRecentChangeInfo( $wlowner, 
$options );
 
-               foreach ( $res as $row ) {
+               foreach ( $items as list ( $watchedItem, $recentChangeInfo ) ) {
                        if ( ++$count > $params['limit'] ) {
                                // We've reached the one extra which shows that 
there are
                                // additional pages to be had. Stop here...
-                               $this->setContinueEnumParameter( 'continue', 
"$row->rc_timestamp|$row->rc_id" );
+                               $this->setContinueEnumParameter(
+                                       'continue',
+                                       $recentChangeInfo['rc_timestamp'] . '|' 
. $recentChangeInfo['rc_id']
+                               );
                                break;
                        }
 
                        if ( is_null( $resultPageSet ) ) {
-                               $vals = $this->extractRowInfo( $row );
+                               $vals = $this->extractOutputData( $watchedItem, 
$recentChangeInfo );
                                $fit = $this->getResult()->addValue( [ 'query', 
$this->getModuleName() ], null, $vals );
                                if ( !$fit ) {
-                                       $this->setContinueEnumParameter( 
'continue', "$row->rc_timestamp|$row->rc_id" );
+                                       $this->setContinueEnumParameter(
+                                               'continue',
+                                               
$recentChangeInfo['rc_timestamp'] . '|' . $recentChangeInfo['rc_id']
+                                       );
                                        break;
                                }
                        } else {
                                if ( $params['allrev'] ) {
-                                       $ids[] = intval( $row->rc_this_oldid );
+                                       $ids[] = intval( 
$recentChangeInfo['rc_this_oldid'] );
                                } else {
-                                       $ids[] = intval( $row->rc_cur_id );
+                                       $ids[] = intval( 
$recentChangeInfo['rc_cur_id'] );
                                }
                        }
                }
@@ -295,56 +219,127 @@
                }
        }
 
-       private function extractRowInfo( $row ) {
+       private function getFieldsToInclude() {
+               $includeFields = [];
+               if ( $this->fld_flags ) {
+                       $includeFields[] = WatchedItemQuery::INCLUDE_FLAGS;
+               }
+               if ( $this->fld_user || $this->fld_userid ) {
+                       $includeFields[] = WatchedItemQuery::INCLUDE_USER_ID;
+               }
+               if ( $this->fld_user ) {
+                       $includeFields[] = WatchedItemQuery::INCLUDE_USER;
+               }
+               if ( $this->fld_comment || $this->fld_parsedcomment ) {
+                       $includeFields[] = WatchedItemQuery::INCLUDE_COMMENT;
+               }
+               if ( $this->fld_patrol ) {
+                       $includeFields[] = 
WatchedItemQuery::INCLUDE_PATROL_INFO;
+               }
+               if ( $this->fld_sizes ) {
+                       $includeFields[] = WatchedItemQuery::INCLUDE_SIZES;
+               }
+               if ( $this->fld_loginfo ) {
+                       $includeFields[] = WatchedItemQuery::INCLUDE_SIZES;
+               }
+               return $includeFields;
+       }
+
+       private function getFilters( array $show ) {
+               $filters = [];
+               if ( isset( $show['!minor'] ) ) {
+                       $filters[] = WatchedItemQuery::FILTER_NOT_MINOR;
+               }
+               if ( isset( $show['minor'] ) ) {
+                       $filters[] = WatchedItemQuery::FILTER_MINOR;
+               }
+               if ( isset( $show['!bot'] ) ) {
+                       $filters[] = WatchedItemQuery::FILTER_NOT_BOT;
+               }
+               if ( isset( $show['bot'] ) ) {
+                       $filters[] = WatchedItemQuery::FILTER_BOT;
+               }
+               if ( isset( $show['anon'] ) ) {
+                       $filters[] = WatchedItemQuery::FILTER_ANON;
+               }
+               if ( isset( $show['!anon'] ) ) {
+                       $filters[] = WatchedItemQuery::FILTER_NOT_ANON;
+               }
+               if ( isset( $show['!patrolled'] ) ) {
+                       $filters[] = WatchedItemQuery::FILTER_NOT_PATROLLED;
+               }
+               if ( isset( $show['patrolled'] ) ) {
+                       $filters[] = WatchedItemQuery::FILTER_PATROLLED;
+               }
+               if ( isset( $show['unread'] ) ) {
+                       $filters[] = WatchedItemQuery::FILTER_UNREAD;
+               }
+               if ( isset( $show['!unread'] ) ) {
+                       $filters[] = WatchedItemQuery::FILTER_NOT_UNREAD;
+               }
+               return $filters;
+       }
+       private function extractOutputData( WatchedItem $watchedItem, array 
$recentChangeInfo ) {
                /* Determine the title of the page that has been changed. */
-               $title = Title::makeTitle( $row->rc_namespace, $row->rc_title );
+               $title = Title::makeTitle(
+                       $watchedItem->getLinkTarget()->getNamespace(),
+                       $watchedItem->getLinkTarget()->getDBkey()
+               );
                $user = $this->getUser();
 
                /* Our output data. */
                $vals = [];
-               $type = intval( $row->rc_type );
+               $type = intval( $recentChangeInfo['rc_type'] );
                $vals['type'] = RecentChange::parseFromRCType( $type );
                $anyHidden = false;
 
                /* Create a new entry in the result for the title. */
                if ( $this->fld_title || $this->fld_ids ) {
                        // These should already have been filtered out of the 
query, but just in case.
-                       if ( $type === RC_LOG && ( $row->rc_deleted & 
LogPage::DELETED_ACTION ) ) {
+                       if ( $type === RC_LOG && ( 
$recentChangeInfo['rc_deleted'] & LogPage::DELETED_ACTION ) ) {
                                $vals['actionhidden'] = true;
                                $anyHidden = true;
                        }
                        if ( $type !== RC_LOG ||
-                               LogEventsList::userCanBitfield( 
$row->rc_deleted, LogPage::DELETED_ACTION, $user )
+                               LogEventsList::userCanBitfield(
+                                       $recentChangeInfo['rc_deleted'],
+                                       LogPage::DELETED_ACTION,
+                                       $user
+                               )
                        ) {
                                if ( $this->fld_title ) {
                                        ApiQueryBase::addTitleInfo( $vals, 
$title );
                                }
                                if ( $this->fld_ids ) {
-                                       $vals['pageid'] = intval( 
$row->rc_cur_id );
-                                       $vals['revid'] = intval( 
$row->rc_this_oldid );
-                                       $vals['old_revid'] = intval( 
$row->rc_last_oldid );
+                                       $vals['pageid'] = intval( 
$recentChangeInfo['rc_cur_id'] );
+                                       $vals['revid'] = intval( 
$recentChangeInfo['rc_this_oldid'] );
+                                       $vals['old_revid'] = intval( 
$recentChangeInfo['rc_last_oldid'] );
                                }
                        }
                }
 
                /* Add user data and 'anon' flag, if user is anonymous. */
                if ( $this->fld_user || $this->fld_userid ) {
-                       if ( $row->rc_deleted & Revision::DELETED_USER ) {
+                       if ( $recentChangeInfo['rc_deleted'] & 
Revision::DELETED_USER ) {
                                $vals['userhidden'] = true;
                                $anyHidden = true;
                        }
-                       if ( Revision::userCanBitfield( $row->rc_deleted, 
Revision::DELETED_USER, $user ) ) {
+                       if ( Revision::userCanBitfield(
+                               $recentChangeInfo['rc_deleted'],
+                               Revision::DELETED_USER,
+                               $user
+                       ) ) {
                                if ( $this->fld_userid ) {
-                                       $vals['userid'] = (int)$row->rc_user;
+                                       $vals['userid'] = 
(int)$recentChangeInfo['rc_user'];
                                        // for backwards compatibility
-                                       $vals['user'] = (int)$row->rc_user;
+                                       $vals['user'] = 
(int)$recentChangeInfo['rc_user'];
                                }
 
                                if ( $this->fld_user ) {
-                                       $vals['user'] = $row->rc_user_text;
+                                       $vals['user'] = 
$recentChangeInfo['rc_user_text'];
                                }
 
-                               if ( !$row->rc_user ) {
+                               if ( !$recentChangeInfo['rc_user'] ) {
                                        $vals['anon'] = true;
                                }
                        }
@@ -352,65 +347,73 @@
 
                /* Add flags, such as new, minor, bot. */
                if ( $this->fld_flags ) {
-                       $vals['bot'] = (bool)$row->rc_bot;
-                       $vals['new'] = $row->rc_type == RC_NEW;
-                       $vals['minor'] = (bool)$row->rc_minor;
+                       $vals['bot'] = (bool)$recentChangeInfo['rc_bot'];
+                       $vals['new'] = $recentChangeInfo['rc_type'] == RC_NEW;
+                       $vals['minor'] = (bool)$recentChangeInfo['rc_minor'];
                }
 
                /* Add sizes of each revision. (Only available on 1.10+) */
                if ( $this->fld_sizes ) {
-                       $vals['oldlen'] = intval( $row->rc_old_len );
-                       $vals['newlen'] = intval( $row->rc_new_len );
+                       $vals['oldlen'] = intval( 
$recentChangeInfo['rc_old_len'] );
+                       $vals['newlen'] = intval( 
$recentChangeInfo['rc_new_len'] );
                }
 
                /* Add the timestamp. */
                if ( $this->fld_timestamp ) {
-                       $vals['timestamp'] = wfTimestamp( TS_ISO_8601, 
$row->rc_timestamp );
+                       $vals['timestamp'] = wfTimestamp( TS_ISO_8601, 
$recentChangeInfo['rc_timestamp'] );
                }
 
                if ( $this->fld_notificationtimestamp ) {
-                       $vals['notificationtimestamp'] = ( 
$row->wl_notificationtimestamp == null )
+                       $vals['notificationtimestamp'] = ( 
$watchedItem->getNotificationTimestamp() == null )
                                ? ''
-                               : wfTimestamp( TS_ISO_8601, 
$row->wl_notificationtimestamp );
+                               : wfTimestamp( TS_ISO_8601, 
$watchedItem->getNotificationTimestamp() );
                }
 
                /* Add edit summary / log summary. */
                if ( $this->fld_comment || $this->fld_parsedcomment ) {
-                       if ( $row->rc_deleted & Revision::DELETED_COMMENT ) {
+                       if ( $recentChangeInfo['rc_deleted'] & 
Revision::DELETED_COMMENT ) {
                                $vals['commenthidden'] = true;
                                $anyHidden = true;
                        }
-                       if ( Revision::userCanBitfield( $row->rc_deleted, 
Revision::DELETED_COMMENT, $user ) ) {
-                               if ( $this->fld_comment && isset( 
$row->rc_comment ) ) {
-                                       $vals['comment'] = $row->rc_comment;
+                       if ( Revision::userCanBitfield(
+                               $recentChangeInfo['rc_deleted'],
+                               Revision::DELETED_COMMENT,
+                               $user
+                       ) ) {
+                               if ( $this->fld_comment && isset( 
$recentChangeInfo['rc_comment'] ) ) {
+                                       $vals['comment'] = 
$recentChangeInfo['rc_comment'];
                                }
 
-                               if ( $this->fld_parsedcomment && isset( 
$row->rc_comment ) ) {
-                                       $vals['parsedcomment'] = 
Linker::formatComment( $row->rc_comment, $title );
+                               if ( $this->fld_parsedcomment && isset( 
$recentChangeInfo['rc_comment'] ) ) {
+                                       $vals['parsedcomment'] = 
Linker::formatComment( $recentChangeInfo['rc_comment'], $title );
                                }
                        }
                }
 
                /* Add the patrolled flag */
                if ( $this->fld_patrol ) {
-                       $vals['patrolled'] = $row->rc_patrolled == 1;
-                       $vals['unpatrolled'] = ChangesList::isUnpatrolled( 
$row, $user );
+                       $vals['patrolled'] = $recentChangeInfo['rc_patrolled'] 
== 1;
+                       $vals['unpatrolled'] = ChangesList::isUnpatrolled( 
(object)$recentChangeInfo, $user );
                }
 
-               if ( $this->fld_loginfo && $row->rc_type == RC_LOG ) {
-                       if ( $row->rc_deleted & LogPage::DELETED_ACTION ) {
+               if ( $this->fld_loginfo && $recentChangeInfo['rc_type'] == 
RC_LOG ) {
+                       if ( $recentChangeInfo['rc_deleted'] & 
LogPage::DELETED_ACTION ) {
                                $vals['actionhidden'] = true;
                                $anyHidden = true;
                        }
-                       if ( LogEventsList::userCanBitfield( $row->rc_deleted, 
LogPage::DELETED_ACTION, $user ) ) {
-                               $vals['logid'] = intval( $row->rc_logid );
-                               $vals['logtype'] = $row->rc_log_type;
-                               $vals['logaction'] = $row->rc_log_action;
-                               $vals['logparams'] = LogFormatter::newFromRow( 
$row )->formatParametersForApi();
+                       if ( LogEventsList::userCanBitfield(
+                               $recentChangeInfo['rc_deleted'],
+                               LogPage::DELETED_ACTION,
+                               $user
+                       ) ) {
+                               $vals['logid'] = intval( 
$recentChangeInfo['rc_logid'] );
+                               $vals['logtype'] = 
$recentChangeInfo['rc_log_type'];
+                               $vals['logaction'] = 
$recentChangeInfo['rc_log_action'];
+                               $vals['logparams'] = LogFormatter::newFromRow( 
$recentChangeInfo )->formatParametersForApi();
                        }
                }
 
-               if ( $anyHidden && ( $row->rc_deleted & 
Revision::DELETED_RESTRICTED ) ) {
+               if ( $anyHidden && ( $recentChangeInfo['rc_deleted'] & 
Revision::DELETED_RESTRICTED ) ) {
                        $vals['suppressed'] = true;
                }
 
diff --git a/tests/phpunit/includes/WatchedItemQueryUnitTest.php 
b/tests/phpunit/includes/WatchedItemQueryUnitTest.php
new file mode 100644
index 0000000..0c6aef3
--- /dev/null
+++ b/tests/phpunit/includes/WatchedItemQueryUnitTest.php
@@ -0,0 +1,990 @@
+<?php
+
+use Wikimedia\Assert\ParameterAssertionException;
+
+class WatchedItemQueryUnitTest extends PHPUnit_Framework_TestCase {
+
+       /**
+        * @return PHPUnit_Framework_MockObject_MockObject|IDatabase
+        */
+       private function getMockDb() {
+               $mock = $this->getMock( IDatabase::class );
+
+               $mock->expects( $this->any() )
+                       ->method( 'makeList' )
+                       ->with(
+                               $this->isType( 'array' ),
+                               $this->isType( 'int' )
+                       )
+                       ->will( $this->returnCallback( function( $a, $conj ) {
+                               $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR 
';
+                               return join( $sqlConj, array_map( function( $s 
) {
+                                       return '(' . $s . ')';
+                               }, $a
+                               ) );
+                       } ) );
+
+               $mock->expects( $this->any() )
+                       ->method( 'addQuotes' )
+                       ->will( $this->returnCallback( function( $value ) {
+                               return "'$value'";
+                       } ) );
+
+               $mock->expects( $this->any() )
+                       ->method( 'timestamp' )
+                       ->will( $this->returnArgument( 0 ) );
+
+               $mock->expects( $this->any() )
+                       ->method( 'bitAnd' )
+                       ->willReturnCallback( function( $a, $b ) {
+                               return "($a & $b)";
+                       } );
+
+               return $mock;
+       }
+
+       /**
+        * @param int $id
+        * @return PHPUnit_Framework_MockObject_MockObject|User
+        */
+       private function getMockNonAnonUserWithId( $id ) {
+               $mock = $this->getMock( User::class );
+               $mock->expects( $this->any() )
+                       ->method( 'isAnon' )
+                       ->will( $this->returnValue( false ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getId' )
+                       ->will( $this->returnValue( $id ) );
+               return $mock;
+       }
+
+       /**
+        * @param int $id
+        * @return PHPUnit_Framework_MockObject_MockObject|User
+        */
+       private function getMockUnrestrictedNonAnonUserWithId( $id ) {
+               $mock = $this->getMockNonAnonUserWithId( $id );
+               $mock->expects( $this->any() )
+                       ->method( 'isAllowed' )
+                       ->will( $this->returnValue( true ) );
+               $mock->expects( $this->any() )
+                       ->method( 'isAllowedAny' )
+                       ->will( $this->returnValue( true ) );
+               $mock->expects( $this->any() )
+                       ->method( 'useRCPatrol' )
+                       ->will( $this->returnValue( true ) );
+               return $mock;
+       }
+
+       /**
+        * @param int $id
+        * @param string $notAllowedAction
+        * @return PHPUnit_Framework_MockObject_MockObject|User
+        */
+       private function getMockNonAnonUserWithIdAndRestrictedPermissions( $id, 
$notAllowedAction ) {
+               $mock = $this->getMockNonAnonUserWithId( $id );
+
+               $mock->expects( $this->any() )
+                       ->method( 'isAllowed' )
+                       ->will( $this->returnCallback( function( $action ) use 
( $notAllowedAction ) {
+                               return $action !== $notAllowedAction;
+                       } ) );
+               $mock->expects( $this->any() )
+                       ->method( 'isAllowedAny' )
+                       ->will( $this->returnCallback( function() use ( 
$notAllowedAction ) {
+                               $actions = func_get_args();
+                               return !in_array( $notAllowedAction, $actions );
+                       } ) );
+
+               return $mock;
+       }
+
+       /**
+        * @param int $id
+        * @param string $notAllowedAction
+        * @return PHPUnit_Framework_MockObject_MockObject|User
+        */
+       private function getMockNonAnonUserWithIdAndNoPatrolRights( $id ) {
+               $mock = $this->getMockNonAnonUserWithId( $id );
+
+               $mock->expects( $this->any() )
+                       ->method( 'isAllowed' )
+                       ->will( $this->returnValue( true ) );
+               $mock->expects( $this->any() )
+                       ->method( 'isAllowedAny' )
+                       ->will( $this->returnValue( true ) );
+
+               $mock->expects( $this->any() )
+                       ->method( 'useRCPatrol' )
+                       ->will( $this->returnValue( false ) );
+               $mock->expects( $this->any() )
+                       ->method( 'useNPPatrol' )
+                       ->will( $this->returnValue( false ) );
+
+               return $mock;
+       }
+
+       private function getFakeRow( array $rowValues ) {
+               $fakeRow = new stdClass();
+               foreach ( $rowValues as $valueName => $value ) {
+                       $fakeRow->$valueName = $value;
+               }
+               return $fakeRow;
+       }
+
+       public function testGetWatchedItemsWithRecentChangeInfo() {
+               $mockDb = $this->getMockDb();
+               $mockDb->expects( $this->once() )
+                       ->method( 'select' )
+                       ->with(
+                               [ 'recentchanges', 'watchlist', 'page' ],
+                               [
+                                       'rc_id',
+                                       'rc_namespace',
+                                       'rc_title',
+                                       'rc_timestamp',
+                                       'rc_type',
+                                       'rc_deleted',
+                                       'wl_notificationtimestamp',
+                                       'rc_cur_id',
+                                       'rc_this_oldid',
+                                       'rc_last_oldid',
+                               ],
+                               [
+                                       'wl_user' => 1,
+                                       '(rc_this_oldid=page_latest) OR 
(rc_type=3)',
+                               ],
+                               $this->isType( 'string' ),
+                               [],
+                               [
+                                       'watchlist' => [
+                                               'INNER JOIN',
+                                               [
+                                                       
'wl_namespace=rc_namespace',
+                                                       'wl_title=rc_title'
+                                               ]
+                                       ],
+                                       'page' => [
+                                               'LEFT JOIN',
+                                               'rc_cur_id=page_id',
+                                       ],
+                               ]
+                       )
+                       ->will( $this->returnValue( [
+                               $this->getFakeRow( [
+                                       'rc_id' => 1,
+                                       'rc_namespace' => 0,
+                                       'rc_title' => 'Foo1',
+                                       'rc_timestamp' => '20151212010101',
+                                       'rc_type' => RC_NEW,
+                                       'rc_deleted' => 0,
+                                       'wl_notificationtimestamp' => 
'20151212010101',
+                               ] ),
+                               $this->getFakeRow( [
+                                       'rc_id' => 2,
+                                       'rc_namespace' => 1,
+                                       'rc_title' => 'Foo2',
+                                       'rc_timestamp' => '20151212010102',
+                                       'rc_type' => RC_NEW,
+                                       'rc_deleted' => 0,
+                                       'wl_notificationtimestamp' => null,
+                               ] ),
+                       ] ) );
+
+               $query = new WatchedItemQuery( $mockDb );
+               $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+               $items = $query->getWatchedItemsWithRecentChangeInfo( $user );
+
+               $this->assertInternalType( 'array', $items );
+               $this->assertCount( 2, $items );
+
+               foreach ( $items as list( $watchedItem, $recentChangeInfo ) ) {
+                       $this->assertInstanceOf( WatchedItem::class, 
$watchedItem );
+                       $this->assertInternalType( 'array', $recentChangeInfo );
+               }
+
+               $this->assertEquals(
+                       new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), 
'20151212010101' ),
+                       $items[0][0]
+               );
+               $this->assertEquals(
+                       [
+                               'rc_id' => 1,
+                               'rc_namespace' => 0,
+                               'rc_title' => 'Foo1',
+                               'rc_timestamp' => '20151212010101',
+                               'rc_type' => RC_NEW,
+                               'rc_deleted' => 0,
+                       ],
+                       $items[0][1]
+               );
+
+               $this->assertEquals(
+                       new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), 
null ),
+                       $items[1][0]
+               );
+               $this->assertEquals(
+                       [
+                               'rc_id' => 2,
+                               'rc_namespace' => 1,
+                               'rc_title' => 'Foo2',
+                               'rc_timestamp' => '20151212010102',
+                               'rc_type' => RC_NEW,
+                               'rc_deleted' => 0,
+                       ],
+                       $items[1][1]
+               );
+       }
+
+       public function getWatchedItemsWithRecentChangeInfoOptionsProvider() {
+               return [
+                       [
+                               [ 'includeFields' => [ 
WatchedItemQuery::INCLUDE_FLAGS ] ],
+                               [ 'rc_type', 'rc_minor', 'rc_bot' ],
+                               [],
+                               [],
+                       ],
+                       [
+                               [ 'includeFields' => [ 
WatchedItemQuery::INCLUDE_USER ] ],
+                               [ 'rc_user_text' ],
+                               [],
+                               [],
+                       ],
+                       [
+                               [ 'includeFields' => [ 
WatchedItemQuery::INCLUDE_USER_ID ] ],
+                               [ 'rc_user' ],
+                               [],
+                               [],
+                       ],
+                       [
+                               [ 'includeFields' => [ 
WatchedItemQuery::INCLUDE_COMMENT ] ],
+                               [ 'rc_comment' ],
+                               [],
+                               [],
+                       ],
+                       [
+                               [ 'includeFields' => [ 
WatchedItemQuery::INCLUDE_PATROL_INFO ] ],
+                               [ 'rc_patrolled', 'rc_log_type' ],
+                               [],
+                               [],
+                       ],
+                       [
+                               [ 'includeFields' => [ 
WatchedItemQuery::INCLUDE_SIZES ] ],
+                               [ 'rc_old_len', 'rc_new_len' ],
+                               [],
+                               [],
+                       ],
+                       [
+                               [ 'includeFields' => [ 
WatchedItemQuery::INCLUDE_LOG_INFO ] ],
+                               [ 'rc_logid', 'rc_log_type', 'rc_log_action', 
'rc_params' ],
+                               [],
+                               [],
+                       ],
+                       [
+                               [ 'namespaceIds' => [ 0, 1 ] ],
+                               [],
+                               [ 'wl_namespace' => [ 0, 1 ] ],
+                               [],
+                       ],
+                       [
+                               [ 'namespaceIds' => [ 0, "1; DROP TABLE 
watchlist;\n--" ] ],
+                               [],
+                               [ 'wl_namespace' => [ 0, 1 ] ],
+                               [],
+                       ],
+                       [
+                               [ 'rcTypes' => [ RC_EDIT, RC_NEW ] ],
+                               [],
+                               [ 'rc_type' => [ RC_EDIT, RC_NEW ] ],
+                               [],
+                       ],
+                       [
+                               [ 'dir' => WatchedItemQuery::DIR_OLDER ],
+                               [],
+                               [],
+                               [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id 
DESC' ] ]
+                       ],
+                       [
+                               [ 'dir' => WatchedItemQuery::DIR_NEWER ],
+                               [],
+                               [],
+                               [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ]
+                       ],
+                       [
+                               [ 'dir' => WatchedItemQuery::DIR_OLDER, 'start' 
=> '20151212010101' ],
+                               [],
+                               [ "rc_timestamp <= '20151212010101'" ],
+                               [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id 
DESC' ] ]
+                       ],
+                       [
+                               [ 'dir' => WatchedItemQuery::DIR_OLDER, 'end' 
=> '20151212010101' ],
+                               [],
+                               [ "rc_timestamp >= '20151212010101'" ],
+                               [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id 
DESC' ] ]
+                       ],
+                       [
+                               [
+                                       'dir' => WatchedItemQuery::DIR_OLDER,
+                                       'start' => '20151212020101',
+                                       'end' => '20151212010101'
+                               ],
+                               [],
+                               [ "rc_timestamp <= '20151212020101'", 
"rc_timestamp >= '20151212010101'" ],
+                               [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id 
DESC' ] ]
+                       ],
+                       [
+                               [ 'dir' => WatchedItemQuery::DIR_NEWER, 'start' 
=> '20151212010101' ],
+                               [],
+                               [ "rc_timestamp >= '20151212010101'" ],
+                               [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ]
+                       ],
+                       [
+                               [ 'dir' => WatchedItemQuery::DIR_NEWER, 'end' 
=> '20151212010101' ],
+                               [],
+                               [ "rc_timestamp <= '20151212010101'" ],
+                               [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ]
+                       ],
+                       [
+                               [
+                                       'dir' => WatchedItemQuery::DIR_NEWER,
+                                       'start' => '20151212010101',
+                                       'end' => '20151212020101'
+                               ],
+                               [],
+                               [ "rc_timestamp >= '20151212010101'", 
"rc_timestamp <= '20151212020101'" ],
+                               [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ]
+                       ],
+                       [
+                               [ 'limit' => 10 ],
+                               [],
+                               [],
+                               [ 'LIMIT' => 10 ],
+                       ],
+                       [
+                               [ 'limit' => "10; DROP TABLE watchlist;\n--" ],
+                               [],
+                               [],
+                               [ 'LIMIT' => 10 ],
+                       ],
+                       [
+                               [ 'filters' => [ WatchedItemQuery::FILTER_MINOR 
] ],
+                               [],
+                               [ 'rc_minor != 0' ],
+                               [],
+                       ],
+                       [
+                               [ 'filters' => [ 
WatchedItemQuery::FILTER_NOT_MINOR ] ],
+                               [],
+                               [ 'rc_minor = 0' ],
+                               [],
+                       ],
+                       [
+                               [ 'filters' => [ WatchedItemQuery::FILTER_BOT ] 
],
+                               [],
+                               [ 'rc_bot != 0' ],
+                               [],
+                       ],
+                       [
+                               [ 'filters' => [ 
WatchedItemQuery::FILTER_NOT_BOT ] ],
+                               [],
+                               [ 'rc_bot = 0' ],
+                               [],
+                       ],
+                       [
+                               [ 'filters' => [ WatchedItemQuery::FILTER_ANON 
] ],
+                               [],
+                               [ 'rc_user = 0' ],
+                               [],
+                       ],
+                       [
+                               [ 'filters' => [ 
WatchedItemQuery::FILTER_NOT_ANON ] ],
+                               [],
+                               [ 'rc_user != 0' ],
+                               [],
+                       ],
+                       [
+                               [ 'filters' => [ 
WatchedItemQuery::FILTER_PATROLLED ] ],
+                               [],
+                               [ 'rc_patrolled != 0' ],
+                               [],
+                       ],
+                       [
+                               [ 'filters' => [ 
WatchedItemQuery::FILTER_NOT_PATROLLED ] ],
+                               [],
+                               [ 'rc_patrolled = 0' ],
+                               [],
+                       ],
+                       [
+                               [ 'filters' => [ 
WatchedItemQuery::FILTER_UNREAD ] ],
+                               [],
+                               [ 'rc_timestamp >= wl_notificationtimestamp' ],
+                               [],
+                       ],
+                       [
+                               [ 'filters' => [ 
WatchedItemQuery::FILTER_NOT_UNREAD ] ],
+                               [],
+                               [ 'wl_notificationtimestamp IS NULL OR 
rc_timestamp < wl_notificationtimestamp' ],
+                               [],
+                       ],
+                       [
+                               [ 'onlyByUser' => 'SomeOtherUser' ],
+                               [],
+                               [ 'rc_user_text' => 'SomeOtherUser' ],
+                               [],
+                       ],
+                       [
+                               [ 'notByUser' => 'SomeOtherUser' ],
+                               [],
+                               [ "rc_user_text != 'SomeOtherUser'" ],
+                               [],
+                       ],
+                       [
+                               [ 'startFrom' => [ '20151212010101', 123 ], 
'dir' => WatchedItemQuery::DIR_OLDER ],
+                               [],
+                               [
+                                       "(rc_timestamp < '20151212010101') OR 
((rc_timestamp = '20151212010101') AND (rc_id <= 123))"
+                               ],
+                               [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id 
DESC' ] ],
+                       ],
+                       [
+                               [ 'startFrom' => [ '20151212010101', 123 ], 
'dir' => WatchedItemQuery::DIR_NEWER ],
+                               [],
+                               [
+                                       "(rc_timestamp > '20151212010101') OR 
((rc_timestamp = '20151212010101') AND (rc_id >= 123))"
+                               ],
+                               [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ],
+                       ],
+                       [
+                               [
+                                       'startFrom' => [ '20151212010101', 
"123; DROP TABLE watchlist;\n--" ],
+                                       'dir' => WatchedItemQuery::DIR_OLDER
+                               ],
+                               [],
+                               [
+                                       "(rc_timestamp < '20151212010101') OR 
((rc_timestamp = '20151212010101') AND (rc_id <= 123))"
+                               ],
+                               [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id 
DESC' ] ],
+                       ],
+               ];
+       }
+
+       /**
+        * @dataProvider getWatchedItemsWithRecentChangeInfoOptionsProvider
+        */
+       public function 
testGetWatchedItemsWithRecentChangeInfo_optionsAndEmptyResult(
+               array $options,
+               array $expectedExtraFields,
+               array $expectedExtraConds,
+               array $expectedDbOptions
+       ) {
+               $expectedFields = array_merge(
+                       [
+                               'rc_id',
+                               'rc_namespace',
+                               'rc_title',
+                               'rc_timestamp',
+                               'rc_type',
+                               'rc_deleted',
+                               'wl_notificationtimestamp',
+
+                               'rc_cur_id',
+                               'rc_this_oldid',
+                               'rc_last_oldid',
+                       ],
+                       $expectedExtraFields
+               );
+               $expectedConds = array_merge(
+                       [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR 
(rc_type=3)', ],
+                       $expectedExtraConds
+               );
+
+               $mockDb = $this->getMockDb();
+               $mockDb->expects( $this->once() )
+                       ->method( 'select' )
+                       ->with(
+                               [ 'recentchanges', 'watchlist', 'page' ],
+                               $expectedFields,
+                               $expectedConds,
+                               $this->isType( 'string' ),
+                               $expectedDbOptions,
+                               [
+                                       'watchlist' => [
+                                               'INNER JOIN',
+                                               [
+                                                       
'wl_namespace=rc_namespace',
+                                                       'wl_title=rc_title'
+                                               ]
+                                       ],
+                                       'page' => [
+                                               'LEFT JOIN',
+                                               'rc_cur_id=page_id',
+                                       ],
+                               ]
+                       )
+                       ->will( $this->returnValue( [] ) );
+
+               $query = new WatchedItemQuery( $mockDb );
+               $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+               $items = $query->getWatchedItemsWithRecentChangeInfo( $user, 
$options );
+
+               $this->assertEmpty( $items );
+       }
+
+       public function filterPatrolledOptionProvider() {
+               return [
+                       [ WatchedItemQuery::FILTER_PATROLLED ],
+                       [ WatchedItemQuery::FILTER_NOT_PATROLLED ],
+               ];
+       }
+
+       /**
+        * @dataProvider filterPatrolledOptionProvider
+        */
+       public function 
testGetWatchedItemsWithRecentChangeInfo_filterPatrolledAndUserWithNoPatrolRights(
+               $filtersOption
+       ) {
+               $mockDb = $this->getMockDb();
+               $mockDb->expects( $this->once() )
+                       ->method( 'select' )
+                       ->with(
+                               [ 'recentchanges', 'watchlist', 'page' ],
+                               $this->isType( 'array' ),
+                               [ 'wl_user' => 1, '(rc_this_oldid=page_latest) 
OR (rc_type=3)' ],
+                               $this->isType( 'string' ),
+                               $this->isType( 'array' ),
+                               $this->isType( 'array' )
+                       )
+                       ->will( $this->returnValue( [] ) );
+
+               $user = $this->getMockNonAnonUserWithIdAndNoPatrolRights( 1 );
+
+               $query = new WatchedItemQuery( $mockDb );
+               $items = $query->getWatchedItemsWithRecentChangeInfo(
+                       $user,
+                       [ 'filters' => [ $filtersOption ] ]
+               );
+
+               $this->assertEmpty( $items );
+       }
+
+       public function mysqlIndexOptimizationProvider() {
+               return [
+                       [
+                               'mysql',
+                               [],
+                               [ "rc_timestamp > ''" ],
+                       ],
+                       [
+                               'mysql',
+                               [ 'start' => '20151212010101', 'dir' => 
WatchedItemQuery::DIR_OLDER ],
+                               [ "rc_timestamp <= '20151212010101'" ],
+                       ],
+                       [
+                               'mysql',
+                               [ 'end' => '20151212010101', 'dir' => 
WatchedItemQuery::DIR_OLDER ],
+                               [ "rc_timestamp >= '20151212010101'" ],
+                       ],
+                       [
+                               'postgres',
+                               [],
+                               [],
+                       ],
+               ];
+       }
+
+       /**
+        * @dataProvider mysqlIndexOptimizationProvider
+        */
+       public function 
testGetWatchedItemsWithRecentChangeInfo_mysqlIndexOptimization(
+               $dbType,
+               array $options,
+               array $expectedExtraConds
+       ) {
+               $commonConds = [ 'wl_user' => 1, '(rc_this_oldid=page_latest) 
OR (rc_type=3)' ];
+               $conds = array_merge( $commonConds, $expectedExtraConds );
+
+               $mockDb = $this->getMockDb();
+               $mockDb->expects( $this->once() )
+                       ->method( 'select' )
+                       ->with(
+                               [ 'recentchanges', 'watchlist', 'page' ],
+                               $this->isType( 'array' ),
+                               $conds,
+                               $this->isType( 'string' ),
+                               $this->isType( 'array' ),
+                               $this->isType( 'array' )
+                       )
+                       ->will( $this->returnValue( [] ) );
+               $mockDb->expects( $this->any() )
+                       ->method( 'getType' )
+                       ->will( $this->returnValue( $dbType ) );
+
+               $query = new WatchedItemQuery( $mockDb );
+               $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+               $items = $query->getWatchedItemsWithRecentChangeInfo( $user, 
$options );
+
+               $this->assertEmpty( $items );
+       }
+
+       public function userPermissionRelatedExtraChecksProvider() {
+               return [
+                       [
+                               [],
+                               'deletedhistory',
+                               [
+                                       '(rc_type != ' . RC_LOG . ') OR 
((rc_deleted & ' . LogPage::DELETED_ACTION . ') != ' .
+                                               LogPage::DELETED_ACTION . ')'
+                               ],
+                       ],
+                       [
+                               [],
+                               'suppressrevision',
+                               [
+                                       '(rc_type != ' . RC_LOG . ') OR (' .
+                                               '(rc_deleted & ' . ( 
LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' .
+                                               ( LogPage::DELETED_ACTION | 
LogPage::DELETED_RESTRICTED ) . ')'
+                               ],
+                       ],
+                       [
+                               [],
+                               'viewsuppressed',
+                               [
+                                       '(rc_type != ' . RC_LOG . ') OR (' .
+                                               '(rc_deleted & ' . ( 
LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' .
+                                               ( LogPage::DELETED_ACTION | 
LogPage::DELETED_RESTRICTED ) . ')'
+                               ],
+                       ],
+                       [
+                               [ 'onlyByUser' => 'SomeOtherUser' ],
+                               'deletedhistory',
+                               [
+                                       'rc_user_text' => 'SomeOtherUser',
+                                       '(rc_deleted & ' . 
Revision::DELETED_USER . ') != ' . Revision::DELETED_USER,
+                                       '(rc_type != ' . RC_LOG . ') OR 
((rc_deleted & ' . LogPage::DELETED_ACTION . ') != ' .
+                                               LogPage::DELETED_ACTION . ')'
+                               ],
+                       ],
+                       [
+                               [ 'onlyByUser' => 'SomeOtherUser' ],
+                               'suppressrevision',
+                               [
+                                       'rc_user_text' => 'SomeOtherUser',
+                                       '(rc_deleted & ' . ( 
Revision::DELETED_USER | Revision::DELETED_RESTRICTED ) . ') != ' .
+                                               ( Revision::DELETED_USER | 
Revision::DELETED_RESTRICTED ),
+                                       '(rc_type != ' . RC_LOG . ') OR (' .
+                                               '(rc_deleted & ' . ( 
LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' .
+                                               ( LogPage::DELETED_ACTION | 
LogPage::DELETED_RESTRICTED ) . ')'
+                               ],
+                       ],
+                       [
+                               [ 'onlyByUser' => 'SomeOtherUser' ],
+                               'viewsuppressed',
+                               [
+                                       'rc_user_text' => 'SomeOtherUser',
+                                       '(rc_deleted & ' . ( 
Revision::DELETED_USER | Revision::DELETED_RESTRICTED ) . ') != ' .
+                                               ( Revision::DELETED_USER | 
Revision::DELETED_RESTRICTED ),
+                                       '(rc_type != ' . RC_LOG . ') OR (' .
+                                               '(rc_deleted & ' . ( 
LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' .
+                                               ( LogPage::DELETED_ACTION | 
LogPage::DELETED_RESTRICTED ) . ')'
+                               ],
+                       ],
+               ];
+       }
+
+       /**
+        * @dataProvider userPermissionRelatedExtraChecksProvider
+        */
+       public function 
testGetWatchedItemsWithRecentChangeInfo_userPermissionRelatedExtraChecks(
+               array $options,
+               $notAllowedAction,
+               array $expectedExtraConds
+       ) {
+               $commonConds = [ 'wl_user' => 1, '(rc_this_oldid=page_latest) 
OR (rc_type=3)' ];
+               $conds = array_merge( $commonConds, $expectedExtraConds );
+
+               $mockDb = $this->getMockDb();
+               $mockDb->expects( $this->once() )
+                       ->method( 'select' )
+                       ->with(
+                               [ 'recentchanges', 'watchlist', 'page' ],
+                               $this->isType( 'array' ),
+                               $conds,
+                               $this->isType( 'string' ),
+                               $this->isType( 'array' ),
+                               $this->isType( 'array' )
+                       )
+                       ->will( $this->returnValue( [] ) );
+
+               $user = 
$this->getMockNonAnonUserWithIdAndRestrictedPermissions( 1, $notAllowedAction );
+
+               $query = new WatchedItemQuery( $mockDb );
+               $items = $query->getWatchedItemsWithRecentChangeInfo( $user, 
$options );
+
+               $this->assertEmpty( $items );
+       }
+
+       public function 
testGetWatchedItemsWithRecentChangeInfo_allRevisionsOptionAndEmptyResult() {
+               $mockDb = $this->getMockDb();
+               $mockDb->expects( $this->once() )
+                       ->method( 'select' )
+                       ->with(
+                               [ 'recentchanges', 'watchlist' ],
+                               [
+                                       'rc_id',
+                                       'rc_namespace',
+                                       'rc_title',
+                                       'rc_timestamp',
+                                       'rc_type',
+                                       'rc_deleted',
+                                       'wl_notificationtimestamp',
+
+                                       'rc_cur_id',
+                                       'rc_this_oldid',
+                                       'rc_last_oldid',
+                               ],
+                               [ 'wl_user' => 1, ],
+                               $this->isType( 'string' ),
+                               [],
+                               [
+                                       'watchlist' => [
+                                               'INNER JOIN',
+                                               [
+                                                       
'wl_namespace=rc_namespace',
+                                                       'wl_title=rc_title'
+                                               ]
+                                       ],
+                               ]
+                       )
+                       ->will( $this->returnValue( [] ) );
+
+               $query = new WatchedItemQuery( $mockDb );
+               $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+               $items = $query->getWatchedItemsWithRecentChangeInfo( $user, [ 
'allRevisions' => true ] );
+
+               $this->assertEmpty( $items );
+       }
+
+       public function 
getWatchedItemsWithRecentChangeInfoInvalidOptionsProvider() {
+               return [
+                       [
+                               [ 'rcTypes' => [ 1337 ] ],
+                               'Bad value for parameter $options[\'rcTypes\']',
+                       ],
+                       [
+                               [ 'rcTypes' => [ 'edit' ] ],
+                               'Bad value for parameter $options[\'rcTypes\']',
+                       ],
+                       [
+                               [ 'rcTypes' => [ RC_EDIT, 1337 ] ],
+                               'Bad value for parameter $options[\'rcTypes\']',
+                       ],
+                       [
+                               [ 'dir' => 'foo' ],
+                               'Bad value for parameter $options[\'dir\']',
+                       ],
+                       [
+                               [ 'start' => '20151212010101' ],
+                               'Bad value for parameter $options[\'dir\']: 
must be provided',
+                       ],
+                       [
+                               [ 'end' => '20151212010101' ],
+                               'Bad value for parameter $options[\'dir\']: 
must be provided',
+                       ],
+                       [
+                               [ 'startFrom' => [ '20151212010101', 123 ] ],
+                               'Bad value for parameter $options[\'dir\']: 
must be provided',
+                       ],
+                       [
+                               [ 'dir' => WatchedItemQuery::DIR_OLDER, 
'startFrom' => '20151212010101' ],
+                               'Bad value for parameter 
$options[\'startFrom\']: must be a two-element array',
+                       ],
+                       [
+                               [ 'dir' => WatchedItemQuery::DIR_OLDER, 
'startFrom' => [ '20151212010101' ] ],
+                               'Bad value for parameter 
$options[\'startFrom\']: must be a two-element array',
+                       ],
+                       [
+                               [ 'dir' => WatchedItemQuery::DIR_OLDER, 
'startFrom' => [ '20151212010101', 123, 'foo' ] ],
+                               'Bad value for parameter 
$options[\'startFrom\']: must be a two-element array',
+                       ],
+                       [
+                               [ 'watchlistOwner' => 
$this->getMockUnrestrictedNonAnonUserWithId( 2 ) ],
+                               'Bad value for parameter 
$options[\'watchlistOwnerToken\']',
+                       ],
+                       [
+                               [ 'watchlistOwner' => 'Other User', 
'watchlistOwnerToken' => 'some-token' ],
+                               'Bad value for parameter 
$options[\'watchlistOwner\']',
+                       ],
+               ];
+       }
+
+       /**
+        * @dataProvider 
getWatchedItemsWithRecentChangeInfoInvalidOptionsProvider
+        */
+       public function testGetWatchedItemsWithRecentChangeInfo_invalidOptions(
+               array $options,
+               $expectedInExceptionMessage
+       ) {
+               $mockDb = $this->getMockDb();
+               $mockDb->expects( $this->never() )
+                       ->method( $this->anything() );
+
+               $query = new WatchedItemQuery( $mockDb );
+               $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+               $this->setExpectedException( 
ParameterAssertionException::class, $expectedInExceptionMessage );
+               $query->getWatchedItemsWithRecentChangeInfo( $user, $options );
+       }
+
+       public function 
testGetWatchedItemsWithRecentChangeInfo_usedInGeneratorOptionAndEmptyResult() {
+               $mockDb = $this->getMockDb();
+               $mockDb->expects( $this->once() )
+                       ->method( 'select' )
+                       ->with(
+                               [ 'recentchanges', 'watchlist', 'page' ],
+                               [
+                                       'rc_id',
+                                       'rc_namespace',
+                                       'rc_title',
+                                       'rc_timestamp',
+                                       'rc_type',
+                                       'rc_deleted',
+                                       'wl_notificationtimestamp',
+                                       'rc_cur_id',
+                               ],
+                               [ 'wl_user' => 1, '(rc_this_oldid=page_latest) 
OR (rc_type=3)' ],
+                               $this->isType( 'string' ),
+                               [],
+                               [
+                                       'watchlist' => [
+                                               'INNER JOIN',
+                                               [
+                                                       
'wl_namespace=rc_namespace',
+                                                       'wl_title=rc_title'
+                                               ]
+                                       ],
+                                       'page' => [
+                                               'LEFT JOIN',
+                                               'rc_cur_id=page_id',
+                                       ],
+                               ]
+                       )
+                       ->will( $this->returnValue( [] ) );
+
+               $query = new WatchedItemQuery( $mockDb );
+               $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+               $items = $query->getWatchedItemsWithRecentChangeInfo( $user, [ 
'usedInGenerator' => true ] );
+
+               $this->assertEmpty( $items );
+       }
+
+       public function 
testGetWatchedItemsWithRecentChangeInfo_usedInGeneratorAllRevisionsOptions() {
+               $mockDb = $this->getMockDb();
+               $mockDb->expects( $this->once() )
+                       ->method( 'select' )
+                       ->with(
+                               [ 'recentchanges', 'watchlist' ],
+                               [
+                                       'rc_id',
+                                       'rc_namespace',
+                                       'rc_title',
+                                       'rc_timestamp',
+                                       'rc_type',
+                                       'rc_deleted',
+                                       'wl_notificationtimestamp',
+                                       'rc_this_oldid',
+                               ],
+                               [ 'wl_user' => 1 ],
+                               $this->isType( 'string' ),
+                               [],
+                               [
+                                       'watchlist' => [
+                                               'INNER JOIN',
+                                               [
+                                                       
'wl_namespace=rc_namespace',
+                                                       'wl_title=rc_title'
+                                               ]
+                                       ],
+                               ]
+                       )
+                       ->will( $this->returnValue( [] ) );
+
+               $query = new WatchedItemQuery( $mockDb );
+               $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+               $items = $query->getWatchedItemsWithRecentChangeInfo(
+                       $user,
+                       [ 'usedInGenerator' => true, 'allRevisions' => true, ]
+               );
+
+               $this->assertEmpty( $items );
+       }
+
+       public function 
testGetWatchedItemsWithRecentChangeInfo_watchlistOwnerOptionAndEmptyResult() {
+               $mockDb = $this->getMockDb();
+               $mockDb->expects( $this->once() )
+                       ->method( 'select' )
+                       ->with(
+                               $this->isType( 'array' ),
+                               $this->isType( 'array' ),
+                               [
+                                       'wl_user' => 2,
+                                       '(rc_this_oldid=page_latest) OR 
(rc_type=3)',
+                               ],
+                               $this->isType( 'string' ),
+                               $this->isType( 'array' ),
+                               $this->isType( 'array' )
+                       )
+                       ->will( $this->returnValue( [] ) );
+
+               $query = new WatchedItemQuery( $mockDb );
+               $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+               $otherUser = $this->getMockUnrestrictedNonAnonUserWithId( 2 );
+               $otherUser->expects( $this->once() )
+                       ->method( 'getOption' )
+                       ->with( 'watchlisttoken' )
+                       ->willReturn( '0123456789abcdef' );
+
+               $items = $query->getWatchedItemsWithRecentChangeInfo(
+                       $user,
+                       [ 'watchlistOwner' => $otherUser, 'watchlistOwnerToken' 
=> '0123456789abcdef' ]
+               );
+
+               $this->assertEmpty( $items );
+       }
+
+       public function invalidWatchlistTokenProvider() {
+               return [
+                       [ 'wrongToken' ],
+                       [ '' ],
+               ];
+       }
+
+       /**
+        * @dataProvider invalidWatchlistTokenProvider
+        */
+       public function 
testGetWatchedItemsWithRecentChangeInfo_watchlistOwnerAndInvalidToken( $token ) 
{
+               $mockDb = $this->getMockDb();
+               $mockDb->expects( $this->never() )
+                       ->method( $this->anything() );
+
+               $query = new WatchedItemQuery( $mockDb );
+               $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+               $otherUser = $this->getMockUnrestrictedNonAnonUserWithId( 2 );
+               $otherUser->expects( $this->once() )
+                       ->method( 'getOption' )
+                       ->with( 'watchlisttoken' )
+                       ->willReturn( '0123456789abcdef' );
+
+               $this->setExpectedException( UsageException::class, 'Incorrect 
watchlist token provided' );
+               $query->getWatchedItemsWithRecentChangeInfo(
+                       $user,
+                       [ 'watchlistOwner' => $otherUser, 'watchlistOwnerToken' 
=> $token ]
+               );
+       }
+
+}

-- 
To view, visit https://gerrit.wikimedia.org/r/284169
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: I5a5cda13f8091baa430ac1a8e2176e0efd1ae192
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/core
Gerrit-Branch: master
Gerrit-Owner: WMDE-leszek <leszek.mani...@wikimedia.de>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
MediaWiki-commits@lists.wikimedia.org
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to