3 use Wikimedia\Rdbms\IDatabase
;
4 use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface
;
5 use MediaWiki\Linker\LinkTarget
;
6 use MediaWiki\MediaWikiServices
;
7 use Wikimedia\Assert\Assert
;
8 use Wikimedia\ScopedCallback
;
9 use Wikimedia\Rdbms\LoadBalancer
;
10 use Wikimedia\Rdbms\DBUnexpectedError
;
13 * Storage layer class for WatchedItems.
14 * Database interaction.
16 * Uses database because this uses User::isAnon
23 class WatchedItemStore
implements StatsdAwareInterface
{
25 const SORT_DESC
= 'DESC';
26 const SORT_ASC
= 'ASC';
31 private $loadBalancer;
39 * @var array[] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key'
40 * The index is needed so that on mass changes all relevant items can be un-cached.
41 * For example: Clearing a users watchlist of all items or updating notification timestamps
42 * for all users watching a single target.
44 private $cacheIndex = [];
49 private $deferredUpdatesAddCallableUpdateCallback;
54 private $revisionGetTimestampFromIdCallback;
57 * @var StatsdDataFactoryInterface
62 * @param LoadBalancer $loadBalancer
63 * @param HashBagOStuff $cache
65 public function __construct(
66 LoadBalancer
$loadBalancer,
69 $this->loadBalancer
= $loadBalancer;
70 $this->cache
= $cache;
71 $this->stats
= new NullStatsdDataFactory();
72 $this->deferredUpdatesAddCallableUpdateCallback
= [ 'DeferredUpdates', 'addCallableUpdate' ];
73 $this->revisionGetTimestampFromIdCallback
= [ 'Revision', 'getTimestampFromId' ];
76 public function setStatsdDataFactory( StatsdDataFactoryInterface
$stats ) {
77 $this->stats
= $stats;
81 * Overrides the DeferredUpdates::addCallableUpdate callback
82 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
84 * @param callable $callback
86 * @see DeferredUpdates::addCallableUpdate for callback signiture
88 * @return ScopedCallback to reset the overridden value
91 public function overrideDeferredUpdatesAddCallableUpdateCallback( callable
$callback ) {
92 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
93 throw new MWException(
94 'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
97 $previousValue = $this->deferredUpdatesAddCallableUpdateCallback
;
98 $this->deferredUpdatesAddCallableUpdateCallback
= $callback;
99 return new ScopedCallback( function() use ( $previousValue ) {
100 $this->deferredUpdatesAddCallableUpdateCallback
= $previousValue;
105 * Overrides the Revision::getTimestampFromId callback
106 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
108 * @param callable $callback
109 * @see Revision::getTimestampFromId for callback signiture
111 * @return ScopedCallback to reset the overridden value
112 * @throws MWException
114 public function overrideRevisionGetTimestampFromIdCallback( callable
$callback ) {
115 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
116 throw new MWException(
117 'Cannot override Revision::getTimestampFromId callback in operation.'
120 $previousValue = $this->revisionGetTimestampFromIdCallback
;
121 $this->revisionGetTimestampFromIdCallback
= $callback;
122 return new ScopedCallback( function() use ( $previousValue ) {
123 $this->revisionGetTimestampFromIdCallback
= $previousValue;
127 private function getCacheKey( User
$user, LinkTarget
$target ) {
128 return $this->cache
->makeKey(
129 (string)$target->getNamespace(),
131 (string)$user->getId()
135 private function cache( WatchedItem
$item ) {
136 $user = $item->getUser();
137 $target = $item->getLinkTarget();
138 $key = $this->getCacheKey( $user, $target );
139 $this->cache
->set( $key, $item );
140 $this->cacheIndex
[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
141 $this->stats
->increment( 'WatchedItemStore.cache' );
144 private function uncache( User
$user, LinkTarget
$target ) {
145 $this->cache
->delete( $this->getCacheKey( $user, $target ) );
146 unset( $this->cacheIndex
[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
147 $this->stats
->increment( 'WatchedItemStore.uncache' );
150 private function uncacheLinkTarget( LinkTarget
$target ) {
151 $this->stats
->increment( 'WatchedItemStore.uncacheLinkTarget' );
152 if ( !isset( $this->cacheIndex
[$target->getNamespace()][$target->getDBkey()] ) ) {
155 foreach ( $this->cacheIndex
[$target->getNamespace()][$target->getDBkey()] as $key ) {
156 $this->stats
->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
157 $this->cache
->delete( $key );
161 private function uncacheUser( User
$user ) {
162 $this->stats
->increment( 'WatchedItemStore.uncacheUser' );
163 foreach ( $this->cacheIndex
as $ns => $dbKeyArray ) {
164 foreach ( $dbKeyArray as $dbKey => $userArray ) {
165 if ( isset( $userArray[$user->getId()] ) ) {
166 $this->stats
->increment( 'WatchedItemStore.uncacheUser.items' );
167 $this->cache
->delete( $userArray[$user->getId()] );
175 * @param LinkTarget $target
177 * @return WatchedItem|false
179 private function getCached( User
$user, LinkTarget
$target ) {
180 return $this->cache
->get( $this->getCacheKey( $user, $target ) );
184 * Return an array of conditions to select or update the appropriate database
188 * @param LinkTarget $target
192 private function dbCond( User
$user, LinkTarget
$target ) {
194 'wl_user' => $user->getId(),
195 'wl_namespace' => $target->getNamespace(),
196 'wl_title' => $target->getDBkey(),
201 * @param int $dbIndex DB_MASTER or DB_REPLICA
204 * @throws MWException
206 private function getConnectionRef( $dbIndex ) {
207 return $this->loadBalancer
->getConnectionRef( $dbIndex, [ 'watchlist' ] );
211 * Count the number of individual items that are watched by the user.
212 * If a subject and corresponding talk page are watched this will return 2.
218 public function countWatchedItems( User
$user ) {
219 $dbr = $this->getConnectionRef( DB_REPLICA
);
220 $return = (int)$dbr->selectField(
224 'wl_user' => $user->getId()
233 * @param LinkTarget $target
237 public function countWatchers( LinkTarget
$target ) {
238 $dbr = $this->getConnectionRef( DB_REPLICA
);
239 $return = (int)$dbr->selectField(
243 'wl_namespace' => $target->getNamespace(),
244 'wl_title' => $target->getDBkey(),
253 * Number of page watchers who also visited a "recent" edit
255 * @param LinkTarget $target
256 * @param mixed $threshold timestamp accepted by wfTimestamp
259 * @throws DBUnexpectedError
260 * @throws MWException
262 public function countVisitingWatchers( LinkTarget
$target, $threshold ) {
263 $dbr = $this->getConnectionRef( DB_REPLICA
);
264 $visitingWatchers = (int)$dbr->selectField(
268 'wl_namespace' => $target->getNamespace(),
269 'wl_title' => $target->getDBkey(),
270 'wl_notificationtimestamp >= ' .
271 $dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
272 ' OR wl_notificationtimestamp IS NULL'
277 return $visitingWatchers;
281 * @param LinkTarget[] $targets
282 * @param array $options Allowed keys:
283 * 'minimumWatchers' => int
285 * @return array multi dimensional like $return[$namespaceId][$titleString] = int $watchers
286 * All targets will be present in the result. 0 either means no watchers or the number
287 * of watchers was below the minimumWatchers option if passed.
289 public function countWatchersMultiple( array $targets, array $options = [] ) {
290 $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
292 $dbr = $this->getConnectionRef( DB_REPLICA
);
294 if ( array_key_exists( 'minimumWatchers', $options ) ) {
295 $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
298 $lb = new LinkBatch( $targets );
301 [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
302 [ $lb->constructSet( 'wl', $dbr ) ],
308 foreach ( $targets as $linkTarget ) {
309 $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
312 foreach ( $res as $row ) {
313 $watchCounts[$row->wl_namespace
][$row->wl_title
] = (int)$row->watchers
;
320 * Number of watchers of each page who have visited recent edits to that page
322 * @param array $targetsWithVisitThresholds array of pairs (LinkTarget $target, mixed $threshold),
324 * - a timestamp of the recent edit if $target exists (format accepted by wfTimestamp)
325 * - null if $target doesn't exist
326 * @param int|null $minimumWatchers
327 * @return array multi-dimensional like $return[$namespaceId][$titleString] = $watchers,
328 * where $watchers is an int:
329 * - if the page exists, number of users watching who have visited the page recently
330 * - if the page doesn't exist, number of users that have the page on their watchlist
331 * - 0 means there are no visiting watchers or their number is below the minimumWatchers
332 * option (if passed).
334 public function countVisitingWatchersMultiple(
335 array $targetsWithVisitThresholds,
336 $minimumWatchers = null
338 $dbr = $this->getConnectionRef( DB_REPLICA
);
340 $conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds );
342 $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
343 if ( $minimumWatchers !== null ) {
344 $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
348 [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
355 foreach ( $targetsWithVisitThresholds as list( $target ) ) {
356 /* @var LinkTarget $target */
357 $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
360 foreach ( $res as $row ) {
361 $watcherCounts[$row->wl_namespace
][$row->wl_title
] = (int)$row->watchers
;
364 return $watcherCounts;
368 * Generates condition for the query used in a batch count visiting watchers.
370 * @param IDatabase $db
371 * @param array $targetsWithVisitThresholds array of pairs (LinkTarget, last visit threshold)
374 private function getVisitingWatchersCondition(
376 array $targetsWithVisitThresholds
378 $missingTargets = [];
379 $namespaceConds = [];
380 foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
381 if ( $threshold === null ) {
382 $missingTargets[] = $target;
385 /* @var LinkTarget $target */
386 $namespaceConds[$target->getNamespace()][] = $db->makeList( [
387 'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
389 'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
390 'wl_notificationtimestamp IS NULL'
396 foreach ( $namespaceConds as $namespace => $pageConds ) {
397 $conds[] = $db->makeList( [
398 'wl_namespace = ' . $namespace,
399 '(' . $db->makeList( $pageConds, LIST_OR
) . ')'
403 if ( $missingTargets ) {
404 $lb = new LinkBatch( $missingTargets );
405 $conds[] = $lb->constructSet( 'wl', $db );
408 return $db->makeList( $conds, LIST_OR
);
412 * Get an item (may be cached)
415 * @param LinkTarget $target
417 * @return WatchedItem|false
419 public function getWatchedItem( User
$user, LinkTarget
$target ) {
420 if ( $user->isAnon() ) {
424 $cached = $this->getCached( $user, $target );
426 $this->stats
->increment( 'WatchedItemStore.getWatchedItem.cached' );
429 $this->stats
->increment( 'WatchedItemStore.getWatchedItem.load' );
430 return $this->loadWatchedItem( $user, $target );
434 * Loads an item from the db
437 * @param LinkTarget $target
439 * @return WatchedItem|false
441 public function loadWatchedItem( User
$user, LinkTarget
$target ) {
442 // Only loggedin user can have a watchlist
443 if ( $user->isAnon() ) {
447 $dbr = $this->getConnectionRef( DB_REPLICA
);
448 $row = $dbr->selectRow(
450 'wl_notificationtimestamp',
451 $this->dbCond( $user, $target ),
459 $item = new WatchedItem(
462 $row->wl_notificationtimestamp
464 $this->cache( $item );
471 * @param array $options Allowed keys:
472 * 'forWrite' => bool defaults to false
473 * 'sort' => string optional sorting by namespace ID and title
474 * one of the self::SORT_* constants
476 * @return WatchedItem[]
478 public function getWatchedItemsForUser( User
$user, array $options = [] ) {
479 $options +
= [ 'forWrite' => false ];
482 if ( array_key_exists( 'sort', $options ) ) {
484 ( in_array( $options['sort'], [ self
::SORT_ASC
, self
::SORT_DESC
] ) ),
485 '$options[\'sort\']',
486 'must be SORT_ASC or SORT_DESC'
488 $dbOptions['ORDER BY'] = [
489 "wl_namespace {$options['sort']}",
490 "wl_title {$options['sort']}"
493 $db = $this->getConnectionRef( $options['forWrite'] ? DB_MASTER
: DB_REPLICA
);
497 [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
498 [ 'wl_user' => $user->getId() ],
504 foreach ( $res as $row ) {
505 // @todo: Should we add these to the process cache?
506 $watchedItems[] = new WatchedItem(
508 new TitleValue( (int)$row->wl_namespace
, $row->wl_title
),
509 $row->wl_notificationtimestamp
513 return $watchedItems;
517 * Must be called separately for Subject & Talk namespaces
520 * @param LinkTarget $target
524 public function isWatched( User
$user, LinkTarget
$target ) {
525 return (bool)$this->getWatchedItem( $user, $target );
530 * @param LinkTarget[] $targets
532 * @return array multi-dimensional like $return[$namespaceId][$titleString] = $timestamp,
533 * where $timestamp is:
534 * - string|null value of wl_notificationtimestamp,
535 * - false if $target is not watched by $user.
537 public function getNotificationTimestampsBatch( User
$user, array $targets ) {
539 foreach ( $targets as $target ) {
540 $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
543 if ( $user->isAnon() ) {
548 foreach ( $targets as $target ) {
549 $cachedItem = $this->getCached( $user, $target );
551 $timestamps[$target->getNamespace()][$target->getDBkey()] =
552 $cachedItem->getNotificationTimestamp();
554 $targetsToLoad[] = $target;
558 if ( !$targetsToLoad ) {
562 $dbr = $this->getConnectionRef( DB_REPLICA
);
564 $lb = new LinkBatch( $targetsToLoad );
567 [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
569 $lb->constructSet( 'wl', $dbr ),
570 'wl_user' => $user->getId(),
575 foreach ( $res as $row ) {
576 $timestamps[$row->wl_namespace
][$row->wl_title
] = $row->wl_notificationtimestamp
;
583 * Must be called separately for Subject & Talk namespaces
586 * @param LinkTarget $target
588 public function addWatch( User
$user, LinkTarget
$target ) {
589 $this->addWatchBatchForUser( $user, [ $target ] );
594 * @param LinkTarget[] $targets
596 * @return bool success
598 public function addWatchBatchForUser( User
$user, array $targets ) {
599 if ( $this->loadBalancer
->getReadOnlyReason() !== false ) {
602 // Only loggedin user can have a watchlist
603 if ( $user->isAnon() ) {
613 foreach ( $targets as $target ) {
615 'wl_user' => $user->getId(),
616 'wl_namespace' => $target->getNamespace(),
617 'wl_title' => $target->getDBkey(),
618 'wl_notificationtimestamp' => null,
620 $items[] = new WatchedItem(
625 $this->uncache( $user, $target );
628 $dbw = $this->getConnectionRef( DB_MASTER
);
629 foreach ( array_chunk( $rows, 100 ) as $toInsert ) {
630 // Use INSERT IGNORE to avoid overwriting the notification timestamp
631 // if there's already an entry for this page
632 $dbw->insert( 'watchlist', $toInsert, __METHOD__
, 'IGNORE' );
634 // Update process cache to ensure skin doesn't claim that the current
635 // page is unwatched in the response of action=watch itself (T28292).
636 // This would otherwise be re-queried from a slave by isWatched().
637 foreach ( $items as $item ) {
638 $this->cache( $item );
645 * Removes the an entry for the User watching the LinkTarget
646 * Must be called separately for Subject & Talk namespaces
649 * @param LinkTarget $target
651 * @return bool success
652 * @throws DBUnexpectedError
653 * @throws MWException
655 public function removeWatch( User
$user, LinkTarget
$target ) {
656 // Only logged in user can have a watchlist
657 if ( $this->loadBalancer
->getReadOnlyReason() !== false ||
$user->isAnon() ) {
661 $this->uncache( $user, $target );
663 $dbw = $this->getConnectionRef( DB_MASTER
);
664 $dbw->delete( 'watchlist',
666 'wl_user' => $user->getId(),
667 'wl_namespace' => $target->getNamespace(),
668 'wl_title' => $target->getDBkey(),
671 $success = (bool)$dbw->affectedRows();
677 * @param User $user The user to set the timestamp for
678 * @param string|null $timestamp Set the update timestamp to this value
679 * @param LinkTarget[] $targets List of targets to update. Default to all targets
681 * @return bool success
683 public function setNotificationTimestampsForUser( User
$user, $timestamp, array $targets = [] ) {
684 // Only loggedin user can have a watchlist
685 if ( $user->isAnon() ) {
689 $dbw = $this->getConnectionRef( DB_MASTER
);
691 $conds = [ 'wl_user' => $user->getId() ];
693 $batch = new LinkBatch( $targets );
694 $conds[] = $batch->constructSet( 'wl', $dbw );
697 if ( $timestamp !== null ) {
698 $timestamp = $dbw->timestamp( $timestamp );
701 $success = $dbw->update(
703 [ 'wl_notificationtimestamp' => $timestamp ],
708 $this->uncacheUser( $user );
714 * @param User $editor The editor that triggered the update. Their notification
715 * timestamp will not be updated(they have already seen it)
716 * @param LinkTarget $target The target to update timestamps for
717 * @param string $timestamp Set the update timestamp to this value
719 * @return int[] Array of user IDs the timestamp has been updated for
721 public function updateNotificationTimestamp( User
$editor, LinkTarget
$target, $timestamp ) {
722 $dbw = $this->getConnectionRef( DB_MASTER
);
723 $uids = $dbw->selectFieldValues(
727 'wl_user != ' . intval( $editor->getId() ),
728 'wl_namespace' => $target->getNamespace(),
729 'wl_title' => $target->getDBkey(),
730 'wl_notificationtimestamp IS NULL',
735 $watchers = array_map( 'intval', $uids );
737 // Update wl_notificationtimestamp for all watching users except the editor
739 DeferredUpdates
::addCallableUpdate(
740 function () use ( $timestamp, $watchers, $target, $fname ) {
741 global $wgUpdateRowsPerQuery;
743 $dbw = $this->getConnectionRef( DB_MASTER
);
744 $factory = MediaWikiServices
::getInstance()->getDBLoadBalancerFactory();
745 $ticket = $factory->getEmptyTransactionTicket( __METHOD__
);
747 $watchersChunks = array_chunk( $watchers, $wgUpdateRowsPerQuery );
748 foreach ( $watchersChunks as $watchersChunk ) {
749 $dbw->update( 'watchlist',
751 'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
752 ], [ /* WHERE - TODO Use wl_id T130067 */
753 'wl_user' => $watchersChunk,
754 'wl_namespace' => $target->getNamespace(),
755 'wl_title' => $target->getDBkey(),
758 if ( count( $watchersChunks ) > 1 ) {
759 $factory->commitAndWaitForReplication(
760 __METHOD__
, $ticket, [ 'wiki' => $dbw->getWikiID() ]
764 $this->uncacheLinkTarget( $target );
766 DeferredUpdates
::POSTSEND
,
775 * Reset the notification timestamp of this entry
778 * @param Title $title
779 * @param string $force Whether to force the write query to be executed even if the
780 * page is not watched or the notification timestamp is already NULL.
781 * 'force' in order to force
782 * @param int $oldid The revision id being viewed. If not given or 0, latest revision is assumed.
784 * @return bool success
786 public function resetNotificationTimestamp( User
$user, Title
$title, $force = '', $oldid = 0 ) {
787 // Only loggedin user can have a watchlist
788 if ( $this->loadBalancer
->getReadOnlyReason() !== false ||
$user->isAnon() ) {
793 if ( $force != 'force' ) {
794 $item = $this->loadWatchedItem( $user, $title );
795 if ( !$item ||
$item->getNotificationTimestamp() === null ) {
800 // If the page is watched by the user (or may be watched), update the timestamp
801 $job = new ActivityUpdateJob(
804 'type' => 'updateWatchlistNotification',
805 'userid' => $user->getId(),
806 'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
811 // Try to run this post-send
812 // Calls DeferredUpdates::addCallableUpdate in normal operation
814 $this->deferredUpdatesAddCallableUpdateCallback
,
815 function() use ( $job ) {
820 $this->uncache( $user, $title );
825 private function getNotificationTimestamp( User
$user, Title
$title, $item, $force, $oldid ) {
827 // No oldid given, assuming latest revision; clear the timestamp.
831 if ( !$title->getNextRevisionID( $oldid ) ) {
832 // Oldid given and is the latest revision for this title; clear the timestamp.
836 if ( $item === null ) {
837 $item = $this->loadWatchedItem( $user, $title );
841 // This can only happen if $force is enabled.
845 // Oldid given and isn't the latest; update the timestamp.
846 // This will result in no further notification emails being sent!
847 // Calls Revision::getTimestampFromId in normal operation
848 $notificationTimestamp = call_user_func(
849 $this->revisionGetTimestampFromIdCallback
,
854 // We need to go one second to the future because of various strict comparisons
855 // throughout the codebase
856 $ts = new MWTimestamp( $notificationTimestamp );
857 $ts->timestamp
->add( new DateInterval( 'PT1S' ) );
858 $notificationTimestamp = $ts->getTimestamp( TS_MW
);
860 if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
861 if ( $force != 'force' ) {
864 // This is a little silly…
865 return $item->getNotificationTimestamp();
869 return $notificationTimestamp;
874 * @param int $unreadLimit
876 * @return int|bool The number of unread notifications
877 * true if greater than or equal to $unreadLimit
879 public function countUnreadNotifications( User
$user, $unreadLimit = null ) {
881 if ( $unreadLimit !== null ) {
882 $unreadLimit = (int)$unreadLimit;
883 $queryOptions['LIMIT'] = $unreadLimit;
886 $dbr = $this->getConnectionRef( DB_REPLICA
);
887 $rowCount = $dbr->selectRowCount(
891 'wl_user' => $user->getId(),
892 'wl_notificationtimestamp IS NOT NULL',
898 if ( !isset( $unreadLimit ) ) {
902 if ( $rowCount >= $unreadLimit ) {
910 * Check if the given title already is watched by the user, and if so
911 * add a watch for the new title.
913 * To be used for page renames and such.
915 * @param LinkTarget $oldTarget
916 * @param LinkTarget $newTarget
918 public function duplicateAllAssociatedEntries( LinkTarget
$oldTarget, LinkTarget
$newTarget ) {
919 $oldTarget = Title
::newFromLinkTarget( $oldTarget );
920 $newTarget = Title
::newFromLinkTarget( $newTarget );
922 $this->duplicateEntry( $oldTarget->getSubjectPage(), $newTarget->getSubjectPage() );
923 $this->duplicateEntry( $oldTarget->getTalkPage(), $newTarget->getTalkPage() );
927 * Check if the given title already is watched by the user, and if so
928 * add a watch for the new title.
930 * To be used for page renames and such.
931 * This must be called separately for Subject and Talk pages
933 * @param LinkTarget $oldTarget
934 * @param LinkTarget $newTarget
936 public function duplicateEntry( LinkTarget
$oldTarget, LinkTarget
$newTarget ) {
937 $dbw = $this->getConnectionRef( DB_MASTER
);
939 $result = $dbw->select(
941 [ 'wl_user', 'wl_notificationtimestamp' ],
943 'wl_namespace' => $oldTarget->getNamespace(),
944 'wl_title' => $oldTarget->getDBkey(),
950 $newNamespace = $newTarget->getNamespace();
951 $newDBkey = $newTarget->getDBkey();
953 # Construct array to replace into the watchlist
955 foreach ( $result as $row ) {
957 'wl_user' => $row->wl_user
,
958 'wl_namespace' => $newNamespace,
959 'wl_title' => $newDBkey,
960 'wl_notificationtimestamp' => $row->wl_notificationtimestamp
,
964 if ( !empty( $values ) ) {
966 # Note that multi-row replace is very efficient for MySQL but may be inefficient for
967 # some other DBMSes, mostly due to poor simulation by us
970 [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],