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 = $this->lbFactory
->getEmptyTransactionTicket( __METHOD__
);
395 // Batch delete items per namespace.
396 foreach ( $rows as $namespace => $namespaceTitles ) {
397 $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery
);
398 foreach ( $rowBatches as $toDelete ) {
399 $dbw->delete( 'watchlist', [
400 'wl_user' => $user->getId(),
401 'wl_namespace' => $namespace,
402 'wl_title' => $toDelete
404 $affectedRows +
= $dbw->affectedRows();
405 $this->lbFactory
->commitAndWaitForReplication( __METHOD__
, $ticket );
409 return (bool)$affectedRows;
414 * @param LinkTarget[] $targets
415 * @param array $options
418 public function countWatchersMultiple( array $targets, array $options = [] ) {
419 $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
421 $dbr = $this->getConnectionRef( DB_REPLICA
);
423 if ( array_key_exists( 'minimumWatchers', $options ) ) {
424 $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
427 $lb = new LinkBatch( $targets );
430 [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
431 [ $lb->constructSet( 'wl', $dbr ) ],
437 foreach ( $targets as $linkTarget ) {
438 $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
441 foreach ( $res as $row ) {
442 $watchCounts[$row->wl_namespace
][$row->wl_title
] = (int)$row->watchers
;
450 * @param array $targetsWithVisitThresholds
451 * @param int|null $minimumWatchers
454 public function countVisitingWatchersMultiple(
455 array $targetsWithVisitThresholds,
456 $minimumWatchers = null
458 if ( $targetsWithVisitThresholds === [] ) {
459 // No titles requested => no results returned
463 $dbr = $this->getConnectionRef( DB_REPLICA
);
465 $conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds );
467 $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
468 if ( $minimumWatchers !== null ) {
469 $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
473 [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
480 foreach ( $targetsWithVisitThresholds as list( $target ) ) {
481 /* @var LinkTarget $target */
482 $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
485 foreach ( $res as $row ) {
486 $watcherCounts[$row->wl_namespace
][$row->wl_title
] = (int)$row->watchers
;
489 return $watcherCounts;
493 * Generates condition for the query used in a batch count visiting watchers.
495 * @param IDatabase $db
496 * @param array $targetsWithVisitThresholds array of pairs (LinkTarget, last visit threshold)
499 private function getVisitingWatchersCondition(
501 array $targetsWithVisitThresholds
503 $missingTargets = [];
504 $namespaceConds = [];
505 foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
506 if ( $threshold === null ) {
507 $missingTargets[] = $target;
510 /* @var LinkTarget $target */
511 $namespaceConds[$target->getNamespace()][] = $db->makeList( [
512 'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
514 'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
515 'wl_notificationtimestamp IS NULL'
521 foreach ( $namespaceConds as $namespace => $pageConds ) {
522 $conds[] = $db->makeList( [
523 'wl_namespace = ' . $namespace,
524 '(' . $db->makeList( $pageConds, LIST_OR
) . ')'
528 if ( $missingTargets ) {
529 $lb = new LinkBatch( $missingTargets );
530 $conds[] = $lb->constructSet( 'wl', $db );
533 return $db->makeList( $conds, LIST_OR
);
539 * @param LinkTarget $target
542 public function getWatchedItem( User
$user, LinkTarget
$target ) {
543 if ( $user->isAnon() ) {
547 $cached = $this->getCached( $user, $target );
549 $this->stats
->increment( 'WatchedItemStore.getWatchedItem.cached' );
552 $this->stats
->increment( 'WatchedItemStore.getWatchedItem.load' );
553 return $this->loadWatchedItem( $user, $target );
559 * @param LinkTarget $target
560 * @return WatchedItem|bool
562 public function loadWatchedItem( User
$user, LinkTarget
$target ) {
563 // Only loggedin user can have a watchlist
564 if ( $user->isAnon() ) {
568 $dbr = $this->getConnectionRef( DB_REPLICA
);
569 $row = $dbr->selectRow(
571 'wl_notificationtimestamp',
572 $this->dbCond( $user, $target ),
580 $item = new WatchedItem(
583 wfTimestampOrNull( TS_MW
, $row->wl_notificationtimestamp
)
585 $this->cache( $item );
593 * @param array $options
594 * @return WatchedItem[]
596 public function getWatchedItemsForUser( User
$user, array $options = [] ) {
597 $options +
= [ 'forWrite' => false ];
600 if ( array_key_exists( 'sort', $options ) ) {
602 ( in_array( $options['sort'], [ self
::SORT_ASC
, self
::SORT_DESC
] ) ),
603 '$options[\'sort\']',
604 'must be SORT_ASC or SORT_DESC'
606 $dbOptions['ORDER BY'] = [
607 "wl_namespace {$options['sort']}",
608 "wl_title {$options['sort']}"
611 $db = $this->getConnectionRef( $options['forWrite'] ? DB_MASTER
: DB_REPLICA
);
615 [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
616 [ 'wl_user' => $user->getId() ],
622 foreach ( $res as $row ) {
623 // @todo: Should we add these to the process cache?
624 $watchedItems[] = new WatchedItem(
626 new TitleValue( (int)$row->wl_namespace
, $row->wl_title
),
627 $row->wl_notificationtimestamp
631 return $watchedItems;
637 * @param LinkTarget $target
640 public function isWatched( User
$user, LinkTarget
$target ) {
641 return (bool)$this->getWatchedItem( $user, $target );
647 * @param LinkTarget[] $targets
650 public function getNotificationTimestampsBatch( User
$user, array $targets ) {
652 foreach ( $targets as $target ) {
653 $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
656 if ( $user->isAnon() ) {
661 foreach ( $targets as $target ) {
662 $cachedItem = $this->getCached( $user, $target );
664 $timestamps[$target->getNamespace()][$target->getDBkey()] =
665 $cachedItem->getNotificationTimestamp();
667 $targetsToLoad[] = $target;
671 if ( !$targetsToLoad ) {
675 $dbr = $this->getConnectionRef( DB_REPLICA
);
677 $lb = new LinkBatch( $targetsToLoad );
680 [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
682 $lb->constructSet( 'wl', $dbr ),
683 'wl_user' => $user->getId(),
688 foreach ( $res as $row ) {
689 $timestamps[$row->wl_namespace
][$row->wl_title
] =
690 wfTimestampOrNull( TS_MW
, $row->wl_notificationtimestamp
);
699 * @param LinkTarget $target
700 * @throws MWException
702 public function addWatch( User
$user, LinkTarget
$target ) {
703 $this->addWatchBatchForUser( $user, [ $target ] );
709 * @param LinkTarget[] $targets
711 * @throws MWException
713 public function addWatchBatchForUser( User
$user, array $targets ) {
714 if ( $this->readOnlyMode
->isReadOnly() ) {
717 // Only loggedin user can have a watchlist
718 if ( $user->isAnon() ) {
728 foreach ( $targets as $target ) {
730 'wl_user' => $user->getId(),
731 'wl_namespace' => $target->getNamespace(),
732 'wl_title' => $target->getDBkey(),
733 'wl_notificationtimestamp' => null,
735 $items[] = new WatchedItem(
740 $this->uncache( $user, $target );
743 $dbw = $this->getConnectionRef( DB_MASTER
);
744 $ticket = $this->lbFactory
->getEmptyTransactionTicket( __METHOD__
);
746 $rowBatches = array_chunk( $rows, $this->updateRowsPerQuery
);
747 foreach ( $rowBatches as $toInsert ) {
748 // Use INSERT IGNORE to avoid overwriting the notification timestamp
749 // if there's already an entry for this page
750 $dbw->insert( 'watchlist', $toInsert, __METHOD__
, 'IGNORE' );
751 $affectedRows +
= $dbw->affectedRows();
752 $this->lbFactory
->commitAndWaitForReplication( __METHOD__
, $ticket );
754 // Update process cache to ensure skin doesn't claim that the current
755 // page is unwatched in the response of action=watch itself (T28292).
756 // This would otherwise be re-queried from a replica by isWatched().
757 foreach ( $items as $item ) {
758 $this->cache( $item );
761 return (bool)$affectedRows;
767 * @param LinkTarget $target
769 * @throws MWException
771 public function removeWatch( User
$user, LinkTarget
$target ) {
772 return $this->removeWatchBatchForUser( $user, [ $target ] );
778 * @param string|int $timestamp
779 * @param LinkTarget[] $targets
782 public function setNotificationTimestampsForUser( User
$user, $timestamp, array $targets = [] ) {
783 // Only loggedin user can have a watchlist
784 if ( $user->isAnon() ) {
788 $dbw = $this->getConnectionRef( DB_MASTER
);
790 $conds = [ 'wl_user' => $user->getId() ];
792 $batch = new LinkBatch( $targets );
793 $conds[] = $batch->constructSet( 'wl', $dbw );
796 if ( $timestamp !== null ) {
797 $timestamp = $dbw->timestamp( $timestamp );
800 $success = $dbw->update(
802 [ 'wl_notificationtimestamp' => $timestamp ],
807 $this->uncacheUser( $user );
812 public function resetAllNotificationTimestampsForUser( User
$user ) {
813 // Only loggedin user can have a watchlist
814 if ( $user->isAnon() ) {
818 // If the page is watched by the user (or may be watched), update the timestamp
819 $job = new ClearWatchlistNotificationsJob(
820 $user->getUserPage(),
821 [ 'userId' => $user->getId(), 'casTime' => time() ]
824 // Try to run this post-send
825 // Calls DeferredUpdates::addCallableUpdate in normal operation
827 $this->deferredUpdatesAddCallableUpdateCallback
,
828 function () use ( $job ) {
836 * @param User $editor
837 * @param LinkTarget $target
838 * @param string|int $timestamp
841 public function updateNotificationTimestamp( User
$editor, LinkTarget
$target, $timestamp ) {
842 $dbw = $this->getConnectionRef( DB_MASTER
);
843 $uids = $dbw->selectFieldValues(
847 'wl_user != ' . intval( $editor->getId() ),
848 'wl_namespace' => $target->getNamespace(),
849 'wl_title' => $target->getDBkey(),
850 'wl_notificationtimestamp IS NULL',
855 $watchers = array_map( 'intval', $uids );
857 // Update wl_notificationtimestamp for all watching users except the editor
859 DeferredUpdates
::addCallableUpdate(
860 function () use ( $timestamp, $watchers, $target, $fname ) {
861 $dbw = $this->getConnectionRef( DB_MASTER
);
862 $ticket = $this->lbFactory
->getEmptyTransactionTicket( $fname );
864 $watchersChunks = array_chunk( $watchers, $this->updateRowsPerQuery
);
865 foreach ( $watchersChunks as $watchersChunk ) {
866 $dbw->update( 'watchlist',
868 'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
869 ], [ /* WHERE - TODO Use wl_id T130067 */
870 'wl_user' => $watchersChunk,
871 'wl_namespace' => $target->getNamespace(),
872 'wl_title' => $target->getDBkey(),
875 if ( count( $watchersChunks ) > 1 ) {
876 $this->lbFactory
->commitAndWaitForReplication(
877 $fname, $ticket, [ 'domain' => $dbw->getDomainID() ]
881 $this->uncacheLinkTarget( $target );
883 DeferredUpdates
::POSTSEND
,
894 * @param Title $title
895 * @param string $force
899 public function resetNotificationTimestamp( User
$user, Title
$title, $force = '', $oldid = 0 ) {
900 // Only loggedin user can have a watchlist
901 if ( $this->readOnlyMode
->isReadOnly() ||
$user->isAnon() ) {
906 if ( $force != 'force' ) {
907 $item = $this->loadWatchedItem( $user, $title );
908 if ( !$item ||
$item->getNotificationTimestamp() === null ) {
913 // If the page is watched by the user (or may be watched), update the timestamp
914 $job = new ActivityUpdateJob(
917 'type' => 'updateWatchlistNotification',
918 'userid' => $user->getId(),
919 'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
924 // Try to run this post-send
925 // Calls DeferredUpdates::addCallableUpdate in normal operation
927 $this->deferredUpdatesAddCallableUpdateCallback
,
928 function () use ( $job ) {
933 $this->uncache( $user, $title );
938 private function getNotificationTimestamp( User
$user, Title
$title, $item, $force, $oldid ) {
940 // No oldid given, assuming latest revision; clear the timestamp.
944 if ( !$title->getNextRevisionID( $oldid ) ) {
945 // Oldid given and is the latest revision for this title; clear the timestamp.
949 if ( $item === null ) {
950 $item = $this->loadWatchedItem( $user, $title );
954 // This can only happen if $force is enabled.
958 // Oldid given and isn't the latest; update the timestamp.
959 // This will result in no further notification emails being sent!
960 // Calls Revision::getTimestampFromId in normal operation
961 $notificationTimestamp = call_user_func(
962 $this->revisionGetTimestampFromIdCallback
,
967 // We need to go one second to the future because of various strict comparisons
968 // throughout the codebase
969 $ts = new MWTimestamp( $notificationTimestamp );
970 $ts->timestamp
->add( new DateInterval( 'PT1S' ) );
971 $notificationTimestamp = $ts->getTimestamp( TS_MW
);
973 if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
974 if ( $force != 'force' ) {
977 // This is a little silly…
978 return $item->getNotificationTimestamp();
982 return $notificationTimestamp;
988 * @param int|null $unreadLimit
991 public function countUnreadNotifications( User
$user, $unreadLimit = null ) {
993 if ( $unreadLimit !== null ) {
994 $unreadLimit = (int)$unreadLimit;
995 $queryOptions['LIMIT'] = $unreadLimit;
998 $dbr = $this->getConnectionRef( DB_REPLICA
);
999 $rowCount = $dbr->selectRowCount(
1003 'wl_user' => $user->getId(),
1004 'wl_notificationtimestamp IS NOT NULL',
1010 if ( !isset( $unreadLimit ) ) {
1014 if ( $rowCount >= $unreadLimit ) {
1023 * @param LinkTarget $oldTarget
1024 * @param LinkTarget $newTarget
1026 public function duplicateAllAssociatedEntries( LinkTarget
$oldTarget, LinkTarget
$newTarget ) {
1027 $oldTarget = Title
::newFromLinkTarget( $oldTarget );
1028 $newTarget = Title
::newFromLinkTarget( $newTarget );
1030 $this->duplicateEntry( $oldTarget->getSubjectPage(), $newTarget->getSubjectPage() );
1031 $this->duplicateEntry( $oldTarget->getTalkPage(), $newTarget->getTalkPage() );
1036 * @param LinkTarget $oldTarget
1037 * @param LinkTarget $newTarget
1039 public function duplicateEntry( LinkTarget
$oldTarget, LinkTarget
$newTarget ) {
1040 $dbw = $this->getConnectionRef( DB_MASTER
);
1042 $result = $dbw->select(
1044 [ 'wl_user', 'wl_notificationtimestamp' ],
1046 'wl_namespace' => $oldTarget->getNamespace(),
1047 'wl_title' => $oldTarget->getDBkey(),
1053 $newNamespace = $newTarget->getNamespace();
1054 $newDBkey = $newTarget->getDBkey();
1056 # Construct array to replace into the watchlist
1058 foreach ( $result as $row ) {
1060 'wl_user' => $row->wl_user
,
1061 'wl_namespace' => $newNamespace,
1062 'wl_title' => $newDBkey,
1063 'wl_notificationtimestamp' => $row->wl_notificationtimestamp
,
1067 if ( !empty( $values ) ) {
1069 # Note that multi-row replace is very efficient for MySQL but may be inefficient for
1070 # some other DBMSes, mostly due to poor simulation by us
1073 [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
1081 * @param TitleValue[] $titles
1084 private function getTitleDbKeysGroupedByNamespace( array $titles ) {
1086 foreach ( $titles as $title ) {
1087 // Group titles by namespace.
1088 $rows[ $title->getNamespace() ][] = $title->getDBkey();
1095 * @param Title[] $titles
1097 private function uncacheTitlesForUser( User
$user, array $titles ) {
1098 foreach ( $titles as $title ) {
1099 $this->uncache( $user, $title );