3 use Wikimedia\Rdbms\IDatabase
;
4 use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface
;
5 use MediaWiki\Linker\LinkTarget
;
6 use Wikimedia\Assert\Assert
;
7 use Wikimedia\Rdbms\LBFactory
;
8 use Wikimedia\ScopedCallback
;
9 use Wikimedia\Rdbms\ILBFactory
;
10 use Wikimedia\Rdbms\LoadBalancer
;
13 * Storage layer class for WatchedItems.
14 * Database interaction & caching
15 * TODO caching should be factored out into a CachingWatchedItemStore class
20 class WatchedItemStore
implements WatchedItemStoreInterface
, StatsdAwareInterface
{
30 private $loadBalancer;
35 private $readOnlyMode;
43 * @var array[] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key'
44 * The index is needed so that on mass changes all relevant items can be un-cached.
45 * For example: Clearing a users watchlist of all items or updating notification timestamps
46 * for all users watching a single target.
48 private $cacheIndex = [];
53 private $deferredUpdatesAddCallableUpdateCallback;
58 private $revisionGetTimestampFromIdCallback;
63 private $updateRowsPerQuery;
66 * @var StatsdDataFactoryInterface
71 * @param ILBFactory $lbFactory
72 * @param HashBagOStuff $cache
73 * @param ReadOnlyMode $readOnlyMode
74 * @param int $updateRowsPerQuery
76 public function __construct(
77 ILBFactory
$lbFactory,
79 ReadOnlyMode
$readOnlyMode,
82 $this->lbFactory
= $lbFactory;
83 $this->loadBalancer
= $lbFactory->getMainLB();
84 $this->cache
= $cache;
85 $this->readOnlyMode
= $readOnlyMode;
86 $this->stats
= new NullStatsdDataFactory();
87 $this->deferredUpdatesAddCallableUpdateCallback
=
88 [ DeferredUpdates
::class, 'addCallableUpdate' ];
89 $this->revisionGetTimestampFromIdCallback
=
90 [ Revision
::class, 'getTimestampFromId' ];
91 $this->updateRowsPerQuery
= $updateRowsPerQuery;
95 * @param StatsdDataFactoryInterface $stats
97 public function setStatsdDataFactory( StatsdDataFactoryInterface
$stats ) {
98 $this->stats
= $stats;
102 * Overrides the DeferredUpdates::addCallableUpdate callback
103 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
105 * @param callable $callback
107 * @see DeferredUpdates::addCallableUpdate for callback signiture
109 * @return ScopedCallback to reset the overridden value
110 * @throws MWException
112 public function overrideDeferredUpdatesAddCallableUpdateCallback( callable
$callback ) {
113 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
114 throw new MWException(
115 'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
118 $previousValue = $this->deferredUpdatesAddCallableUpdateCallback
;
119 $this->deferredUpdatesAddCallableUpdateCallback
= $callback;
120 return new ScopedCallback( function () use ( $previousValue ) {
121 $this->deferredUpdatesAddCallableUpdateCallback
= $previousValue;
126 * Overrides the Revision::getTimestampFromId callback
127 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
129 * @param callable $callback
130 * @see Revision::getTimestampFromId for callback signiture
132 * @return ScopedCallback to reset the overridden value
133 * @throws MWException
135 public function overrideRevisionGetTimestampFromIdCallback( callable
$callback ) {
136 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
137 throw new MWException(
138 'Cannot override Revision::getTimestampFromId callback in operation.'
141 $previousValue = $this->revisionGetTimestampFromIdCallback
;
142 $this->revisionGetTimestampFromIdCallback
= $callback;
143 return new ScopedCallback( function () use ( $previousValue ) {
144 $this->revisionGetTimestampFromIdCallback
= $previousValue;
148 private function getCacheKey( User
$user, LinkTarget
$target ) {
149 return $this->cache
->makeKey(
150 (string)$target->getNamespace(),
152 (string)$user->getId()
156 private function cache( WatchedItem
$item ) {
157 $user = $item->getUser();
158 $target = $item->getLinkTarget();
159 $key = $this->getCacheKey( $user, $target );
160 $this->cache
->set( $key, $item );
161 $this->cacheIndex
[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
162 $this->stats
->increment( 'WatchedItemStore.cache' );
165 private function uncache( User
$user, LinkTarget
$target ) {
166 $this->cache
->delete( $this->getCacheKey( $user, $target ) );
167 unset( $this->cacheIndex
[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
168 $this->stats
->increment( 'WatchedItemStore.uncache' );
171 private function uncacheLinkTarget( LinkTarget
$target ) {
172 $this->stats
->increment( 'WatchedItemStore.uncacheLinkTarget' );
173 if ( !isset( $this->cacheIndex
[$target->getNamespace()][$target->getDBkey()] ) ) {
176 foreach ( $this->cacheIndex
[$target->getNamespace()][$target->getDBkey()] as $key ) {
177 $this->stats
->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
178 $this->cache
->delete( $key );
182 private function uncacheUser( User
$user ) {
183 $this->stats
->increment( 'WatchedItemStore.uncacheUser' );
184 foreach ( $this->cacheIndex
as $ns => $dbKeyArray ) {
185 foreach ( $dbKeyArray as $dbKey => $userArray ) {
186 if ( isset( $userArray[$user->getId()] ) ) {
187 $this->stats
->increment( 'WatchedItemStore.uncacheUser.items' );
188 $this->cache
->delete( $userArray[$user->getId()] );
196 * @param LinkTarget $target
198 * @return WatchedItem|false
200 private function getCached( User
$user, LinkTarget
$target ) {
201 return $this->cache
->get( $this->getCacheKey( $user, $target ) );
205 * Return an array of conditions to select or update the appropriate database
209 * @param LinkTarget $target
213 private function dbCond( User
$user, LinkTarget
$target ) {
215 'wl_user' => $user->getId(),
216 'wl_namespace' => $target->getNamespace(),
217 'wl_title' => $target->getDBkey(),
222 * @param int $dbIndex DB_MASTER or DB_REPLICA
225 * @throws MWException
227 private function getConnectionRef( $dbIndex ) {
228 return $this->loadBalancer
->getConnectionRef( $dbIndex, [ 'watchlist' ] );
232 * Deletes ALL watched items for the given user when under
233 * $updateRowsPerQuery entries exist.
239 * @return bool true on success, false when too many items are watched
241 public function clearUserWatchedItems( User
$user ) {
242 if ( $this->countWatchedItems( $user ) > $this->updateRowsPerQuery
) {
246 $dbw = $this->loadBalancer
->getConnectionRef( DB_MASTER
);
249 [ 'wl_user' => $user->getId() ],
252 $this->uncacheAllItemsForUser( $user );
257 private function uncacheAllItemsForUser( User
$user ) {
258 $userId = $user->getId();
259 foreach ( $this->cacheIndex
as $ns => $dbKeyIndex ) {
260 foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
261 if ( array_key_exists( $userId, $userIndex ) ) {
262 $this->cache
->delete( $userIndex[$userId] );
263 unset( $this->cacheIndex
[$ns][$dbKey][$userId] );
268 // Cleanup empty cache keys
269 foreach ( $this->cacheIndex
as $ns => $dbKeyIndex ) {
270 foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
271 if ( empty( $this->cacheIndex
[$ns][$dbKey] ) ) {
272 unset( $this->cacheIndex
[$ns][$dbKey] );
275 if ( empty( $this->cacheIndex
[$ns] ) ) {
276 unset( $this->cacheIndex
[$ns] );
282 * Queues a job that will clear the users watchlist using the Job Queue.
288 public function clearUserWatchedItemsUsingJobQueue( User
$user ) {
289 $job = ClearUserWatchlistJob
::newForUser( $user, $this->getMaxId() );
291 JobQueueGroup
::singleton()->push( $job );
296 * @return int The maximum current wl_id
298 public function getMaxId() {
299 $dbr = $this->getConnectionRef( DB_REPLICA
);
300 return (int)$dbr->selectField(
313 public function countWatchedItems( User
$user ) {
314 $dbr = $this->getConnectionRef( DB_REPLICA
);
315 $return = (int)$dbr->selectField(
319 'wl_user' => $user->getId()
329 * @param LinkTarget $target
332 public function countWatchers( LinkTarget
$target ) {
333 $dbr = $this->getConnectionRef( DB_REPLICA
);
334 $return = (int)$dbr->selectField(
338 'wl_namespace' => $target->getNamespace(),
339 'wl_title' => $target->getDBkey(),
349 * @param LinkTarget $target
350 * @param string|int $threshold
353 public function countVisitingWatchers( LinkTarget
$target, $threshold ) {
354 $dbr = $this->getConnectionRef( DB_REPLICA
);
355 $visitingWatchers = (int)$dbr->selectField(
359 'wl_namespace' => $target->getNamespace(),
360 'wl_title' => $target->getDBkey(),
361 'wl_notificationtimestamp >= ' .
362 $dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
363 ' OR wl_notificationtimestamp IS NULL'
368 return $visitingWatchers;
373 * @param TitleValue[] $titles
375 * @throws MWException
377 public function removeWatchBatchForUser( User
$user, array $titles ) {
378 if ( $this->readOnlyMode
->isReadOnly() ) {
381 if ( $user->isAnon() ) {
388 $rows = $this->getTitleDbKeysGroupedByNamespace( $titles );
389 $this->uncacheTitlesForUser( $user, $titles );
391 $dbw = $this->getConnectionRef( DB_MASTER
);
392 $ticket = count( $titles ) > $this->updateRowsPerQuery ?
393 $this->lbFactory
->getEmptyTransactionTicket( __METHOD__
) : null;
396 // Batch delete items per namespace.
397 foreach ( $rows as $namespace => $namespaceTitles ) {
398 $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery
);
399 foreach ( $rowBatches as $toDelete ) {
400 $dbw->delete( 'watchlist', [
401 'wl_user' => $user->getId(),
402 'wl_namespace' => $namespace,
403 'wl_title' => $toDelete
405 $affectedRows +
= $dbw->affectedRows();
407 $this->lbFactory
->commitAndWaitForReplication( __METHOD__
, $ticket );
412 return (bool)$affectedRows;
417 * @param LinkTarget[] $targets
418 * @param array $options
421 public function countWatchersMultiple( array $targets, array $options = [] ) {
422 $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
424 $dbr = $this->getConnectionRef( DB_REPLICA
);
426 if ( array_key_exists( 'minimumWatchers', $options ) ) {
427 $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
430 $lb = new LinkBatch( $targets );
433 [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
434 [ $lb->constructSet( 'wl', $dbr ) ],
440 foreach ( $targets as $linkTarget ) {
441 $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
444 foreach ( $res as $row ) {
445 $watchCounts[$row->wl_namespace
][$row->wl_title
] = (int)$row->watchers
;
453 * @param array $targetsWithVisitThresholds
454 * @param int|null $minimumWatchers
457 public function countVisitingWatchersMultiple(
458 array $targetsWithVisitThresholds,
459 $minimumWatchers = null
461 if ( $targetsWithVisitThresholds === [] ) {
462 // No titles requested => no results returned
466 $dbr = $this->getConnectionRef( DB_REPLICA
);
468 $conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds );
470 $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
471 if ( $minimumWatchers !== null ) {
472 $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
476 [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
483 foreach ( $targetsWithVisitThresholds as list( $target ) ) {
484 /* @var LinkTarget $target */
485 $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
488 foreach ( $res as $row ) {
489 $watcherCounts[$row->wl_namespace
][$row->wl_title
] = (int)$row->watchers
;
492 return $watcherCounts;
496 * Generates condition for the query used in a batch count visiting watchers.
498 * @param IDatabase $db
499 * @param array $targetsWithVisitThresholds array of pairs (LinkTarget, last visit threshold)
502 private function getVisitingWatchersCondition(
504 array $targetsWithVisitThresholds
506 $missingTargets = [];
507 $namespaceConds = [];
508 foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
509 if ( $threshold === null ) {
510 $missingTargets[] = $target;
513 /* @var LinkTarget $target */
514 $namespaceConds[$target->getNamespace()][] = $db->makeList( [
515 'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
517 'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
518 'wl_notificationtimestamp IS NULL'
524 foreach ( $namespaceConds as $namespace => $pageConds ) {
525 $conds[] = $db->makeList( [
526 'wl_namespace = ' . $namespace,
527 '(' . $db->makeList( $pageConds, LIST_OR
) . ')'
531 if ( $missingTargets ) {
532 $lb = new LinkBatch( $missingTargets );
533 $conds[] = $lb->constructSet( 'wl', $db );
536 return $db->makeList( $conds, LIST_OR
);
542 * @param LinkTarget $target
545 public function getWatchedItem( User
$user, LinkTarget
$target ) {
546 if ( $user->isAnon() ) {
550 $cached = $this->getCached( $user, $target );
552 $this->stats
->increment( 'WatchedItemStore.getWatchedItem.cached' );
555 $this->stats
->increment( 'WatchedItemStore.getWatchedItem.load' );
556 return $this->loadWatchedItem( $user, $target );
562 * @param LinkTarget $target
563 * @return WatchedItem|bool
565 public function loadWatchedItem( User
$user, LinkTarget
$target ) {
566 // Only loggedin user can have a watchlist
567 if ( $user->isAnon() ) {
571 $dbr = $this->getConnectionRef( DB_REPLICA
);
572 $row = $dbr->selectRow(
574 'wl_notificationtimestamp',
575 $this->dbCond( $user, $target ),
583 $item = new WatchedItem(
586 wfTimestampOrNull( TS_MW
, $row->wl_notificationtimestamp
)
588 $this->cache( $item );
596 * @param array $options
597 * @return WatchedItem[]
599 public function getWatchedItemsForUser( User
$user, array $options = [] ) {
600 $options +
= [ 'forWrite' => false ];
603 if ( array_key_exists( 'sort', $options ) ) {
605 ( in_array( $options['sort'], [ self
::SORT_ASC
, self
::SORT_DESC
] ) ),
606 '$options[\'sort\']',
607 'must be SORT_ASC or SORT_DESC'
609 $dbOptions['ORDER BY'] = [
610 "wl_namespace {$options['sort']}",
611 "wl_title {$options['sort']}"
614 $db = $this->getConnectionRef( $options['forWrite'] ? DB_MASTER
: DB_REPLICA
);
618 [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
619 [ 'wl_user' => $user->getId() ],
625 foreach ( $res as $row ) {
626 // @todo: Should we add these to the process cache?
627 $watchedItems[] = new WatchedItem(
629 new TitleValue( (int)$row->wl_namespace
, $row->wl_title
),
630 $row->wl_notificationtimestamp
634 return $watchedItems;
640 * @param LinkTarget $target
643 public function isWatched( User
$user, LinkTarget
$target ) {
644 return (bool)$this->getWatchedItem( $user, $target );
650 * @param LinkTarget[] $targets
653 public function getNotificationTimestampsBatch( User
$user, array $targets ) {
655 foreach ( $targets as $target ) {
656 $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
659 if ( $user->isAnon() ) {
664 foreach ( $targets as $target ) {
665 $cachedItem = $this->getCached( $user, $target );
667 $timestamps[$target->getNamespace()][$target->getDBkey()] =
668 $cachedItem->getNotificationTimestamp();
670 $targetsToLoad[] = $target;
674 if ( !$targetsToLoad ) {
678 $dbr = $this->getConnectionRef( DB_REPLICA
);
680 $lb = new LinkBatch( $targetsToLoad );
683 [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
685 $lb->constructSet( 'wl', $dbr ),
686 'wl_user' => $user->getId(),
691 foreach ( $res as $row ) {
692 $timestamps[$row->wl_namespace
][$row->wl_title
] =
693 wfTimestampOrNull( TS_MW
, $row->wl_notificationtimestamp
);
702 * @param LinkTarget $target
703 * @throws MWException
705 public function addWatch( User
$user, LinkTarget
$target ) {
706 $this->addWatchBatchForUser( $user, [ $target ] );
712 * @param LinkTarget[] $targets
714 * @throws MWException
716 public function addWatchBatchForUser( User
$user, array $targets ) {
717 if ( $this->readOnlyMode
->isReadOnly() ) {
720 // Only logged-in user can have a watchlist
721 if ( $user->isAnon() ) {
731 foreach ( $targets as $target ) {
733 'wl_user' => $user->getId(),
734 'wl_namespace' => $target->getNamespace(),
735 'wl_title' => $target->getDBkey(),
736 'wl_notificationtimestamp' => null,
738 $items[] = new WatchedItem(
743 $this->uncache( $user, $target );
746 $dbw = $this->getConnectionRef( DB_MASTER
);
747 $ticket = count( $targets ) > $this->updateRowsPerQuery ?
748 $this->lbFactory
->getEmptyTransactionTicket( __METHOD__
) : null;
750 $rowBatches = array_chunk( $rows, $this->updateRowsPerQuery
);
751 foreach ( $rowBatches as $toInsert ) {
752 // Use INSERT IGNORE to avoid overwriting the notification timestamp
753 // if there's already an entry for this page
754 $dbw->insert( 'watchlist', $toInsert, __METHOD__
, 'IGNORE' );
755 $affectedRows +
= $dbw->affectedRows();
757 $this->lbFactory
->commitAndWaitForReplication( __METHOD__
, $ticket );
760 // Update process cache to ensure skin doesn't claim that the current
761 // page is unwatched in the response of action=watch itself (T28292).
762 // This would otherwise be re-queried from a replica by isWatched().
763 foreach ( $items as $item ) {
764 $this->cache( $item );
767 return (bool)$affectedRows;
773 * @param LinkTarget $target
775 * @throws MWException
777 public function removeWatch( User
$user, LinkTarget
$target ) {
778 return $this->removeWatchBatchForUser( $user, [ $target ] );
784 * @param string|int $timestamp
785 * @param LinkTarget[] $targets
788 public function setNotificationTimestampsForUser( User
$user, $timestamp, array $targets = [] ) {
789 // Only loggedin user can have a watchlist
790 if ( $user->isAnon() ) {
794 $dbw = $this->getConnectionRef( DB_MASTER
);
796 $conds = [ 'wl_user' => $user->getId() ];
798 $batch = new LinkBatch( $targets );
799 $conds[] = $batch->constructSet( 'wl', $dbw );
802 if ( $timestamp !== null ) {
803 $timestamp = $dbw->timestamp( $timestamp );
806 $success = $dbw->update(
808 [ 'wl_notificationtimestamp' => $timestamp ],
813 $this->uncacheUser( $user );
818 public function resetAllNotificationTimestampsForUser( User
$user ) {
819 // Only loggedin user can have a watchlist
820 if ( $user->isAnon() ) {
824 // If the page is watched by the user (or may be watched), update the timestamp
825 $job = new ClearWatchlistNotificationsJob(
826 $user->getUserPage(),
827 [ 'userId' => $user->getId(), 'casTime' => time() ]
830 // Try to run this post-send
831 // Calls DeferredUpdates::addCallableUpdate in normal operation
833 $this->deferredUpdatesAddCallableUpdateCallback
,
834 function () use ( $job ) {
842 * @param User $editor
843 * @param LinkTarget $target
844 * @param string|int $timestamp
847 public function updateNotificationTimestamp( User
$editor, LinkTarget
$target, $timestamp ) {
848 $dbw = $this->getConnectionRef( DB_MASTER
);
849 $uids = $dbw->selectFieldValues(
853 'wl_user != ' . intval( $editor->getId() ),
854 'wl_namespace' => $target->getNamespace(),
855 'wl_title' => $target->getDBkey(),
856 'wl_notificationtimestamp IS NULL',
861 $watchers = array_map( 'intval', $uids );
863 // Update wl_notificationtimestamp for all watching users except the editor
865 DeferredUpdates
::addCallableUpdate(
866 function () use ( $timestamp, $watchers, $target, $fname ) {
867 $dbw = $this->getConnectionRef( DB_MASTER
);
868 $ticket = $this->lbFactory
->getEmptyTransactionTicket( $fname );
870 $watchersChunks = array_chunk( $watchers, $this->updateRowsPerQuery
);
871 foreach ( $watchersChunks as $watchersChunk ) {
872 $dbw->update( 'watchlist',
874 'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
875 ], [ /* WHERE - TODO Use wl_id T130067 */
876 'wl_user' => $watchersChunk,
877 'wl_namespace' => $target->getNamespace(),
878 'wl_title' => $target->getDBkey(),
881 if ( count( $watchersChunks ) > 1 ) {
882 $this->lbFactory
->commitAndWaitForReplication(
883 $fname, $ticket, [ 'domain' => $dbw->getDomainID() ]
887 $this->uncacheLinkTarget( $target );
889 DeferredUpdates
::POSTSEND
,
900 * @param Title $title
901 * @param string $force
905 public function resetNotificationTimestamp( User
$user, Title
$title, $force = '', $oldid = 0 ) {
906 // Only loggedin user can have a watchlist
907 if ( $this->readOnlyMode
->isReadOnly() ||
$user->isAnon() ) {
912 if ( $force != 'force' ) {
913 $item = $this->loadWatchedItem( $user, $title );
914 if ( !$item ||
$item->getNotificationTimestamp() === null ) {
919 // If the page is watched by the user (or may be watched), update the timestamp
920 $job = new ActivityUpdateJob(
923 'type' => 'updateWatchlistNotification',
924 'userid' => $user->getId(),
925 'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
930 // Try to run this post-send
931 // Calls DeferredUpdates::addCallableUpdate in normal operation
933 $this->deferredUpdatesAddCallableUpdateCallback
,
934 function () use ( $job ) {
939 $this->uncache( $user, $title );
944 private function getNotificationTimestamp( User
$user, Title
$title, $item, $force, $oldid ) {
946 // No oldid given, assuming latest revision; clear the timestamp.
950 if ( !$title->getNextRevisionID( $oldid ) ) {
951 // Oldid given and is the latest revision for this title; clear the timestamp.
955 if ( $item === null ) {
956 $item = $this->loadWatchedItem( $user, $title );
960 // This can only happen if $force is enabled.
964 // Oldid given and isn't the latest; update the timestamp.
965 // This will result in no further notification emails being sent!
966 // Calls Revision::getTimestampFromId in normal operation
967 $notificationTimestamp = call_user_func(
968 $this->revisionGetTimestampFromIdCallback
,
973 // We need to go one second to the future because of various strict comparisons
974 // throughout the codebase
975 $ts = new MWTimestamp( $notificationTimestamp );
976 $ts->timestamp
->add( new DateInterval( 'PT1S' ) );
977 $notificationTimestamp = $ts->getTimestamp( TS_MW
);
979 if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
980 if ( $force != 'force' ) {
983 // This is a little silly…
984 return $item->getNotificationTimestamp();
988 return $notificationTimestamp;
994 * @param int|null $unreadLimit
997 public function countUnreadNotifications( User
$user, $unreadLimit = null ) {
999 if ( $unreadLimit !== null ) {
1000 $unreadLimit = (int)$unreadLimit;
1001 $queryOptions['LIMIT'] = $unreadLimit;
1004 $dbr = $this->getConnectionRef( DB_REPLICA
);
1005 $rowCount = $dbr->selectRowCount(
1009 'wl_user' => $user->getId(),
1010 'wl_notificationtimestamp IS NOT NULL',
1016 if ( !isset( $unreadLimit ) ) {
1020 if ( $rowCount >= $unreadLimit ) {
1029 * @param LinkTarget $oldTarget
1030 * @param LinkTarget $newTarget
1032 public function duplicateAllAssociatedEntries( LinkTarget
$oldTarget, LinkTarget
$newTarget ) {
1033 $oldTarget = Title
::newFromLinkTarget( $oldTarget );
1034 $newTarget = Title
::newFromLinkTarget( $newTarget );
1036 $this->duplicateEntry( $oldTarget->getSubjectPage(), $newTarget->getSubjectPage() );
1037 $this->duplicateEntry( $oldTarget->getTalkPage(), $newTarget->getTalkPage() );
1042 * @param LinkTarget $oldTarget
1043 * @param LinkTarget $newTarget
1045 public function duplicateEntry( LinkTarget
$oldTarget, LinkTarget
$newTarget ) {
1046 $dbw = $this->getConnectionRef( DB_MASTER
);
1048 $result = $dbw->select(
1050 [ 'wl_user', 'wl_notificationtimestamp' ],
1052 'wl_namespace' => $oldTarget->getNamespace(),
1053 'wl_title' => $oldTarget->getDBkey(),
1059 $newNamespace = $newTarget->getNamespace();
1060 $newDBkey = $newTarget->getDBkey();
1062 # Construct array to replace into the watchlist
1064 foreach ( $result as $row ) {
1066 'wl_user' => $row->wl_user
,
1067 'wl_namespace' => $newNamespace,
1068 'wl_title' => $newDBkey,
1069 'wl_notificationtimestamp' => $row->wl_notificationtimestamp
,
1073 if ( !empty( $values ) ) {
1075 # Note that multi-row replace is very efficient for MySQL but may be inefficient for
1076 # some other DBMSes, mostly due to poor simulation by us
1079 [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
1087 * @param TitleValue[] $titles
1090 private function getTitleDbKeysGroupedByNamespace( array $titles ) {
1092 foreach ( $titles as $title ) {
1093 // Group titles by namespace.
1094 $rows[ $title->getNamespace() ][] = $title->getDBkey();
1101 * @param Title[] $titles
1103 private function uncacheTitlesForUser( User
$user, array $titles ) {
1104 foreach ( $titles as $title ) {
1105 $this->uncache( $user, $title );