'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',
use SiteLookup;
use SiteStore;
use WatchedItemStore;
+use WatchedItemQueryService;
use SkinFactory;
use TitleFormatter;
use TitleParser;
return $this->getService( 'WatchedItemStore' );
}
+ /**
+ * @since 1.28
+ * @return WatchedItemQueryService
+ */
+ public function getWatchedItemQueryService() {
+ return $this->getService( 'WatchedItemQueryService' );
+ }
+
/**
* @since 1.28
* @return GenderCache
return $store;
},
+ 'WatchedItemQueryService' => function( MediaWikiServices $services ) {
+ return new WatchedItemQueryService( $services->getDBLoadBalancer() );
+ },
+
'LinkCache' => function( MediaWikiServices $services ) {
return new LinkCache(
$services->getTitleFormatter()
--- /dev/null
+<?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;
+ }
+
+}
* @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.
}
}
- $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',
}
}
- /* 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() );
}
$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'] );
}
}
}
}
}
- 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;
}
}
/* 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;
}
'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' => [
'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 ],
--- /dev/null
+<?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 ]
+ );
+ }
+
+}
);
$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 ),
);
$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 ),
$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(
$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(
$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(