Refactor database-related code in ApiQueryWatchlist
authorLeszek Manicki <leszek.manicki@wikimedia.de>
Tue, 19 Apr 2016 09:34:31 +0000 (11:34 +0200)
committerLeszek Manicki <leszek.manicki@wikimedia.de>
Wed, 29 Jun 2016 08:21:17 +0000 (10:21 +0200)
This moves generating of a complex Watchlist and RecentChanges
related query to a WatchedItemQueryService class.
ApiQueryWatchlist class no longer contains any database-related
code.

Bug: T132565
Change-Id: I5a5cda13f8091baa430ac1a8e2176e0efd1ae192

autoload.php
includes/MediaWikiServices.php
includes/ServiceWiring.php
includes/WatchedItemQueryService.php [new file with mode: 0644]
includes/api/ApiQueryWatchlist.php
tests/phpunit/includes/MediaWikiServicesTest.php
tests/phpunit/includes/WatchedItemQueryServiceUnitTest.php [new file with mode: 0644]
tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php

index 87ba225..2e6c0b9 100644 (file)
@@ -1485,6 +1485,7 @@ $wgAutoloadLocalClasses = [
        'WantedTemplatesPage' => __DIR__ . '/includes/specials/SpecialWantedtemplates.php',
        'WatchAction' => __DIR__ . '/includes/actions/WatchAction.php',
        'WatchedItem' => __DIR__ . '/includes/WatchedItem.php',
+       'WatchedItemQueryService' => __DIR__ . '/includes/WatchedItemQueryService.php',
        'WatchedItemStore' => __DIR__ . '/includes/WatchedItemStore.php',
        'WatchlistCleanup' => __DIR__ . '/maintenance/cleanupWatchlist.php',
        'WebInstaller' => __DIR__ . '/includes/installer/WebInstaller.php',
index 6613db1..ff292cf 100644 (file)
@@ -23,6 +23,7 @@ use SearchEngineFactory;
 use SiteLookup;
 use SiteStore;
 use WatchedItemStore;
+use WatchedItemQueryService;
 use SkinFactory;
 use TitleFormatter;
 use TitleParser;
@@ -502,6 +503,14 @@ class MediaWikiServices extends ServiceContainer {
                return $this->getService( 'WatchedItemStore' );
        }
 
+       /**
+        * @since 1.28
+        * @return WatchedItemQueryService
+        */
+       public function getWatchedItemQueryService() {
+               return $this->getService( 'WatchedItemQueryService' );
+       }
+
        /**
         * @since 1.28
         * @return GenderCache
index b076d07..9ee4236 100644 (file)
@@ -154,6 +154,10 @@ return [
                return $store;
        },
 
+       'WatchedItemQueryService' => function( MediaWikiServices $services ) {
+               return new WatchedItemQueryService( $services->getDBLoadBalancer() );
+       },
+
        'LinkCache' => function( MediaWikiServices $services ) {
                return new LinkCache(
                        $services->getTitleFormatter()
diff --git a/includes/WatchedItemQueryService.php b/includes/WatchedItemQueryService.php
new file mode 100644 (file)
index 0000000..14d6aac
--- /dev/null
@@ -0,0 +1,474 @@
+<?php
+
+use Wikimedia\Assert\Assert;
+
+/**
+ * Class performing complex database queries related to WatchedItems.
+ *
+ * @since 1.28
+ *
+ * @file
+ * @ingroup Watchlist
+ *
+ * @license GNU GPL v2+
+ */
+class WatchedItemQueryService {
+
+       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';
+
+       // FILTER_* constants are part of public API (are used
+       // in ApiQueryWatchlist class) and should not be changed.
+       // Changing values of those constants will result in a breaking change in the API
+       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 LoadBalancer
+        */
+       private $loadBalancer;
+
+       public function __construct( LoadBalancer $loadBalancer ) {
+               $this->loadBalancer = $loadBalancer;
+       }
+
+       /**
+        * @return DatabaseBase
+        * @throws MWException
+        */
+       private function getConnection() {
+               return $this->loadBalancer->getConnection( DB_SLAVE, [ 'watchlist' ] );
+       }
+
+       /**
+        * @param DatabaseBase $connection
+        * @throws MWException
+        */
+       private function reuseConnection( DatabaseBase $connection ) {
+               $this->loadBalancer->reuseConnection( $connection );
+       }
+
+       /**
+        * @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' => [],
+                       'namespaceIds' => [],
+                       'filters' => [],
+                       'allRevisions' => false,
+                       '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';
+               }
+
+               $db = $this->getConnection();
+
+               $fields = $this->getFields( $options );
+               $conds = $this->getConds( $db, $user, $options );
+               $dbOptions = $this->getDbOptions( $options );
+               $joinConds = $this->getJoinConds( $options );
+
+               $res = $db->select(
+                       $tables,
+                       $fields,
+                       $conds,
+                       __METHOD__,
+                       $dbOptions,
+                       $joinConds
+               );
+
+               $this->reuseConnection( $db );
+
+               $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 ) {
+               // This can be simplified to single array_filter call filtering by key value,
+               // once we stop supporting PHP 5.5
+               $allFields = get_object_vars( $row );
+               $rcKeys = array_filter(
+                       array_keys( $allFields ),
+                       function( $key ) {
+                               return substr( $key, 0, 3 ) === 'rc_';
+                       }
+               );
+               return array_intersect_key( $allFields, array_flip( $rcKeys ) );
+       }
+
+       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( DatabaseBase $db, User $user, array $options ) {
+               $watchlistOwnerId = $this->getWatchlistOwnerId( $user, $options );
+               $conds = [ 'wl_user' => $watchlistOwnerId ];
+
+               if ( !$options['allRevisions'] ) {
+                       $conds[] = $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 ) );
+
+               $conds = array_merge( $conds, $this->getStartEndConds( $db, $options ) );
+
+               if ( !isset( $options['start'] ) && !isset( $options['end'] ) ) {
+                       if ( $db->getType() === 'mysql' ) {
+                               // This is an index optimization for mysql
+                               $conds[] = "rc_timestamp > ''";
+                       }
+               }
+
+               $conds = array_merge( $conds, $this->getUserRelatedConds( $db, $user, $options ) );
+
+               $deletedPageLogCond = $this->getExtraDeletedPageLogEntryRelatedCond( $db, $user );
+               if ( $deletedPageLogCond ) {
+                       $conds[] = $deletedPageLogCond;
+               }
+
+               if ( array_key_exists( 'startFrom', $options ) ) {
+                       $conds[] = $this->getStartFromConds( $db, $options );
+               }
+
+               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 getStartEndConds( DatabaseBase $db, array $options ) {
+               if ( !isset( $options['start'] ) && ! isset( $options['end'] ) ) {
+                       return [];
+               }
+
+               $conds = [];
+
+               if ( isset( $options['start'] ) ) {
+                       $after = $options['dir'] === self::DIR_OLDER ? '<=' : '>=';
+                       $conds[] = 'rc_timestamp ' . $after . ' ' . $db->addQuotes( $options['start'] );
+               }
+               if ( isset( $options['end'] ) ) {
+                       $before = $options['dir'] === self::DIR_OLDER ? '>=' : '<=';
+                       $conds[] = 'rc_timestamp ' . $before . ' ' . $db->addQuotes( $options['end'] );
+               }
+
+               return $conds;
+       }
+
+       private function getUserRelatedConds( DatabaseBase $db, User $user, array $options ) {
+               if ( !array_key_exists( 'onlyByUser', $options ) && !array_key_exists( 'notByUser', $options ) ) {
+                       return [];
+               }
+
+               $conds = [];
+
+               if ( array_key_exists( 'onlyByUser', $options ) ) {
+                       $conds['rc_user_text'] = $options['onlyByUser'];
+               } elseif ( array_key_exists( 'notByUser', $options ) ) {
+                       $conds[] = 'rc_user_text != ' . $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[] = $db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask";
+               }
+
+               return $conds;
+       }
+
+       private function getExtraDeletedPageLogEntryRelatedCond( DatabaseBase $db, 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 $db->makeList( [
+                               'rc_type != ' . RC_LOG,
+                               $db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask",
+                       ], LIST_OR );
+               }
+               return '';
+       }
+
+       private function getStartFromConds( DatabaseBase $db, array $options ) {
+               $op = $options['dir'] === self::DIR_OLDER ? '<' : '>';
+               list( $rcTimestamp, $rcId ) = $options['startFrom'];
+               $rcTimestamp = $db->addQuotes( $db->timestamp( $rcTimestamp ) );
+               $rcId = (int)$rcId;
+               return $db->makeList(
+                       [
+                               "rc_timestamp $op $rcTimestamp",
+                               $db->makeList(
+                                       [
+                                               "rc_timestamp = $rcTimestamp",
+                                               "rc_id $op= $rcId"
+                                       ],
+                                       LIST_AND
+                               )
+                       ],
+                       LIST_OR
+               );
+       }
+
+       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;
+       }
+
+}
index db2cf86..e2599d1 100644 (file)
@@ -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,96 +87,59 @@ class ApiQueryWatchlist extends ApiQueryGeneratorBase {
                        }
                }
 
-               $this->addFields( [
-                       'rc_id',
-                       'rc_namespace',
-                       'rc_title',
-                       'rc_timestamp',
-                       'rc_type',
-                       'rc_deleted',
-               ] );
+               $options = [
+                       'dir' => $params['dir'] === 'older'
+                               ? WatchedItemQueryService::DIR_OLDER
+                               : WatchedItemQueryService::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'] ) ) {
                        $show = array_flip( $params['show'] );
 
                        /* Check for conflicting parameters. */
-                       if ( ( isset( $show['minor'] ) && isset( $show['!minor'] ) )
-                               || ( isset( $show['bot'] ) && isset( $show['!bot'] ) )
-                               || ( isset( $show['anon'] ) && isset( $show['!anon'] ) )
-                               || ( isset( $show['patrolled'] ) && isset( $show['!patrolled'] ) )
-                               || ( isset( $show['unread'] ) && isset( $show['!unread'] ) )
-                       ) {
+                       if ( $this->showParamsConflicting( $show ) ) {
                                $this->dieUsageMsg( 'show' );
                        }
 
                        // Check permissions.
-                       if ( isset( $show['patrolled'] ) || isset( $show['!patrolled'] ) ) {
+                       if ( isset( $show[WatchedItemQueryService::FILTER_PATROLLED] )
+                               || isset( $show[WatchedItemQueryService::FILTER_NOT_PATROLLED] )
+                       ) {
                                if ( !$user->useRCPatrol() && !$user->useNPPatrol() ) {
                                        $this->dieUsage(
                                                'You need the patrol right to request the patrolled flag',
@@ -183,25 +148,12 @@ class ApiQueryWatchlist extends ApiQueryGeneratorBase {
                                }
                        }
 
-                       /* 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'] = array_keys( $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 +163,46 @@ class ApiQueryWatchlist extends ApiQueryGeneratorBase {
                        $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__ );
+               $watchedItemQuery = MediaWikiServices::getInstance()->getWatchedItemQueryService();
+               $items = $watchedItemQuery->getWatchedItemsWithRecentChangeInfo( $wlowner, $options );
 
-               foreach ( $res as $row ) {
+               foreach ( $items as list ( $watchedItem, $recentChangeInfo ) ) {
+                       /** @var WatchedItem $watchedItem */
                        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,106 @@ class ApiQueryWatchlist extends ApiQueryGeneratorBase {
                }
        }
 
-       private function extractRowInfo( $row ) {
+       private function getFieldsToInclude() {
+               $includeFields = [];
+               if ( $this->fld_flags ) {
+                       $includeFields[] = WatchedItemQueryService::INCLUDE_FLAGS;
+               }
+               if ( $this->fld_user || $this->fld_userid ) {
+                       $includeFields[] = WatchedItemQueryService::INCLUDE_USER_ID;
+               }
+               if ( $this->fld_user ) {
+                       $includeFields[] = WatchedItemQueryService::INCLUDE_USER;
+               }
+               if ( $this->fld_comment || $this->fld_parsedcomment ) {
+                       $includeFields[] = WatchedItemQueryService::INCLUDE_COMMENT;
+               }
+               if ( $this->fld_patrol ) {
+                       $includeFields[] = WatchedItemQueryService::INCLUDE_PATROL_INFO;
+               }
+               if ( $this->fld_sizes ) {
+                       $includeFields[] = WatchedItemQueryService::INCLUDE_SIZES;
+               }
+               if ( $this->fld_loginfo ) {
+                       $includeFields[] = WatchedItemQueryService::INCLUDE_LOG_INFO;
+               }
+               return $includeFields;
+       }
+
+       private function showParamsConflicting( array $show ) {
+               return ( isset( $show[WatchedItemQueryService::FILTER_MINOR] )
+                       && isset( $show[WatchedItemQueryService::FILTER_NOT_MINOR] ) )
+               || ( isset( $show[WatchedItemQueryService::FILTER_BOT] )
+                       && isset( $show[WatchedItemQueryService::FILTER_NOT_BOT] ) )
+               || ( isset( $show[WatchedItemQueryService::FILTER_ANON] )
+                       && isset( $show[WatchedItemQueryService::FILTER_NOT_ANON] ) )
+               || ( isset( $show[WatchedItemQueryService::FILTER_PATROLLED] )
+                       && isset( $show[WatchedItemQueryService::FILTER_NOT_PATROLLED] ) )
+               || ( isset( $show[WatchedItemQueryService::FILTER_UNREAD] )
+                       && isset( $show[WatchedItemQueryService::FILTER_NOT_UNREAD] ) );
+       }
+
+       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 +326,73 @@ class ApiQueryWatchlist extends ApiQueryGeneratorBase {
 
                /* 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;
                }
 
@@ -473,16 +455,16 @@ class ApiQueryWatchlist extends ApiQueryGeneratorBase {
                        'show' => [
                                ApiBase::PARAM_ISMULTI => true,
                                ApiBase::PARAM_TYPE => [
-                                       'minor',
-                                       '!minor',
-                                       'bot',
-                                       '!bot',
-                                       'anon',
-                                       '!anon',
-                                       'patrolled',
-                                       '!patrolled',
-                                       'unread',
-                                       '!unread',
+                                       WatchedItemQueryService::FILTER_MINOR,
+                                       WatchedItemQueryService::FILTER_NOT_MINOR,
+                                       WatchedItemQueryService::FILTER_BOT,
+                                       WatchedItemQueryService::FILTER_NOT_BOT,
+                                       WatchedItemQueryService::FILTER_ANON,
+                                       WatchedItemQueryService::FILTER_NOT_ANON,
+                                       WatchedItemQueryService::FILTER_PATROLLED,
+                                       WatchedItemQueryService::FILTER_NOT_PATROLLED,
+                                       WatchedItemQueryService::FILTER_UNREAD,
+                                       WatchedItemQueryService::FILTER_NOT_UNREAD,
                                ]
                        ],
                        'type' => [
index 0e13721..d20344d 100644 (file)
@@ -315,6 +315,7 @@ class MediaWikiServicesTest extends MediaWikiTestCase {
                        'DBLoadBalancerFactory' => [ 'DBLoadBalancerFactory', 'LBFactory' ],
                        'DBLoadBalancer' => [ 'DBLoadBalancer', 'LoadBalancer' ],
                        'WatchedItemStore' => [ 'WatchedItemStore', WatchedItemStore::class ],
+                       'WatchedItemQueryService' => [ 'WatchedItemQueryService', WatchedItemQueryService::class ],
                        'GenderCache' => [ 'GenderCache', GenderCache::class ],
                        'LinkCache' => [ 'LinkCache', LinkCache::class ],
                        'LinkRenderer' => [ 'LinkRenderer', LinkRenderer::class ],
diff --git a/tests/phpunit/includes/WatchedItemQueryServiceUnitTest.php b/tests/phpunit/includes/WatchedItemQueryServiceUnitTest.php
new file mode 100644 (file)
index 0000000..b63a1f4
--- /dev/null
@@ -0,0 +1,1013 @@
+<?php
+
+/**
+ * @covers WatchedItemQueryService
+ */
+class WatchedItemQueryServiceUnitTest extends PHPUnit_Framework_TestCase {
+
+       /**
+        * @return PHPUnit_Framework_MockObject_MockObject|DatabaseBase
+        */
+       private function getMockDb() {
+               $mock = $this->getMockBuilder( DatabaseBase::class )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+
+               $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 $mockDb
+        * @return PHPUnit_Framework_MockObject_MockObject|LoadBalancer
+        */
+       private function getMockLoadBalancer( $mockDb ) {
+               $mock = $this->getMockBuilder( LoadBalancer::class )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+               $mock->expects( $this->any() )
+                       ->method( 'getConnection' )
+                       ->with( DB_SLAVE )
+                       ->will( $this->returnValue( $mockDb ) );
+               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
+        * @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,
+                               ] ),
+                       ] ) );
+
+               $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+               $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+               $items = $queryService->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' => [ WatchedItemQueryService::INCLUDE_FLAGS ] ],
+                               [ 'rc_type', 'rc_minor', 'rc_bot' ],
+                               [],
+                               [],
+                       ],
+                       [
+                               [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER ] ],
+                               [ 'rc_user_text' ],
+                               [],
+                               [],
+                       ],
+                       [
+                               [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER_ID ] ],
+                               [ 'rc_user' ],
+                               [],
+                               [],
+                       ],
+                       [
+                               [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ],
+                               [ 'rc_comment' ],
+                               [],
+                               [],
+                       ],
+                       [
+                               [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_PATROL_INFO ] ],
+                               [ 'rc_patrolled', 'rc_log_type' ],
+                               [],
+                               [],
+                       ],
+                       [
+                               [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_SIZES ] ],
+                               [ 'rc_old_len', 'rc_new_len' ],
+                               [],
+                               [],
+                       ],
+                       [
+                               [ 'includeFields' => [ WatchedItemQueryService::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' => WatchedItemQueryService::DIR_OLDER ],
+                               [],
+                               [],
+                               [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ]
+                       ],
+                       [
+                               [ 'dir' => WatchedItemQueryService::DIR_NEWER ],
+                               [],
+                               [],
+                               [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ]
+                       ],
+                       [
+                               [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'start' => '20151212010101' ],
+                               [],
+                               [ "rc_timestamp <= '20151212010101'" ],
+                               [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ]
+                       ],
+                       [
+                               [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'end' => '20151212010101' ],
+                               [],
+                               [ "rc_timestamp >= '20151212010101'" ],
+                               [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ]
+                       ],
+                       [
+                               [
+                                       'dir' => WatchedItemQueryService::DIR_OLDER,
+                                       'start' => '20151212020101',
+                                       'end' => '20151212010101'
+                               ],
+                               [],
+                               [ "rc_timestamp <= '20151212020101'", "rc_timestamp >= '20151212010101'" ],
+                               [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ]
+                       ],
+                       [
+                               [ 'dir' => WatchedItemQueryService::DIR_NEWER, 'start' => '20151212010101' ],
+                               [],
+                               [ "rc_timestamp >= '20151212010101'" ],
+                               [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ]
+                       ],
+                       [
+                               [ 'dir' => WatchedItemQueryService::DIR_NEWER, 'end' => '20151212010101' ],
+                               [],
+                               [ "rc_timestamp <= '20151212010101'" ],
+                               [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ]
+                       ],
+                       [
+                               [
+                                       'dir' => WatchedItemQueryService::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' => [ WatchedItemQueryService::FILTER_MINOR ] ],
+                               [],
+                               [ 'rc_minor != 0' ],
+                               [],
+                       ],
+                       [
+                               [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_MINOR ] ],
+                               [],
+                               [ 'rc_minor = 0' ],
+                               [],
+                       ],
+                       [
+                               [ 'filters' => [ WatchedItemQueryService::FILTER_BOT ] ],
+                               [],
+                               [ 'rc_bot != 0' ],
+                               [],
+                       ],
+                       [
+                               [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_BOT ] ],
+                               [],
+                               [ 'rc_bot = 0' ],
+                               [],
+                       ],
+                       [
+                               [ 'filters' => [ WatchedItemQueryService::FILTER_ANON ] ],
+                               [],
+                               [ 'rc_user = 0' ],
+                               [],
+                       ],
+                       [
+                               [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_ANON ] ],
+                               [],
+                               [ 'rc_user != 0' ],
+                               [],
+                       ],
+                       [
+                               [ 'filters' => [ WatchedItemQueryService::FILTER_PATROLLED ] ],
+                               [],
+                               [ 'rc_patrolled != 0' ],
+                               [],
+                       ],
+                       [
+                               [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_PATROLLED ] ],
+                               [],
+                               [ 'rc_patrolled = 0' ],
+                               [],
+                       ],
+                       [
+                               [ 'filters' => [ WatchedItemQueryService::FILTER_UNREAD ] ],
+                               [],
+                               [ 'rc_timestamp >= wl_notificationtimestamp' ],
+                               [],
+                       ],
+                       [
+                               [ 'filters' => [ WatchedItemQueryService::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' => WatchedItemQueryService::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' => WatchedItemQueryService::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' => WatchedItemQueryService::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( [] ) );
+
+               $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+               $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+               $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options );
+
+               $this->assertEmpty( $items );
+       }
+
+       public function filterPatrolledOptionProvider() {
+               return [
+                       [ WatchedItemQueryService::FILTER_PATROLLED ],
+                       [ WatchedItemQueryService::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 );
+
+               $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+               $items = $queryService->getWatchedItemsWithRecentChangeInfo(
+                       $user,
+                       [ 'filters' => [ $filtersOption ] ]
+               );
+
+               $this->assertEmpty( $items );
+       }
+
+       public function mysqlIndexOptimizationProvider() {
+               return [
+                       [
+                               'mysql',
+                               [],
+                               [ "rc_timestamp > ''" ],
+                       ],
+                       [
+                               'mysql',
+                               [ 'start' => '20151212010101', 'dir' => WatchedItemQueryService::DIR_OLDER ],
+                               [ "rc_timestamp <= '20151212010101'" ],
+                       ],
+                       [
+                               'mysql',
+                               [ 'end' => '20151212010101', 'dir' => WatchedItemQueryService::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 ) );
+
+               $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+               $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+               $items = $queryService->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 );
+
+               $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+               $items = $queryService->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( [] ) );
+
+               $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+               $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+               $items = $queryService->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' => WatchedItemQueryService::DIR_OLDER, 'startFrom' => '20151212010101' ],
+                               'Bad value for parameter $options[\'startFrom\']: must be a two-element array',
+                       ],
+                       [
+                               [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'startFrom' => [ '20151212010101' ] ],
+                               'Bad value for parameter $options[\'startFrom\']: must be a two-element array',
+                       ],
+                       [
+                               [
+                                       'dir' => WatchedItemQueryService::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() );
+
+               $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+               $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+               $this->setExpectedException( InvalidArgumentException::class, $expectedInExceptionMessage );
+               $queryService->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( [] ) );
+
+               $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+               $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+               $items = $queryService->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( [] ) );
+
+               $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+               $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+               $items = $queryService->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( [] ) );
+
+               $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+               $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+               $otherUser = $this->getMockUnrestrictedNonAnonUserWithId( 2 );
+               $otherUser->expects( $this->once() )
+                       ->method( 'getOption' )
+                       ->with( 'watchlisttoken' )
+                       ->willReturn( '0123456789abcdef' );
+
+               $items = $queryService->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() );
+
+               $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $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' );
+               $queryService->getWatchedItemsWithRecentChangeInfo(
+                       $user,
+                       [ 'watchlistOwner' => $otherUser, 'watchlistOwnerToken' => $token ]
+               );
+       }
+
+}
index 898b58e..eaeb3ae 100644 (file)
@@ -821,8 +821,13 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase {
                );
                $this->watchPages( $user, [ $target ] );
 
-               $resultMinor = $this->doListWatchlistRequest( [ 'wlshow' => 'minor', 'wlprop' => 'flags' ] );
-               $resultNotMinor = $this->doListWatchlistRequest( [ 'wlshow' => '!minor', 'wlprop' => 'flags' ] );
+               $resultMinor = $this->doListWatchlistRequest( [
+                       'wlshow' => WatchedItemQueryService::FILTER_MINOR,
+                       'wlprop' => 'flags'
+               ] );
+               $resultNotMinor = $this->doListWatchlistRequest( [
+                       'wlshow' => WatchedItemQueryService::FILTER_NOT_MINOR, 'wlprop' => 'flags'
+               ] );
 
                $this->assertArraySubsetsEqual(
                        $this->getItemsFromApiResponse( $resultMinor ),
@@ -845,8 +850,12 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase {
                );
                $this->watchPages( $user, [ $target ] );
 
-               $resultBot = $this->doListWatchlistRequest( [ 'wlshow' => 'bot' ] );
-               $resultNotBot = $this->doListWatchlistRequest( [ 'wlshow' => '!bot' ] );
+               $resultBot = $this->doListWatchlistRequest( [
+                       'wlshow' => WatchedItemQueryService::FILTER_BOT
+               ] );
+               $resultNotBot = $this->doListWatchlistRequest( [
+                       'wlshow' => WatchedItemQueryService::FILTER_NOT_BOT
+               ] );
 
                $this->assertArraySubsetsEqual(
                        $this->getItemsFromApiResponse( $resultBot ),
@@ -870,11 +879,11 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase {
 
                $resultAnon = $this->doListWatchlistRequest( [
                        'wlprop' => 'user',
-                       'wlshow' => 'anon'
+                       'wlshow' => WatchedItemQueryService::FILTER_ANON
                ] );
                $resultNotAnon = $this->doListWatchlistRequest( [
                        'wlprop' => 'user',
-                       'wlshow' => '!anon'
+                       'wlshow' => WatchedItemQueryService::FILTER_NOT_ANON
                ] );
 
                $this->assertArraySubsetsEqual(
@@ -914,11 +923,11 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase {
 
                $resultUnread = $this->doListWatchlistRequest( [
                        'wlprop' => 'notificationtimestamp|title',
-                       'wlshow' => 'unread'
+                       'wlshow' => WatchedItemQueryService::FILTER_UNREAD
                ] );
                $resultNotUnread = $this->doListWatchlistRequest( [
                        'wlprop' => 'notificationtimestamp|title',
-                       'wlshow' => '!unread'
+                       'wlshow' => WatchedItemQueryService::FILTER_NOT_UNREAD
                ] );
 
                $this->assertEquals(
@@ -951,11 +960,11 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase {
 
                $resultPatrolled = $this->doListWatchlistRequest( [
                        'wlprop' => 'patrol',
-                       'wlshow' => 'patrolled'
+                       'wlshow' => WatchedItemQueryService::FILTER_PATROLLED
                ], $user );
                $resultNotPatrolled = $this->doListWatchlistRequest( [
                        'wlprop' => 'patrol',
-                       'wlshow' => '!patrolled'
+                       'wlshow' => WatchedItemQueryService::FILTER_NOT_PATROLLED
                ], $user );
 
                $this->assertEquals(