Merge "Add WatchedItemStore::getWatchedItemsForUser"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Wed, 23 Mar 2016 15:45:45 +0000 (15:45 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Wed, 23 Mar 2016 15:45:45 +0000 (15:45 +0000)
1  2 
includes/WatchedItemStore.php
tests/phpunit/includes/WatchedItemStoreIntegrationTest.php
tests/phpunit/includes/WatchedItemStoreUnitTest.php

@@@ -339,100 -339,6 +339,100 @@@ class WatchedItemStore 
                return $watchCounts;
        }
  
 +      /**
 +       * Number of watchers of each page who have visited recent edits to that page
 +       *
 +       * @param array $targetsWithVisitThresholds array of pairs (LinkTarget $target, mixed $threshold),
 +       *        $threshold is:
 +       *        - a timestamp of the recent edit if $target exists (format accepted by wfTimestamp)
 +       *        - null if $target doesn't exist
 +       * @param int|null $minimumWatchers
 +       * @return array multi-dimensional like $return[$namespaceId][$titleString] = $watchers,
 +       *         where $watchers is an int:
 +       *         - if the page exists, number of users watching who have visited the page recently
 +       *         - if the page doesn't exist, number of users that have the page on their watchlist
 +       *         - 0 means there are no visiting watchers or their number is below the minimumWatchers
 +       *         option (if passed).
 +       */
 +      public function countVisitingWatchersMultiple(
 +              array $targetsWithVisitThresholds,
 +              $minimumWatchers = null
 +      ) {
 +              $dbr = $this->getConnection( DB_SLAVE );
 +
 +              $conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds );
 +
 +              $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
 +              if ( $minimumWatchers !== null ) {
 +                      $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
 +              }
 +              $res = $dbr->select(
 +                      'watchlist',
 +                      [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
 +                      $conds,
 +                      __METHOD__,
 +                      $dbOptions
 +              );
 +
 +              $this->reuseConnection( $dbr );
 +
 +              $watcherCounts = [];
 +              foreach ( $targetsWithVisitThresholds as list( $target ) ) {
 +                      /* @var LinkTarget $target */
 +                      $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
 +              }
 +
 +              foreach ( $res as $row ) {
 +                      $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
 +              }
 +
 +              return $watcherCounts;
 +      }
 +
 +      /**
 +       * Generates condition for the query used in a batch count visiting watchers.
 +       *
 +       * @param IDatabase $db
 +       * @param array $targetsWithVisitThresholds array of pairs (LinkTarget, last visit threshold)
 +       * @return string
 +       */
 +      private function getVisitingWatchersCondition(
 +              IDatabase $db,
 +              array $targetsWithVisitThresholds
 +      ) {
 +              $missingTargets = [];
 +              $namespaceConds = [];
 +              foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
 +                      if ( $threshold === null ) {
 +                              $missingTargets[] = $target;
 +                              continue;
 +                      }
 +                      /* @var LinkTarget $target */
 +                      $namespaceConds[$target->getNamespace()][] = $db->makeList( [
 +                              'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
 +                              $db->makeList( [
 +                                      'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
 +                                      'wl_notificationtimestamp IS NULL'
 +                              ], LIST_OR )
 +                      ], LIST_AND );
 +              }
 +
 +              $conds = [];
 +              foreach ( $namespaceConds as $namespace => $pageConds ) {
 +                      $conds[] = $db->makeList( [
 +                              'wl_namespace = ' . $namespace,
 +                              '(' . $db->makeList( $pageConds, LIST_OR ) . ')'
 +                      ], LIST_AND );
 +              }
 +
 +              if ( $missingTargets ) {
 +                      $lb = new LinkBatch( $missingTargets );
 +                      $conds[] = $lb->constructSet( 'wl', $db );
 +              }
 +
 +              return $db->makeList( $conds, LIST_OR );
 +      }
 +
        /**
         * Get an item (may be cached)
         *
                return $item;
        }
  
+       /**
+        * @param User $user
+        * @param array $options Allowed keys:
+        *        'forWrite' => bool defaults to false
+        *
+        * @return WatchedItem[]
+        */
+       public function getWatchedItemsForUser( User $user, array $options = [] ) {
+               $options += [ 'forWrite' => false ];
+               $db = $this->getConnection( $options['forWrite'] ? DB_MASTER : DB_SLAVE );
+               $res = $db->select(
+                       'watchlist',
+                       [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+                       [ 'wl_user' => $user->getId() ],
+                       __METHOD__
+               );
+               $this->reuseConnection( $db );
+               $watchedItems = [];
+               foreach ( $res as $row ) {
+                       // todo these could all be cached at some point?
+                       $watchedItems[] = new WatchedItem(
+                               $user,
+                               new TitleValue( (int)$row->wl_namespace, $row->wl_title ),
+                               $row->wl_notificationtimestamp
+                       );
+               }
+               return $watchedItems;
+       }
        /**
         * Must be called separately for Subject & Talk namespaces
         *
                return (bool)$this->getWatchedItem( $user, $target );
        }
  
 +      /**
 +       * @param User $user
 +       * @param LinkTarget[] $targets
 +       *
 +       * @return array multi-dimensional like $return[$namespaceId][$titleString] = $timestamp,
 +       *         where $timestamp is:
 +       *         - string|null value of wl_notificationtimestamp,
 +       *         - false if $target is not watched by $user.
 +       */
 +      public function getNotificationTimestampsBatch( User $user, array $targets ) {
 +              $timestamps = [];
 +              foreach ( $targets as $target ) {
 +                      $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
 +              }
 +
 +              if ( $user->isAnon() ) {
 +                      return $timestamps;
 +              }
 +
 +              $targetsToLoad = [];
 +              foreach ( $targets as $target ) {
 +                      $cachedItem = $this->getCached( $user, $target );
 +                      if ( $cachedItem ) {
 +                              $timestamps[$target->getNamespace()][$target->getDBkey()] =
 +                                      $cachedItem->getNotificationTimestamp();
 +                      } else {
 +                              $targetsToLoad[] = $target;
 +                      }
 +              }
 +
 +              if ( !$targetsToLoad ) {
 +                      return $timestamps;
 +              }
 +
 +              $dbr = $this->getConnection( DB_SLAVE );
 +
 +              $lb = new LinkBatch( $targetsToLoad );
 +              $res = $dbr->select(
 +                      'watchlist',
 +                      [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
 +                      [
 +                              $lb->constructSet( 'wl', $dbr ),
 +                              'wl_user' => $user->getId(),
 +                      ],
 +                      __METHOD__
 +              );
 +              $this->reuseConnection( $dbr );
 +
 +              foreach ( $res as $row ) {
 +                      $timestamps[(int)$row->wl_namespace][$row->wl_title] = $row->wl_notificationtimestamp;
 +              }
 +
 +              return $timestamps;
 +      }
 +
        /**
         * Must be called separately for Subject & Talk namespaces
         *
@@@ -39,6 -39,21 +39,21 @@@ class WatchedItemStoreIntegrationTest e
                        'Page should be watched'
                );
                $this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) );
+               $watchedItemsForUser = $store->getWatchedItemsForUser( $user );
+               $this->assertCount( $initialUserWatchedItems + 1, $watchedItemsForUser );
+               $watchedItemsForUserHasExpectedItem = false;
+               foreach ( $watchedItemsForUser as $watchedItem ) {
+                       if (
+                               $watchedItem->getUser()->equals( $user ) &&
+                               $watchedItem->getLinkTarget() == $title->getTitleValue()
+                       ) {
+                               $watchedItemsForUserHasExpectedItem = true;
+                       }
+               }
+               $this->assertTrue(
+                       $watchedItemsForUserHasExpectedItem,
+                       'getWatchedItemsForUser should contain the page'
+               );
                $this->assertEquals( $initialWatchers + 1, $store->countWatchers( $title ) );
                $this->assertEquals(
                        $initialWatchers + 1,
                        [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => 0 ] ],
                        $store->countWatchersMultiple( [ $title ], [ 'minimumWatchers' => $initialWatchers + 2 ] )
                );
 +              $this->assertEquals(
 +                      [ $title->getNamespace() => [ $title->getDBkey() => null ] ],
 +                      $store->getNotificationTimestampsBatch( $user, [ $title ] )
 +              );
  
                $store->removeWatch( $user, $title );
                $this->assertFalse(
                        'Page should be unwatched'
                );
                $this->assertEquals( $initialUserWatchedItems, $store->countWatchedItems( $user ) );
+               $watchedItemsForUser = $store->getWatchedItemsForUser( $user );
+               $this->assertCount( $initialUserWatchedItems, $watchedItemsForUser );
+               $watchedItemsForUserHasExpectedItem = false;
+               foreach ( $watchedItemsForUser as $watchedItem ) {
+                       if (
+                               $watchedItem->getUser()->equals( $user ) &&
+                               $watchedItem->getLinkTarget() == $title->getTitleValue()
+                       ) {
+                               $watchedItemsForUserHasExpectedItem = true;
+                       }
+               }
+               $this->assertFalse(
+                       $watchedItemsForUserHasExpectedItem,
+                       'getWatchedItemsForUser should not contain the page'
+               );
                $this->assertEquals( $initialWatchers, $store->countWatchers( $title ) );
                $this->assertEquals(
                        $initialWatchers,
                        $store->countWatchersMultiple( [ $title ] )[$title->getNamespace()][$title->getDBkey()]
                );
 +              $this->assertEquals(
 +                      [ $title->getNamespace() => [ $title->getDBkey() => false ] ],
 +                      $store->getNotificationTimestampsBatch( $user, [ $title ] )
 +              );
        }
  
        public function testUpdateAndResetNotificationTimestamp() {
                        '20150202010101',
                        $store->loadWatchedItem( $user, $title )->getNotificationTimestamp()
                );
 +              $this->assertEquals(
 +                      [ $title->getNamespace() => [ $title->getDBkey() => '20150202010101' ] ],
 +                      $store->getNotificationTimestampsBatch( $user, [ $title ] )
 +              );
                $this->assertEquals(
                        $initialVisitingWatchers - 1,
                        $store->countVisitingWatchers( $title, '20150202020202' )
                );
 +              $this->assertEquals(
 +                      $initialVisitingWatchers - 1,
 +                      $store->countVisitingWatchersMultiple(
 +                              [ [ $title, '20150202020202' ] ]
 +                      )[$title->getNamespace()][$title->getDBkey()]
 +              );
                $this->assertEquals(
                        $initialUnreadNotifications + 1,
                        $store->countUnreadNotifications( $user )
  
                $this->assertTrue( $store->resetNotificationTimestamp( $user, $title ) );
                $this->assertNull( $store->getWatchedItem( $user, $title )->getNotificationTimestamp() );
 +              $this->assertEquals(
 +                      [ $title->getNamespace() => [ $title->getDBkey() => null ] ],
 +                      $store->getNotificationTimestampsBatch( $user, [ $title ] )
 +              );
                $this->assertEquals(
                        $initialVisitingWatchers,
                        $store->countVisitingWatchers( $title, '20150202020202' )
                );
 +              $this->assertEquals(
 +                      $initialVisitingWatchers,
 +                      $store->countVisitingWatchersMultiple(
 +                              [ [ $title, '20150202020202' ] ]
 +                      )[$title->getNamespace()][$title->getDBkey()]
 +              );
 +              $this->assertEquals(
 +                      [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => $initialVisitingWatchers ] ],
 +                      $store->countVisitingWatchersMultiple(
 +                              [ [ $title, '20150202020202' ] ], $initialVisitingWatchers
 +                      )
 +              );
 +              $this->assertEquals(
 +                      [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => 0 ] ],
 +                      $store->countVisitingWatchersMultiple(
 +                              [ [ $title, '20150202020202' ] ], $initialVisitingWatchers + 1
 +                      )
 +              );
        }
  
        public function testDuplicateAllAssociatedEntries() {
@@@ -17,13 -17,20 +17,20 @@@ class WatchedItemStoreUnitTest extends 
        /**
         * @return PHPUnit_Framework_MockObject_MockObject|LoadBalancer
         */
-       private function getMockLoadBalancer( $mockDb ) {
+       private function getMockLoadBalancer( $mockDb, $expectedConnectionType = null ) {
                $mock = $this->getMockBuilder( LoadBalancer::class )
                        ->disableOriginalConstructor()
                        ->getMock();
-               $mock->expects( $this->any() )
-                       ->method( 'getConnection' )
-                       ->will( $this->returnValue( $mockDb ) );
+               if ( $expectedConnectionType !== null ) {
+                       $mock->expects( $this->any() )
+                               ->method( 'getConnection' )
+                               ->with( $expectedConnectionType )
+                               ->will( $this->returnValue( $mockDb ) );
+               } else {
+                       $mock->expects( $this->any() )
+                               ->method( 'getConnection' )
+                               ->will( $this->returnValue( $mockDb ) );
+               }
                $mock->expects( $this->any() )
                        ->method( 'getReadOnlyReason' )
                        ->will( $this->returnValue( false ) );
                $this->assertEquals( 7, $store->countVisitingWatchers( $titleValue, '111' ) );
        }
  
 +      public function testCountVisitingWatchersMultiple() {
 +              $titleValuesWithThresholds = [
 +                      [ new TitleValue( 0, 'SomeDbKey' ), '111' ],
 +                      [ new TitleValue( 0, 'OtherDbKey' ), '111' ],
 +                      [ new TitleValue( 1, 'AnotherDbKey' ), '123' ],
 +              ];
 +
 +              $dbResult = [
 +                      $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => 0, 'watchers' => 100 ] ),
 +                      $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => 0, 'watchers' => 300 ] ),
 +                      $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => 1, 'watchers' => 500 ] ),
 +              ];
 +              $mockDb = $this->getMockDb();
 +              $mockDb->expects( $this->exactly( 2 * 3 ) )
 +                      ->method( 'addQuotes' )
 +                      ->will( $this->returnCallback( function( $value ) {
 +                              return "'$value'";
 +                      } ) );
 +              $mockDb->expects( $this->exactly( 3 ) )
 +                      ->method( 'timestamp' )
 +                      ->will( $this->returnCallback( function( $value ) {
 +                              return 'TS' . $value . 'TS';
 +                      } ) );
 +              $mockDb->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
 +                              ) );
 +                      } ) );
 +              $mockDb->expects( $this->never() )
 +                      ->method( 'makeWhereFrom2d' );
 +
 +              $expectedCond =
 +                      '((wl_namespace = 0) AND (' .
 +                      "(((wl_title = 'SomeDbKey') AND (" .
 +                      "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
 +                      ')) OR (' .
 +                      "(wl_title = 'OtherDbKey') AND (" .
 +                      "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
 +                      '))))' .
 +                      ') OR ((wl_namespace = 1) AND (' .
 +                      "(((wl_title = 'AnotherDbKey') AND (".
 +                      "(wl_notificationtimestamp >= 'TS123TS') OR (wl_notificationtimestamp IS NULL)" .
 +                      ')))))';
 +              $mockDb->expects( $this->once() )
 +                      ->method( 'select' )
 +                      ->with(
 +                              'watchlist',
 +                              [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
 +                              $expectedCond,
 +                              $this->isType( 'string' ),
 +                              [
 +                                      'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
 +                              ]
 +                      )
 +                      ->will(
 +                              $this->returnValue( $dbResult )
 +                      );
 +
 +              $mockCache = $this->getMockCache();
 +              $mockCache->expects( $this->never() )->method( 'get' );
 +              $mockCache->expects( $this->never() )->method( 'set' );
 +              $mockCache->expects( $this->never() )->method( 'delete' );
 +
 +              $store = new WatchedItemStore(
 +                      $this->getMockLoadBalancer( $mockDb ),
 +                      $mockCache
 +              );
 +
 +              $expected = [
 +                      0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ],
 +                      1 => [ 'AnotherDbKey' => 500 ],
 +              ];
 +              $this->assertEquals(
 +                      $expected,
 +                      $store->countVisitingWatchersMultiple( $titleValuesWithThresholds )
 +              );
 +      }
 +
 +      public function testCountVisitingWatchersMultiple_withMissingTargets() {
 +              $titleValuesWithThresholds = [
 +                      [ new TitleValue( 0, 'SomeDbKey' ), '111' ],
 +                      [ new TitleValue( 0, 'OtherDbKey' ), '111' ],
 +                      [ new TitleValue( 1, 'AnotherDbKey' ), '123' ],
 +                      [ new TitleValue( 0, 'SomeNotExisitingDbKey' ), null ],
 +                      [ new TitleValue( 0, 'OtherNotExisitingDbKey' ), null ],
 +              ];
 +
 +              $dbResult = [
 +                      $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => 0, 'watchers' => 100 ] ),
 +                      $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => 0, 'watchers' => 300 ] ),
 +                      $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => 1, 'watchers' => 500 ] ),
 +                      $this->getFakeRow(
 +                              [ 'wl_title' => 'SomeNotExisitingDbKey', 'wl_namespace' => 0, 'watchers' => 100 ]
 +                      ),
 +                      $this->getFakeRow(
 +                              [ 'wl_title' => 'OtherNotExisitingDbKey', 'wl_namespace' => 0, 'watchers' => 200 ]
 +                      ),
 +              ];
 +              $mockDb = $this->getMockDb();
 +              $mockDb->expects( $this->exactly( 2 * 3 ) )
 +                      ->method( 'addQuotes' )
 +                      ->will( $this->returnCallback( function( $value ) {
 +                              return "'$value'";
 +                      } ) );
 +              $mockDb->expects( $this->exactly( 3 ) )
 +                      ->method( 'timestamp' )
 +                      ->will( $this->returnCallback( function( $value ) {
 +                              return 'TS' . $value . 'TS';
 +                      } ) );
 +              $mockDb->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
 +                              ) );
 +                      } ) );
 +              $mockDb->expects( $this->once() )
 +                      ->method( 'makeWhereFrom2d' )
 +                      ->with(
 +                              [ [ 'SomeNotExisitingDbKey' => 1, 'OtherNotExisitingDbKey' => 1 ] ],
 +                              $this->isType( 'string' ),
 +                              $this->isType( 'string' )
 +                      )
 +                      ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
 +
 +              $expectedCond =
 +                      '((wl_namespace = 0) AND (' .
 +                      "(((wl_title = 'SomeDbKey') AND (" .
 +                      "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
 +                      ')) OR (' .
 +                      "(wl_title = 'OtherDbKey') AND (" .
 +                      "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
 +                      '))))' .
 +                      ') OR ((wl_namespace = 1) AND (' .
 +                      "(((wl_title = 'AnotherDbKey') AND (".
 +                      "(wl_notificationtimestamp >= 'TS123TS') OR (wl_notificationtimestamp IS NULL)" .
 +                      '))))' .
 +                      ') OR ' .
 +                      '(makeWhereFrom2d return value)';
 +              $mockDb->expects( $this->once() )
 +                      ->method( 'select' )
 +                      ->with(
 +                              'watchlist',
 +                              [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
 +                              $expectedCond,
 +                              $this->isType( 'string' ),
 +                              [
 +                                      'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
 +                              ]
 +                      )
 +                      ->will(
 +                              $this->returnValue( $dbResult )
 +                      );
 +
 +              $mockCache = $this->getMockCache();
 +              $mockCache->expects( $this->never() )->method( 'get' );
 +              $mockCache->expects( $this->never() )->method( 'set' );
 +              $mockCache->expects( $this->never() )->method( 'delete' );
 +
 +              $store = new WatchedItemStore(
 +                      $this->getMockLoadBalancer( $mockDb ),
 +                      $mockCache
 +              );
 +
 +              $expected = [
 +                      0 => [
 +                              'SomeDbKey' => 100, 'OtherDbKey' => 300,
 +                              'SomeNotExisitingDbKey' => 100, 'OtherNotExisitingDbKey' => 200
 +                      ],
 +                      1 => [ 'AnotherDbKey' => 500 ],
 +              ];
 +              $this->assertEquals(
 +                      $expected,
 +                      $store->countVisitingWatchersMultiple( $titleValuesWithThresholds )
 +              );
 +      }
 +
 +      /**
 +       * @dataProvider provideIntWithDbUnsafeVersion
 +       */
 +      public function testCountVisitingWatchersMultiple_withMinimumWatchers( $minWatchers ) {
 +              $titleValuesWithThresholds = [
 +                      [ new TitleValue( 0, 'SomeDbKey' ), '111' ],
 +                      [ new TitleValue( 0, 'OtherDbKey' ), '111' ],
 +                      [ new TitleValue( 1, 'AnotherDbKey' ), '123' ],
 +              ];
 +
 +              $mockDb = $this->getMockDb();
 +              $mockDb->expects( $this->any() )
 +                      ->method( 'makeList' )
 +                      ->will( $this->returnValue( 'makeList return value' ) );
 +              $mockDb->expects( $this->once() )
 +                      ->method( 'select' )
 +                      ->with(
 +                              'watchlist',
 +                              [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
 +                              'makeList return value',
 +                              $this->isType( 'string' ),
 +                              [
 +                                      'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
 +                                      'HAVING' => 'COUNT(*) >= 50',
 +                              ]
 +                      )
 +                      ->will(
 +                              $this->returnValue( [] )
 +                      );
 +
 +              $mockCache = $this->getMockCache();
 +              $mockCache->expects( $this->never() )->method( 'get' );
 +              $mockCache->expects( $this->never() )->method( 'set' );
 +              $mockCache->expects( $this->never() )->method( 'delete' );
 +
 +              $store = new WatchedItemStore(
 +                      $this->getMockLoadBalancer( $mockDb ),
 +                      $mockCache
 +              );
 +
 +              $expected = [
 +                      0 => [ 'SomeDbKey' => 0, 'OtherDbKey' => 0 ],
 +                      1 => [ 'AnotherDbKey' => 0 ],
 +              ];
 +              $this->assertEquals(
 +                      $expected,
 +                      $store->countVisitingWatchersMultiple( $titleValuesWithThresholds, $minWatchers )
 +              );
 +      }
 +
        public function testCountUnreadNotifications() {
                $user = $this->getMockNonAnonUserWithId( 1 );
  
                );
        }
  
+       public function testGetWatchedItemsForUser() {
+               $mockDb = $this->getMockDb();
+               $mockDb->expects( $this->once() )
+                       ->method( 'select' )
+                       ->with(
+                               'watchlist',
+                               [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+                               [ 'wl_user' => 1 ]
+                       )
+                       ->will( $this->returnValue( [
+                               $this->getFakeRow( [
+                                       'wl_namespace' => 0,
+                                       'wl_title' => 'Foo1',
+                                       'wl_notificationtimestamp' => '20151212010101',
+                               ] ),
+                               $this->getFakeRow( [
+                                       'wl_namespace' => 1,
+                                       'wl_title' => 'Foo2',
+                                       'wl_notificationtimestamp' => null,
+                               ] ),
+                       ] ) );
+               $mockCache = $this->getMockCache();
+               $mockCache->expects( $this->never() )->method( 'delete' );
+               $mockCache->expects( $this->never() )->method( 'get' );
+               $mockCache->expects( $this->never() )->method( 'set' );
+               $store = new WatchedItemStore(
+                       $this->getMockLoadBalancer( $mockDb ),
+                       $mockCache
+               );
+               $user = $this->getMockNonAnonUserWithId( 1 );
+               $watchedItems = $store->getWatchedItemsForUser( $user );
+               $this->assertInternalType( 'array', $watchedItems );
+               $this->assertCount( 2, $watchedItems );
+               foreach ( $watchedItems as $watchedItem ) {
+                       $this->assertInstanceOf( 'WatchedItem', $watchedItem );
+               }
+               $this->assertEquals(
+                       new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ),
+                       $watchedItems[0]
+               );
+               $this->assertEquals(
+                       new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ),
+                       $watchedItems[1]
+               );
+       }
+       public function provideDbTypes() {
+               return [
+                       [ false, DB_SLAVE ],
+                       [ true, DB_MASTER ],
+               ];
+       }
+       /**
+        * @dataProvider provideDbTypes
+        */
+       public function testGetWatchedItemsForUser_optionsAndEmptyResult( $forWrite, $dbType ) {
+               $mockDb = $this->getMockDb();
+               $mockCache = $this->getMockCache();
+               $mockLoadBalancer = $this->getMockLoadBalancer( $mockDb, $dbType );
+               $user = $this->getMockNonAnonUserWithId( 1 );
+               $mockDb->expects( $this->once() )
+                       ->method( 'select' )
+                       ->with(
+                               'watchlist',
+                               [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+                               [ 'wl_user' => 1 ]
+                       )
+                       ->will( $this->returnValue( [] ) );
+               $store = new WatchedItemStore(
+                       $mockLoadBalancer,
+                       $mockCache
+               );
+               $watchedItems = $store->getWatchedItemsForUser(
+                       $user,
+                       [ 'forWrite' => $forWrite ]
+               );
+               $this->assertEquals( [], $watchedItems );
+       }
        public function testIsWatchedItem_existingItem() {
                $mockDb = $this->getMockDb();
                $mockDb->expects( $this->once() )
                );
        }
  
 +      public function testGetNotificationTimestampsBatch() {
 +              $targets = [
 +                      new TitleValue( 0, 'SomeDbKey' ),
 +                      new TitleValue( 1, 'AnotherDbKey' ),
 +              ];
 +
 +              $mockDb = $this->getMockDb();
 +              $dbResult = [
 +                      $this->getFakeRow( [
 +                              'wl_namespace' => 0,
 +                              'wl_title' => 'SomeDbKey',
 +                              'wl_notificationtimestamp' => '20151212010101',
 +                      ] ),
 +                      $this->getFakeRow(
 +                              [
 +                                      'wl_namespace' => 1,
 +                                      'wl_title' => 'AnotherDbKey',
 +                                      'wl_notificationtimestamp' => null,
 +                              ]
 +                      ),
 +              ];
 +
 +              $mockDb->expects( $this->once() )
 +                      ->method( 'makeWhereFrom2d' )
 +                      ->with(
 +                              [ [ 'SomeDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ],
 +                              $this->isType( 'string' ),
 +                              $this->isType( 'string' )
 +                      )
 +                      ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
 +              $mockDb->expects( $this->once() )
 +                      ->method( 'select' )
 +                      ->with(
 +                              'watchlist',
 +                              [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
 +                              [
 +                                      'makeWhereFrom2d return value',
 +                                      'wl_user' => 1
 +                              ],
 +                              $this->isType( 'string' )
 +                      )
 +                      ->will( $this->returnValue( $dbResult ) );
 +
 +              $mockCache = $this->getMockCache();
 +              $mockCache->expects( $this->exactly( 2 ) )
 +                      ->method( 'get' )
 +                      ->withConsecutive(
 +                              [ '0:SomeDbKey:1' ],
 +                              [ '1:AnotherDbKey:1' ]
 +                      )
 +                      ->will( $this->returnValue( null ) );
 +              $mockCache->expects( $this->never() )->method( 'set' );
 +              $mockCache->expects( $this->never() )->method( 'delete' );
 +
 +              $store = new WatchedItemStore(
 +                      $this->getMockLoadBalancer( $mockDb ),
 +                      $mockCache
 +              );
 +
 +              $this->assertEquals(
 +                      [
 +                              0 => [ 'SomeDbKey' => '20151212010101', ],
 +                              1 => [ 'AnotherDbKey' => null, ],
 +                      ],
 +                      $store->getNotificationTimestampsBatch( $this->getMockNonAnonUserWithId( 1 ), $targets )
 +              );
 +      }
 +
 +      public function testGetNotificationTimestampsBatch_notWatchedTarget() {
 +              $targets = [
 +                      new TitleValue( 0, 'OtherDbKey' ),
 +              ];
 +
 +              $mockDb = $this->getMockDb();
 +
 +              $mockDb->expects( $this->once() )
 +                      ->method( 'makeWhereFrom2d' )
 +                      ->with(
 +                              [ [ 'OtherDbKey' => 1 ] ],
 +                              $this->isType( 'string' ),
 +                              $this->isType( 'string' )
 +                      )
 +                      ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
 +              $mockDb->expects( $this->once() )
 +                      ->method( 'select' )
 +                      ->with(
 +                              'watchlist',
 +                              [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
 +                              [
 +                                      'makeWhereFrom2d return value',
 +                                      'wl_user' => 1
 +                              ],
 +                              $this->isType( 'string' )
 +                      )
 +                      ->will( $this->returnValue( $this->getFakeRow( [] ) ) );
 +
 +              $mockCache = $this->getMockCache();
 +              $mockCache->expects( $this->once() )
 +                      ->method( 'get' )
 +                      ->with( '0:OtherDbKey:1' )
 +                      ->will( $this->returnValue( null ) );
 +              $mockCache->expects( $this->never() )->method( 'set' );
 +              $mockCache->expects( $this->never() )->method( 'delete' );
 +
 +              $store = new WatchedItemStore(
 +                      $this->getMockLoadBalancer( $mockDb ),
 +                      $mockCache
 +              );
 +
 +              $this->assertEquals(
 +                      [
 +                              0 => [ 'OtherDbKey' => false, ],
 +                      ],
 +                      $store->getNotificationTimestampsBatch( $this->getMockNonAnonUserWithId( 1 ), $targets )
 +              );
 +      }
 +
 +      public function testGetNotificationTimestampsBatch_cachedItem() {
 +              $targets = [
 +                      new TitleValue( 0, 'SomeDbKey' ),
 +                      new TitleValue( 1, 'AnotherDbKey' ),
 +              ];
 +
 +              $user = $this->getMockNonAnonUserWithId( 1 );
 +              $cachedItem = new WatchedItem( $user, $targets[0], '20151212010101' );
 +
 +              $mockDb = $this->getMockDb();
 +
 +              $mockDb->expects( $this->once() )
 +                      ->method( 'makeWhereFrom2d' )
 +                      ->with(
 +                              [ 1 => [ 'AnotherDbKey' => 1 ] ],
 +                              $this->isType( 'string' ),
 +                              $this->isType( 'string' )
 +                      )
 +                      ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
 +              $mockDb->expects( $this->once() )
 +                      ->method( 'select' )
 +                      ->with(
 +                              'watchlist',
 +                              [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
 +                              [
 +                                      'makeWhereFrom2d return value',
 +                                      'wl_user' => 1
 +                              ],
 +                              $this->isType( 'string' )
 +                      )
 +                      ->will( $this->returnValue( [
 +                              $this->getFakeRow(
 +                                      [ 'wl_namespace' => 1, 'wl_title' => 'AnotherDbKey', 'wl_notificationtimestamp' => null, ]
 +                              )
 +                      ] ) );
 +
 +              $mockCache = $this->getMockCache();
 +              $mockCache->expects( $this->at( 1 ) )
 +                      ->method( 'get' )
 +                      ->with( '0:SomeDbKey:1' )
 +                      ->will( $this->returnValue( $cachedItem ) );
 +              $mockCache->expects( $this->at( 3 ) )
 +                      ->method( 'get' )
 +                      ->with( '1:AnotherDbKey:1' )
 +                      ->will( $this->returnValue( null ) );
 +              $mockCache->expects( $this->never() )->method( 'set' );
 +              $mockCache->expects( $this->never() )->method( 'delete' );
 +
 +              $store = new WatchedItemStore(
 +                      $this->getMockLoadBalancer( $mockDb ),
 +                      $mockCache
 +              );
 +
 +              $this->assertEquals(
 +                      [
 +                              0 => [ 'SomeDbKey' => '20151212010101', ],
 +                              1 => [ 'AnotherDbKey' => null, ],
 +                      ],
 +                      $store->getNotificationTimestampsBatch( $user, $targets )
 +              );
 +      }
 +
 +      public function testGetNotificationTimestampsBatch_allItemsCached() {
 +              $targets = [
 +                      new TitleValue( 0, 'SomeDbKey' ),
 +                      new TitleValue( 1, 'AnotherDbKey' ),
 +              ];
 +
 +              $user = $this->getMockNonAnonUserWithId( 1 );
 +              $cachedItems = [
 +                      new WatchedItem( $user, $targets[0], '20151212010101' ),
 +                      new WatchedItem( $user, $targets[1], null ),
 +              ];
 +              $mockDb = $this->getMockDb();
 +              $mockDb->expects( $this->never() )->method( $this->anything() );
 +
 +              $mockCache = $this->getMockCache();
 +              $mockCache->expects( $this->at( 1 ) )
 +                      ->method( 'get' )
 +                      ->with( '0:SomeDbKey:1' )
 +                      ->will( $this->returnValue( $cachedItems[0] ) );
 +              $mockCache->expects( $this->at( 3 ) )
 +                      ->method( 'get' )
 +                      ->with( '1:AnotherDbKey:1' )
 +                      ->will( $this->returnValue( $cachedItems[1] ) );
 +              $mockCache->expects( $this->never() )->method( 'set' );
 +              $mockCache->expects( $this->never() )->method( 'delete' );
 +
 +              $store = new WatchedItemStore(
 +                      $this->getMockLoadBalancer( $mockDb ),
 +                      $mockCache
 +              );
 +
 +              $this->assertEquals(
 +                      [
 +                              0 => [ 'SomeDbKey' => '20151212010101', ],
 +                              1 => [ 'AnotherDbKey' => null, ],
 +                      ],
 +                      $store->getNotificationTimestampsBatch( $user, $targets )
 +              );
 +      }
 +
 +      public function testGetNotificationTimestampsBatch_anonymousUser() {
 +              $targets = [
 +                      new TitleValue( 0, 'SomeDbKey' ),
 +                      new TitleValue( 1, 'AnotherDbKey' ),
 +              ];
 +
 +              $mockDb = $this->getMockDb();
 +              $mockDb->expects( $this->never() )->method( $this->anything() );
 +
 +              $mockCache = $this->getMockCache();
 +              $mockCache->expects( $this->never() )->method( $this->anything() );
 +
 +              $store = new WatchedItemStore(
 +                      $this->getMockLoadBalancer( $mockDb ),
 +                      $mockCache
 +              );
 +
 +              $this->assertEquals(
 +                      [
 +                              0 => [ 'SomeDbKey' => false, ],
 +                              1 => [ 'AnotherDbKey' => false, ],
 +                      ],
 +                      $store->getNotificationTimestampsBatch( $this->getAnonUser(), $targets )
 +              );
 +      }
 +
        public function testResetNotificationTimestamp_anonymousUser() {
                $mockDb = $this->getMockDb();
                $mockDb->expects( $this->never() )