*/
private $loadBalancer;
+ /** @var WatchedItemQueryServiceExtension[]|null */
+ private $extensions = null;
+
public function __construct( LoadBalancer $loadBalancer ) {
$this->loadBalancer = $loadBalancer;
}
+ /**
+ * @return WatchedItemQueryServiceExtension[]
+ */
+ private function getExtensions() {
+ if ( $this->extensions === null ) {
+ $this->extensions = [];
+ Hooks::run( 'WatchedItemQueryServiceExtensions', [ &$this->extensions, $this ] );
+ }
+ return $this->extensions;
+ }
+
/**
* @return IDatabase
* @throws MWException
* 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
* 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)
+ * @param array|null &$startFrom Continuation value: [ string $rcTimestamp, int $rcId ]
* @return array of pairs ( WatchedItem $watchedItem, string[] $recentChangeInfo ),
* where $recentChangeInfo contains the following keys:
* - 'rc_id',
* - 'rc_deleted',
* Additional keys could be added by specifying the 'includeFields' option
*/
- public function getWatchedItemsWithRecentChangeInfo( User $user, array $options = [] ) {
+ public function getWatchedItemsWithRecentChangeInfo(
+ User $user, array $options = [], &$startFrom = null
+ ) {
$options += [
'includeFields' => [],
'namespaceIds' => [],
'must be DIR_OLDER or DIR_NEWER'
);
Assert::parameter(
- !isset( $options['start'] ) && !isset( $options['end'] ) && !isset( $options['startFrom'] )
+ !isset( $options['start'] ) && !isset( $options['end'] ) && $startFrom === null
|| isset( $options['dir'] ),
'$options[\'dir\']',
- 'must be provided when providing any of options: start, end, startFrom'
+ 'must be provided when providing the "start" or "end" options or the $startFrom parameter'
);
Assert::parameter(
- !isset( $options['startFrom'] )
- || ( is_array( $options['startFrom'] ) && count( $options['startFrom'] ) === 2 ),
+ !isset( $options['startFrom'] ),
'$options[\'startFrom\']',
+ 'must not be provided, use $startFrom instead'
+ );
+ Assert::parameter(
+ !isset( $startFrom ) || ( is_array( $startFrom ) && count( $startFrom ) === 2 ),
+ '$startFrom',
'must be a two-element array'
);
if ( array_key_exists( 'watchlistOwner', $options ) ) {
$dbOptions = $this->getWatchedItemsWithRCInfoQueryDbOptions( $options );
$joinConds = $this->getWatchedItemsWithRCInfoQueryJoinConds( $options );
+ if ( $startFrom !== null ) {
+ $conds[] = $this->getStartFromConds( $db, $options, $startFrom );
+ }
+
+ foreach ( $this->getExtensions() as $extension ) {
+ $extension->modifyWatchedItemsWithRCInfoQuery(
+ $user, $options, $db,
+ $tables,
+ $fields,
+ $conds,
+ $dbOptions,
+ $joinConds
+ );
+ }
+
$res = $db->select(
$tables,
$fields,
$joinConds
);
+ $limit = isset( $dbOptions['LIMIT'] ) ? $dbOptions['LIMIT'] : INF;
$items = [];
+ $startFrom = null;
foreach ( $res as $row ) {
+ if ( --$limit <= 0 ) {
+ $startFrom = [ $row->rc_timestamp, $row->rc_id ];
+ break;
+ }
+
$items[] = [
new WatchedItem(
$user,
];
}
+ foreach ( $this->getExtensions() as $extension ) {
+ $extension->modifyWatchedItemsWithRCInfo( $user, $options, $db, $items, $res, $startFrom );
+ }
+
return $items;
}
$conds[] = $deletedPageLogCond;
}
- if ( array_key_exists( 'startFrom', $options ) ) {
- $conds[] = $this->getStartFromConds( $db, $options );
- }
-
return $conds;
}
return '';
}
- private function getStartFromConds( IDatabase $db, array $options ) {
+ private function getStartFromConds( IDatabase $db, array $options, array $startFrom ) {
$op = $options['dir'] === self::DIR_OLDER ? '<' : '>';
- list( $rcTimestamp, $rcId ) = $options['startFrom'];
+ list( $rcTimestamp, $rcId ) = $startFrom;
$rcTimestamp = $db->addQuotes( $db->timestamp( $rcTimestamp ) );
$rcId = (int)$rcId;
return $db->makeList(
}
if ( array_key_exists( 'limit', $options ) ) {
- $dbOptions['LIMIT'] = (int)$options['limit'];
+ $dbOptions['LIMIT'] = (int)$options['limit'] + 1;
}
return $dbOptions;
'(rc_this_oldid=page_latest) OR (rc_type=3)',
],
$this->isType( 'string' ),
- [],
+ [
+ 'LIMIT' => 3,
+ ],
[
'watchlist' => [
'INNER JOIN',
'rc_deleted' => 0,
'wl_notificationtimestamp' => null,
] ),
+ $this->getFakeRow( [
+ 'rc_id' => 3,
+ 'rc_namespace' => 1,
+ 'rc_title' => 'Foo3',
+ 'rc_timestamp' => '20151212010103',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ 'wl_notificationtimestamp' => null,
+ ] ),
] ) );
$queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
$user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
- $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user );
+ $startFrom = null;
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user, [ 'limit' => 2 ], $startFrom
+ );
+
+ $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]
+ );
+
+ $this->assertEquals( [ '20151212010103', 3 ], $startFrom );
+ }
+
+ public function testGetWatchedItemsWithRecentChangeInfo_extension() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist', 'page', 'extension_dummy_table' ],
+ [
+ 'rc_id',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_timestamp',
+ 'rc_type',
+ 'rc_deleted',
+ 'wl_notificationtimestamp',
+ 'rc_cur_id',
+ 'rc_this_oldid',
+ 'rc_last_oldid',
+ 'extension_dummy_field',
+ ],
+ [
+ 'wl_user' => 1,
+ '(rc_this_oldid=page_latest) OR (rc_type=3)',
+ 'extension_dummy_cond',
+ ],
+ $this->isType( 'string' ),
+ [
+ 'extension_dummy_option',
+ ],
+ [
+ 'watchlist' => [
+ 'INNER JOIN',
+ [
+ 'wl_namespace=rc_namespace',
+ 'wl_title=rc_title'
+ ]
+ ],
+ 'page' => [
+ 'LEFT JOIN',
+ 'rc_cur_id=page_id',
+ ],
+ 'extension_dummy_join_cond' => [],
+ ]
+ )
+ ->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,
+ ] ),
+ ] ) );
+
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $mockExtension = $this->getMockBuilder( WatchedItemQueryServiceExtension::class )
+ ->getMock();
+ $mockExtension->expects( $this->once() )
+ ->method( 'modifyWatchedItemsWithRCInfoQuery' )
+ ->with(
+ $this->identicalTo( $user ),
+ $this->isType( 'array' ),
+ $this->isInstanceOf( IDatabase::class ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' )
+ )
+ ->will( $this->returnCallback( function (
+ $user, $options, $db, &$tables, &$fields, &$conds, &$dbOptions, &$joinConds
+ ) {
+ $tables[] = 'extension_dummy_table';
+ $fields[] = 'extension_dummy_field';
+ $conds[] = 'extension_dummy_cond';
+ $dbOptions[] = 'extension_dummy_option';
+ $joinConds['extension_dummy_join_cond'] = [];
+ } ) );
+ $mockExtension->expects( $this->once() )
+ ->method( 'modifyWatchedItemsWithRCInfo' )
+ ->with(
+ $this->identicalTo( $user ),
+ $this->isType( 'array' ),
+ $this->isInstanceOf( IDatabase::class ),
+ $this->isType( 'array' ),
+ $this->anything(),
+ $this->anything() // Can't test for null here, PHPUnit applies this after the callback
+ )
+ ->will( $this->returnCallback( function ( $user, $options, $db, &$items, $res, &$startFrom ) {
+ foreach ( $items as $i => &$item ) {
+ $item[1]['extension_dummy_field'] = $i;
+ }
+ unset( $item );
+
+ $this->assertNull( $startFrom );
+ $startFrom = [ '20160203123456', 42 ];
+ } ) );
+
+ $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
+ TestingAccessWrapper::newFromObject( $queryService )->extensions = [ $mockExtension ];
+
+ $startFrom = null;
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user, [], $startFrom
+ );
$this->assertInternalType( 'array', $items );
$this->assertCount( 2, $items );
'rc_timestamp' => '20151212010101',
'rc_type' => RC_NEW,
'rc_deleted' => 0,
+ 'extension_dummy_field' => 0,
],
$items[0][1]
);
'rc_timestamp' => '20151212010102',
'rc_type' => RC_NEW,
'rc_deleted' => 0,
+ 'extension_dummy_field' => 1,
],
$items[1][1]
);
+
+ $this->assertEquals( [ '20160203123456', 42 ], $startFrom );
}
public function getWatchedItemsWithRecentChangeInfoOptionsProvider() {
return [
[
[ 'includeFields' => [ WatchedItemQueryService::INCLUDE_FLAGS ] ],
+ null,
[ 'rc_type', 'rc_minor', 'rc_bot' ],
[],
[],
],
[
[ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER ] ],
+ null,
[ 'rc_user_text' ],
[],
[],
],
[
[ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER_ID ] ],
+ null,
[ 'rc_user' ],
[],
[],
],
[
[ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ],
+ null,
[ 'rc_comment' ],
[],
[],
],
[
[ 'includeFields' => [ WatchedItemQueryService::INCLUDE_PATROL_INFO ] ],
+ null,
[ 'rc_patrolled', 'rc_log_type' ],
[],
[],
],
[
[ 'includeFields' => [ WatchedItemQueryService::INCLUDE_SIZES ] ],
+ null,
[ 'rc_old_len', 'rc_new_len' ],
[],
[],
],
[
[ 'includeFields' => [ WatchedItemQueryService::INCLUDE_LOG_INFO ] ],
+ null,
[ 'rc_logid', 'rc_log_type', 'rc_log_action', 'rc_params' ],
[],
[],
],
[
[ 'namespaceIds' => [ 0, 1 ] ],
+ null,
[],
[ 'wl_namespace' => [ 0, 1 ] ],
[],
],
[
[ 'namespaceIds' => [ 0, "1; DROP TABLE watchlist;\n--" ] ],
+ null,
[],
[ 'wl_namespace' => [ 0, 1 ] ],
[],
],
[
[ 'rcTypes' => [ RC_EDIT, RC_NEW ] ],
+ null,
[],
[ 'rc_type' => [ RC_EDIT, RC_NEW ] ],
[],
],
[
[ 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ null,
[],
[],
[ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ]
],
[
[ 'dir' => WatchedItemQueryService::DIR_NEWER ],
+ null,
[],
[],
[ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ]
],
[
[ 'dir' => WatchedItemQueryService::DIR_OLDER, 'start' => '20151212010101' ],
+ null,
[],
[ "rc_timestamp <= '20151212010101'" ],
[ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ]
],
[
[ 'dir' => WatchedItemQueryService::DIR_OLDER, 'end' => '20151212010101' ],
+ null,
[],
[ "rc_timestamp >= '20151212010101'" ],
[ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ]
'start' => '20151212020101',
'end' => '20151212010101'
],
+ null,
[],
[ "rc_timestamp <= '20151212020101'", "rc_timestamp >= '20151212010101'" ],
[ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ]
],
[
[ 'dir' => WatchedItemQueryService::DIR_NEWER, 'start' => '20151212010101' ],
+ null,
[],
[ "rc_timestamp >= '20151212010101'" ],
[ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ]
],
[
[ 'dir' => WatchedItemQueryService::DIR_NEWER, 'end' => '20151212010101' ],
+ null,
[],
[ "rc_timestamp <= '20151212010101'" ],
[ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ]
'start' => '20151212010101',
'end' => '20151212020101'
],
+ null,
[],
[ "rc_timestamp >= '20151212010101'", "rc_timestamp <= '20151212020101'" ],
[ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ]
],
[
[ 'limit' => 10 ],
+ null,
[],
[],
- [ 'LIMIT' => 10 ],
+ [ 'LIMIT' => 11 ],
],
[
[ 'limit' => "10; DROP TABLE watchlist;\n--" ],
+ null,
[],
[],
- [ 'LIMIT' => 10 ],
+ [ 'LIMIT' => 11 ],
],
[
[ 'filters' => [ WatchedItemQueryService::FILTER_MINOR ] ],
+ null,
[],
[ 'rc_minor != 0' ],
[],
],
[
[ 'filters' => [ WatchedItemQueryService::FILTER_NOT_MINOR ] ],
+ null,
[],
[ 'rc_minor = 0' ],
[],
],
[
[ 'filters' => [ WatchedItemQueryService::FILTER_BOT ] ],
+ null,
[],
[ 'rc_bot != 0' ],
[],
],
[
[ 'filters' => [ WatchedItemQueryService::FILTER_NOT_BOT ] ],
+ null,
[],
[ 'rc_bot = 0' ],
[],
],
[
[ 'filters' => [ WatchedItemQueryService::FILTER_ANON ] ],
+ null,
[],
[ 'rc_user = 0' ],
[],
],
[
[ 'filters' => [ WatchedItemQueryService::FILTER_NOT_ANON ] ],
+ null,
[],
[ 'rc_user != 0' ],
[],
],
[
[ 'filters' => [ WatchedItemQueryService::FILTER_PATROLLED ] ],
+ null,
[],
[ 'rc_patrolled != 0' ],
[],
],
[
[ 'filters' => [ WatchedItemQueryService::FILTER_NOT_PATROLLED ] ],
+ null,
[],
[ 'rc_patrolled = 0' ],
[],
],
[
[ 'filters' => [ WatchedItemQueryService::FILTER_UNREAD ] ],
+ null,
[],
[ 'rc_timestamp >= wl_notificationtimestamp' ],
[],
],
[
[ 'filters' => [ WatchedItemQueryService::FILTER_NOT_UNREAD ] ],
+ null,
[],
[ 'wl_notificationtimestamp IS NULL OR rc_timestamp < wl_notificationtimestamp' ],
[],
],
[
[ 'onlyByUser' => 'SomeOtherUser' ],
+ null,
[],
[ 'rc_user_text' => 'SomeOtherUser' ],
[],
],
[
[ 'notByUser' => 'SomeOtherUser' ],
+ null,
[],
[ "rc_user_text != 'SomeOtherUser'" ],
[],
],
[
- [ 'startFrom' => [ '20151212010101', 123 ], 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ [ '20151212010101', 123 ],
[],
[
"(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 ],
+ [ 'dir' => WatchedItemQueryService::DIR_NEWER ],
+ [ '20151212010101', 123 ],
[],
[
"(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
- ],
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ [ '20151212010101', "123; DROP TABLE watchlist;\n--" ],
[],
[
"(rc_timestamp < '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id <= 123))"
*/
public function testGetWatchedItemsWithRecentChangeInfo_optionsAndEmptyResult(
array $options,
+ $startFrom,
array $expectedExtraFields,
array $expectedExtraConds,
array $expectedDbOptions
$queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) );
$user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
- $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options );
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options, $startFrom );
$this->assertEmpty( $items );
+ $this->assertNull( $startFrom );
}
public function filterPatrolledOptionProvider() {
return [
[
[ 'rcTypes' => [ 1337 ] ],
+ null,
'Bad value for parameter $options[\'rcTypes\']',
],
[
[ 'rcTypes' => [ 'edit' ] ],
+ null,
'Bad value for parameter $options[\'rcTypes\']',
],
[
[ 'rcTypes' => [ RC_EDIT, 1337 ] ],
+ null,
'Bad value for parameter $options[\'rcTypes\']',
],
[
[ 'dir' => 'foo' ],
+ null,
'Bad value for parameter $options[\'dir\']',
],
[
[ 'start' => '20151212010101' ],
+ null,
'Bad value for parameter $options[\'dir\']: must be provided',
],
[
[ 'end' => '20151212010101' ],
+ null,
'Bad value for parameter $options[\'dir\']: must be provided',
],
[
- [ 'startFrom' => [ '20151212010101', 123 ] ],
+ [],
+ [ '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 ],
+ '20151212010101',
+ 'Bad value for parameter $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 ],
+ [ '20151212010101' ],
+ 'Bad value for parameter $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',
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ [ '20151212010101', 123, 'foo' ],
+ 'Bad value for parameter $startFrom: must be a two-element array',
],
[
[ 'watchlistOwner' => $this->getMockUnrestrictedNonAnonUserWithId( 2 ) ],
+ null,
'Bad value for parameter $options[\'watchlistOwnerToken\']',
],
[
[ 'watchlistOwner' => 'Other User', 'watchlistOwnerToken' => 'some-token' ],
+ null,
'Bad value for parameter $options[\'watchlistOwner\']',
],
];
*/
public function testGetWatchedItemsWithRecentChangeInfo_invalidOptions(
array $options,
+ $startFrom,
$expectedInExceptionMessage
) {
$mockDb = $this->getMockDb();
$user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
$this->setExpectedException( InvalidArgumentException::class, $expectedInExceptionMessage );
- $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options );
+ $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options, $startFrom );
}
public function testGetWatchedItemsWithRecentChangeInfo_usedInGeneratorOptionAndEmptyResult() {