WMDE-leszek has uploaded a new change for review. https://gerrit.wikimedia.org/r/278859
Change subject: [WIP]Use WatchedItemStore::getWatchedItemsForUser in ApiQueryWatchlistRaw ...................................................................... [WIP]Use WatchedItemStore::getWatchedItemsForUser in ApiQueryWatchlistRaw Adds number of options to WatchedItemStore::getWatchedItemsForUser. Also adds some tests for ApiQueryWatchlistRaw. TODO: more tests for ApiQueryWatchlistRaw? TODO: use WIS::getWatchedItemsForUser in ApiQueryWatchlistRaw::run Change-Id: I875a92074b52c00ac11db1fa05615abbf5262ab1 --- M includes/WatchedItemStore.php M tests/phpunit/includes/WatchedItemStoreUnitTest.php A tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php 3 files changed, 611 insertions(+), 11 deletions(-) git pull ssh://gerrit.wikimedia.org:29418/mediawiki/core refs/changes/59/278859/2 diff --git a/includes/WatchedItemStore.php b/includes/WatchedItemStore.php index 67ff15f..defa9c3 100644 --- a/includes/WatchedItemStore.php +++ b/includes/WatchedItemStore.php @@ -400,8 +400,17 @@ * @param User $user * @param array $options Allowed keys: * 'forWrite' => bool defaults to false - * 'namespaceId' => int optional namespace ID to filter by (default to all namespaces) * 'ordered' => bool optional ordering by namespace ID and title + * 'namespaceIds' => int[] optional namespace IDs to filter by (defaults to all namespaces) + * 'limit' => int maximum number of items to return + * 'filter' => string optional filter, allowed values: + * - changedSinceLastVisit, + * - changedBeforeLastVisit, + * 'orderDescending' => bool reverse ordering of items, defaults to false + * 'from' => LinkTarget requires 'ordered' key, only return items starting from + * those related to the link target + * 'until' => LinkTarget requires 'ordered' key, only return items until + * those related to the link target * * @return WatchedItem[] */ @@ -409,14 +418,16 @@ if ( !array_key_exists( 'forWrite', $options ) ) { $options['forWrite'] = false; } - - $conds = [ 'wl_user' => $user->getId() ]; - - $dbOptions = []; - if ( array_key_exists( 'ordered', $options ) && $options['ordered'] ) { - $dbOptions['ORDER BY'] = [ 'wl_namespace', 'wl_title' ]; + if ( !array_key_exists( 'namespaceIds', $options ) || !is_array( $options['namespaceIds'] ) ) { + $options['namespaceIds'] = []; } + $db = $this->getConnection( $options['forWrite'] ? DB_MASTER : DB_SLAVE ); + + $extraConds = $this->getWatchedItemsForUserQueryConds( $db, $options ); + $conds = array_merge( [ 'wl_user' => $user->getId() ], $extraConds ); + + $dbOptions = $this->getWatchedItemsForUserQueryDbOptions( $options ); $res = $db->select( 'watchlist', @@ -440,6 +451,72 @@ return $watchedItems; } + private function getWatchedItemsForUserQueryConds( IDatabase $db, array $options ) { + $conds = []; + if ( $options['namespaceIds'] ) { + $conds['wl_namespace'] = array_map( + function( $x ) { + return (int)$x; + }, + $options['namespaceIds'] + ); + } + if ( array_key_exists( 'filter', $options ) ) { + if ( $options['filter'] === 'changedSinceLastVisit' ) { + $conds[] = 'wl_notificationtimestamp IS NOT NULL'; + } + if ( $options['filter'] === 'changedBeforeLastVisit' ) { + $conds[] = 'wl_notificationtimestamp IS NULL'; + } + } + $ordered = array_key_exists( 'ordered', $options ) && $options['ordered']; + $reversedOrder = array_key_exists( 'orderDescending', $options ) && $options['orderDescending']; + if ( $ordered && ( + array_key_exists( 'from', $options ) || array_key_exists( 'until', $options ) + ) ) { + /** @var LinkTarget $target */ + if ( array_key_exists( 'from', $options ) ) { + $target = $options['from']; + $op = !$reversedOrder ? '>' : '<'; + } else { + $target = $options['until']; + $op = !$reversedOrder ? '<' : '>'; + } + $conds[] = $db->makeList( + [ + "wl_namespace $op " . $target->getNamespace(), + $db->makeList( + [ + 'wl_namespace = ' . $target->getNamespace(), + "wl_title $op= " . $db->addQuotes( $target->getDBkey() ) + ], + LIST_AND + ) + ], + LIST_OR + ); + } + return $conds; + } + + private function getWatchedItemsForUserQueryDbOptions( array $options ) { + $dbOptions = []; + if ( array_key_exists( 'ordered', $options ) && $options['ordered'] ) { + $sortSuffix = ''; + if ( array_key_exists( 'orderDescending', $options ) && $options['orderDescending'] ) { + $sortSuffix = ' DESC'; + } + $dbOptions['ORDER BY'] = [ 'wl_namespace' . $sortSuffix, 'wl_title' . $sortSuffix ]; + if ( count( $options['namespaceIds'] ) === 1 ) { + $dbOptions['ORDER BY'] = 'wl_title' . $sortSuffix; + } + } + if ( array_key_exists( 'limit', $options ) ) { + $dbOptions['LIMIT'] = (int)$options['limit']; + } + return $dbOptions; + } + /** * Must be called separately for Subject & Talk namespaces * diff --git a/tests/phpunit/includes/WatchedItemStoreUnitTest.php b/tests/phpunit/includes/WatchedItemStoreUnitTest.php index a7ec3dc..fc15927 100644 --- a/tests/phpunit/includes/WatchedItemStoreUnitTest.php +++ b/tests/phpunit/includes/WatchedItemStoreUnitTest.php @@ -1206,7 +1206,7 @@ /** * @dataProvider provideDbTypes */ - public function testGetWatchedItemsForUser_optionsAndEmptyResult( $forWrite, $dbType ) { + public function testGetWatchedItemsForUser_dbTypeOptions( $forWrite, $dbType ) { $mockDb = $this->getMockDb(); $mockCache = $this->getMockCache(); $mockLoadBalancer = $this->getMockLoadBalancer( $mockDb, $dbType ); @@ -1218,8 +1218,7 @@ 'watchlist', [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], [ 'wl_user' => 1 ], - $this->isType( 'string' ), - [ 'ORDER BY' => [ 'wl_namespace', 'wl_title' ] ] + $this->isType( 'string' ) ) ->will( $this->returnValue( [] ) ); @@ -1230,7 +1229,281 @@ $watchedItems = $store->getWatchedItemsForUser( $user, - [ 'forWrite' => $forWrite, 'ordered' => true ] + [ 'forWrite' => $forWrite ] + ); + $this->assertEquals( [], $watchedItems ); + } + + public function provideGetWatchedItemsForUserOptions() { + return [ + [ + [ + 'namespaceIds' => [ 0, 1 ], + ], + [ + 'wl_namespace' => [ 0, 1 ], + ], + [] + ], + [ + [ + 'ordered' => true, + ], + [], + [ 'ORDER BY' => [ 'wl_namespace', 'wl_title' ] ] + ], + [ + [ + 'namespaceIds' => [ 0 ], + 'ordered' => true, + ], + [ + 'wl_namespace' => [ 0 ], + ], + [ 'ORDER BY' => 'wl_title' ] + ], + [ + [ 'limit' => 10 ], + [], + [ 'LIMIT' => 10 ] + ], + [ + [ + 'namespaceIds' => [ 0, "1; DROP TABLE watchlist;\n--" ], + 'limit' => "10; DROP TABLE watchlist;\n--", + ], + [ + 'wl_namespace' => [ 0, 1 ], + ], + [ 'LIMIT' => 10 ] + ], + [ + [ 'filter' => 'changedSinceLastVisit' ], + [ 'wl_notificationtimestamp IS NOT NULL' ], + [] + ], + [ + [ 'filter' => 'changedBeforeLastVisit' ], + [ 'wl_notificationtimestamp IS NULL' ], + [] + ], + [ + [ + 'ordered' => true, + 'orderDescending' => true, + ], + [], + [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ] + ], + [ + [ + 'namespaceIds' => [ 0 ], + 'ordered' => true, + 'orderDescending' => true, + ], + [ + 'wl_namespace' => [ 0 ], + ], + [ 'ORDER BY' => 'wl_title DESC' ] + ], + [ + [ + 'namespaceIds' => 0, + ], + [], + [] + ], + [ + [ + 'namespaceIds' => "1; DROP TABLE watchlist;\n--", + ], + [], + [] + ], + [ + [ + 'orderDescending' => true, + ], + [], + [] + ], + ]; + } + + /** + * @dataProvider provideGetWatchedItemsForUserOptions + */ + public function testGetWatchedItemsForUser_optionsAndEmptyResult( + array $options, + array $expectedConds, + array $expectedDbOptions + ) { + $mockDb = $this->getMockDb(); + $mockCache = $this->getMockCache(); + $user = $this->getMockNonAnonUserWithId( 1 ); + + $expectedConds = array_merge( [ 'wl_user' => 1 ], $expectedConds ); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + $expectedConds, + $this->isType( 'string' ), + $expectedDbOptions + ) + ->will( $this->returnValue( [] ) ); + + $store = new WatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache + ); + + $watchedItems = $store->getWatchedItemsForUser( + $user, + $options + ); + $this->assertEquals( [], $watchedItems ); + } + + public function provideGetWatchedItemsForUser_fromUntilOptions() { + return [ + [ + [ + 'from' => new TitleValue( 0, 'SomeDbKey' ), + 'ordered' => true, + ], + [ + "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))", + ], + [ 'ORDER BY' => [ 'wl_namespace', 'wl_title' ] ] + ], + [ + [ + 'from' => new TitleValue( 0, 'SomeDbKey' ), + 'ordered' => true, + 'orderDescending' => true, + ], + [ + "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))", + ], + [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ] + ], + [ + [ + 'until' => new TitleValue( 0, 'SomeDbKey' ), + 'ordered' => true, + ], + [ + "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))", + ], + [ 'ORDER BY' => [ 'wl_namespace', 'wl_title' ] ] + ], + [ + [ + 'until' => new TitleValue( 0, 'SomeDbKey' ), + 'ordered' => true, + 'orderDescending' => true, + ], + [ + "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))", + ], + [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ] + ], + ]; + } + + /** + * @dataProvider provideGetWatchedItemsForUser_fromUntilOptions + */ + public function testGetWatchedItemsForUser_fromUntilOptions( + array $options, + array $expectedConds, + array $expectedDbOptions + ) { + $mockCache = $this->getMockCache(); + $user = $this->getMockNonAnonUserWithId( 1 ); + + $expectedConds = array_merge( [ 'wl_user' => 1 ], $expectedConds ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'addQuotes' ) + ->will( $this->returnCallback( function( $value ) { + return "'$value'"; + } ) ); + $mockDb->expects( $this->exactly( 2 ) ) + ->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 + ) ); + } ) ); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + $expectedConds, + $this->isType( 'string' ), + $expectedDbOptions + ) + ->will( $this->returnValue( [] ) ); + + $store = new WatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache + ); + + $watchedItems = $store->getWatchedItemsForUser( + $user, + $options + ); + $this->assertEquals( [], $watchedItems ); + } + + public function provideGetWatchedItemsForUser_fromUntilNoOrdered() { + return [ + [ [ 'from' => new TitleValue( 0, 'SomeDbKey' ), ] ], + [ [ 'until' => new TitleValue( 0, 'SomeDbKey' ), ] ], + ]; + } + + /** + * @dataProvider provideGetWatchedItemsForUser_fromUntilNoOrdered + */ + public function testGetWatchedItemsForUser_NoOrdered( array $options ) { + $mockCache = $this->getMockCache(); + $user = $this->getMockNonAnonUserWithId( 1 ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() )->method( 'addQuotes' ); + $mockDb->expects( $this->never() )->method( 'makeList' ); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + [ 'wl_user' => 1 ], + $this->isType( 'string' ), + [] + ) + ->will( $this->returnValue( [] ) ); + + $store = new WatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $mockCache + ); + + $watchedItems = $store->getWatchedItemsForUser( + $user, + $options ); $this->assertEquals( [], $watchedItems ); } diff --git a/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php b/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php new file mode 100644 index 0000000..9703f99 --- /dev/null +++ b/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php @@ -0,0 +1,250 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiQueryWatchlistRaw + */ +class ApiQueryWatchlistRawIntegrationTest extends ApiTestCase { + + protected function setUp() { + parent::setUp(); + self::$users['ApiQueryWatchlistRawIntegrationTestUser'] + = new TestUser( 'ApiQueryWatchlistRawIntegrationTestUser' ); + $this->doLogin( 'ApiQueryWatchlistRawIntegrationTestUser' ); + } + + public function addDBDataOnce() { + // TODO: is this needed at all? wl can include things that don't exist + $this->insertPage( 'ApiQueryWatchlistRawIntegrationTestPage1' ); + $this->insertPage( 'ApiQueryWatchlistRawIntegrationTestPage2' ); + $this->insertPage( 'ApiQueryWatchlistRawIntegrationTestPageA' ); + $this->insertPage( 'ApiQueryWatchlistRawIntegrationTestPageB' ); + + $testUser = new TestUser( 'ApiQueryWatchlistRawIntegrationTestUser' ); + $user = $testUser->getUser(); + $store = WatchedItemStore::getDefaultInstance(); + $store->addWatchBatch( [ + [ $user, new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ) ], + [ $user, new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage1' ) ], + [ $user, new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPageB' ) ], + [ $user, new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPageB' ) ], + [ $user, new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPageA' ) ], + [ $user, new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPageA' ) ], + [ $user, new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ) ], + ] ); + $otherUser = ( new TestUser( 'ApiQueryWatchlistRawIntegrationTestUser_otherUser' ) )->getUser(); + $store->updateNotificationTimestamp( + $otherUser, + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ), + '20151212010101' + ); + } + + public function testListWatchlistRaw_returnsWatchedItems() { + $result = $this->doApiRequest( [ + 'action' => 'query', + 'list' => 'watchlistraw', + ] ); + $resultHasExpectedPage = false; + foreach ( $result[0]['watchlistraw'] as $page ) { + if ( $page['ns'] === 0 && $page['title'] === 'ApiQueryWatchlistRawIntegrationTestPage1' ) { + $resultHasExpectedPage = true; + } + } + $this->assertTrue( $resultHasExpectedPage ); + } + + public function testPropChanged_addsNotificationTimestamp() { + $result = $this->doApiRequest( [ + 'action' => 'query', + 'list' => 'watchlistraw', + 'wrprop' => 'changed', + ] ); + $resultHasChangedField = false; + foreach ( $result[0]['watchlistraw'] as $page ) { + if ( array_key_exists( 'changed', $page ) ) { + $resultHasChangedField = true; + break; + } + } + $this->assertTrue( $resultHasChangedField ); + } + + public function testNamespaceParam() { + $result = $this->doApiRequest( [ + 'action' => 'query', + 'list' => 'watchlistraw', + 'wrnamespace' => '0', + ] ); + $resultHasTalkPage = false; + foreach ( $result[0]['watchlistraw'] as $page ) { + if ( $page['ns'] === 1 ) { + $resultHasTalkPage = true; + break; + } + } + $this->assertFalse( $resultHasTalkPage ); + } + + public function testShowChanged() { + $result = $this->doApiRequest( [ + 'action' => 'query', + 'list' => 'watchlistraw', + 'wrprop' => 'changed', + 'wrshow' => 'changed', + ] ); + $resultHasItemWithoutChangedField = false; + foreach ( $result[0]['watchlistraw'] as $page ) { + if ( !array_key_exists( 'changed', $page ) ) { + $resultHasItemWithoutChangedField = true; + break; + } + } + $this->assertFalse( $resultHasItemWithoutChangedField ); + } + + public function testShowNotChanged() { + $result = $this->doApiRequest( [ + 'action' => 'query', + 'list' => 'watchlistraw', + 'wrprop' => 'changed', + 'wrshow' => '!changed', + ] ); + $resultHasItemWithChangedField = false; + foreach ( $result[0]['watchlistraw'] as $page ) { + if ( array_key_exists( 'changed', $page ) ) { + $resultHasItemWithChangedField = true; + break; + } + } + $this->assertFalse( $resultHasItemWithChangedField ); + } + + public function testLimit() { + $resultWithoutLimit = $this->doApiRequest( [ + 'action' => 'query', + 'list' => 'watchlistraw', + ] ); + $this->assertGreaterThan( 5, count( $resultWithoutLimit[0]['watchlistraw'] ) ); + $this->assertCount( + 5, + $this->doApiRequest( [ + 'action' => 'query', + 'list' => 'watchlistraw', + 'wrlimit' => 5, + ] )[0]['watchlistraw'] + ); + } + + public function testDirAscending() { + $result = $this->doApiRequest( [ + 'action' => 'query', + 'list' => 'watchlistraw', + 'wrdir' => 'ascending', + ] ); + $resultsNotInAscOrder = false; + $pages = $result[0]['watchlistraw']; + $previous = array_shift( $pages ); + foreach ( $pages as $page ) { + if ( $previous['ns'] > $page['ns'] || ( + $previous['ns'] === $page['ns'] && $previous['title'] > $page['title'] + ) ) { + $resultsNotInAscOrder = true; + break; + } + } + $this->assertFalse( $resultsNotInAscOrder ); + } + + public function testDirDescending() { + $result = $this->doApiRequest( [ + 'action' => 'query', + 'list' => 'watchlistraw', + 'wrdir' => 'descending', + ] ); + $resultsNotInDescOrder = false; + $pages = $result[0]['watchlistraw']; + $previous = array_shift( $pages ); + foreach ( $pages as $page ) { + if ( $previous['ns'] < $page['ns'] || ( + $previous['ns'] === $page['ns'] && $previous['title'] < $page['title'] + ) ) { + $resultsNotInDescOrder = true; + break; + } + } + $this->assertFalse( $resultsNotInDescOrder ); + } + + public function testAscendingIsDefaultOrder() { + $resultNoDir = $this->doApiRequest( [ + 'action' => 'query', + 'list' => 'watchlistraw', + ] ); + $resultAscDir = $this->doApiRequest( [ + 'action' => 'query', + 'list' => 'watchlistraw', + 'wrdir' => 'ascending', + ] ); + $this->assertEquals( $resultAscDir[0]['watchlistraw'], $resultNoDir[0]['watchlistraw'] ); + } + + public function testFromTitle() { + $result = $this->doApiRequest( [ + 'action' => 'query', + 'list' => 'watchlistraw', + 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPageA', + ] ); + $pages = $result[0]['watchlistraw']; + $this->assertEquals( 'ApiQueryWatchlistRawIntegrationTestPageA', $pages[0]['title'] ); + $this->assertEquals( 0, $pages[0]['ns'] ); + $resultContainsNotExpectedPage = false; + foreach ( $pages as $page ) { + if ( $page['title'] === 'ApiQueryWatchlistRawIntegrationTestPage1' ) { + $resultContainsNotExpectedPage = true; + } + } + $this->assertFalse( $resultContainsNotExpectedPage ); + } + + public function testToTitle() { + $result = $this->doApiRequest( [ + 'action' => 'query', + 'list' => 'watchlistraw', + 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPageA', + ] ); + $pages = $result[0]['watchlistraw']; + $lastIndex = count( $pages ) - 1; + $this->assertEquals( 'ApiQueryWatchlistRawIntegrationTestPageA', $pages[$lastIndex]['title'] ); + $this->assertEquals( 0, $pages[$lastIndex]['ns'] ); + $resultContainsNotExpectedPage = false; + foreach ( $pages as $page ) { + if ( $page['title'] === 'ApiQueryWatchlistRawIntegrationTestPageB' ) { + $resultContainsNotExpectedPage = true; + } + } + $this->assertFalse( $resultContainsNotExpectedPage ); + } + + public function testContinue() { + $result = $this->doApiRequest( [ + 'action' => 'query', + 'list' => 'watchlistraw', + 'wrcontinue' => '0|ApiQueryWatchlistRawIntegrationTestPageA', + ] ); + $pages = $result[0]['watchlistraw']; + $this->assertEquals( 'ApiQueryWatchlistRawIntegrationTestPageA', $pages[0]['title'] ); + $this->assertEquals( 0, $pages[0]['ns'] ); + $resultContainsNotExpectedPage = false; + foreach ( $pages as $page ) { + if ( $page['title'] === 'ApiQueryWatchlistRawIntegrationTestPage1' ) { + $resultContainsNotExpectedPage = true; + } + } + $this->assertFalse( $resultContainsNotExpectedPage ); + } + +} -- To view, visit https://gerrit.wikimedia.org/r/278859 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: newchange Gerrit-Change-Id: I875a92074b52c00ac11db1fa05615abbf5262ab1 Gerrit-PatchSet: 2 Gerrit-Project: mediawiki/core Gerrit-Branch: master Gerrit-Owner: WMDE-leszek <leszek.mani...@wikimedia.de> Gerrit-Reviewer: Addshore <addshorew...@gmail.com> Gerrit-Reviewer: Tobias Gritschacher <tobias.gritschac...@wikimedia.de> Gerrit-Reviewer: WMDE-Fisch <christoph.fisc...@wikimedia.de> Gerrit-Reviewer: WMDE-leszek <leszek.mani...@wikimedia.de> Gerrit-Reviewer: jenkins-bot <> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits