Merge "Don't create user in AuthManagerTest dataProvider"
[lhc/web/wiklou.git] / includes / WatchedItemStore.php
1 <?php
2
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
11 /**
12 * Storage layer class for WatchedItems.
13 * Database interaction.
14 *
15 * Uses database because this uses User::isAnon
16 *
17 * @group Database
18 *
19 * @author Addshore
20 * @since 1.27
21 */
22 class WatchedItemStore implements StatsdAwareInterface {
23
24 const SORT_DESC = 'DESC';
25 const SORT_ASC = 'ASC';
26
27 /**
28 * @var LoadBalancer
29 */
30 private $loadBalancer;
31
32 /**
33 * @var HashBagOStuff
34 */
35 private $cache;
36
37 /**
38 * @var array[] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key'
39 * The index is needed so that on mass changes all relevant items can be un-cached.
40 * For example: Clearing a users watchlist of all items or updating notification timestamps
41 * for all users watching a single target.
42 */
43 private $cacheIndex = [];
44
45 /**
46 * @var callable|null
47 */
48 private $deferredUpdatesAddCallableUpdateCallback;
49
50 /**
51 * @var callable|null
52 */
53 private $revisionGetTimestampFromIdCallback;
54
55 /**
56 * @var StatsdDataFactoryInterface
57 */
58 private $stats;
59
60 /**
61 * @param LoadBalancer $loadBalancer
62 * @param HashBagOStuff $cache
63 */
64 public function __construct(
65 LoadBalancer $loadBalancer,
66 HashBagOStuff $cache
67 ) {
68 $this->loadBalancer = $loadBalancer;
69 $this->cache = $cache;
70 $this->stats = new NullStatsdDataFactory();
71 $this->deferredUpdatesAddCallableUpdateCallback = [ 'DeferredUpdates', 'addCallableUpdate' ];
72 $this->revisionGetTimestampFromIdCallback = [ 'Revision', 'getTimestampFromId' ];
73 }
74
75 public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) {
76 $this->stats = $stats;
77 }
78
79 /**
80 * Overrides the DeferredUpdates::addCallableUpdate callback
81 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
82 *
83 * @param callable $callback
84 *
85 * @see DeferredUpdates::addCallableUpdate for callback signiture
86 *
87 * @return ScopedCallback to reset the overridden value
88 * @throws MWException
89 */
90 public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) {
91 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
92 throw new MWException(
93 'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
94 );
95 }
96 $previousValue = $this->deferredUpdatesAddCallableUpdateCallback;
97 $this->deferredUpdatesAddCallableUpdateCallback = $callback;
98 return new ScopedCallback( function() use ( $previousValue ) {
99 $this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
100 } );
101 }
102
103 /**
104 * Overrides the Revision::getTimestampFromId callback
105 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
106 *
107 * @param callable $callback
108 * @see Revision::getTimestampFromId for callback signiture
109 *
110 * @return ScopedCallback to reset the overridden value
111 * @throws MWException
112 */
113 public function overrideRevisionGetTimestampFromIdCallback( callable $callback ) {
114 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
115 throw new MWException(
116 'Cannot override Revision::getTimestampFromId callback in operation.'
117 );
118 }
119 $previousValue = $this->revisionGetTimestampFromIdCallback;
120 $this->revisionGetTimestampFromIdCallback = $callback;
121 return new ScopedCallback( function() use ( $previousValue ) {
122 $this->revisionGetTimestampFromIdCallback = $previousValue;
123 } );
124 }
125
126 private function getCacheKey( User $user, LinkTarget $target ) {
127 return $this->cache->makeKey(
128 (string)$target->getNamespace(),
129 $target->getDBkey(),
130 (string)$user->getId()
131 );
132 }
133
134 private function cache( WatchedItem $item ) {
135 $user = $item->getUser();
136 $target = $item->getLinkTarget();
137 $key = $this->getCacheKey( $user, $target );
138 $this->cache->set( $key, $item );
139 $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
140 $this->stats->increment( 'WatchedItemStore.cache' );
141 }
142
143 private function uncache( User $user, LinkTarget $target ) {
144 $this->cache->delete( $this->getCacheKey( $user, $target ) );
145 unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
146 $this->stats->increment( 'WatchedItemStore.uncache' );
147 }
148
149 private function uncacheLinkTarget( LinkTarget $target ) {
150 $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' );
151 if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
152 return;
153 }
154 foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
155 $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
156 $this->cache->delete( $key );
157 }
158 }
159
160 private function uncacheUser( User $user ) {
161 $this->stats->increment( 'WatchedItemStore.uncacheUser' );
162 foreach ( $this->cacheIndex as $ns => $dbKeyArray ) {
163 foreach ( $dbKeyArray as $dbKey => $userArray ) {
164 if ( isset( $userArray[$user->getId()] ) ) {
165 $this->stats->increment( 'WatchedItemStore.uncacheUser.items' );
166 $this->cache->delete( $userArray[$user->getId()] );
167 }
168 }
169 }
170 }
171
172 /**
173 * @param User $user
174 * @param LinkTarget $target
175 *
176 * @return WatchedItem|false
177 */
178 private function getCached( User $user, LinkTarget $target ) {
179 return $this->cache->get( $this->getCacheKey( $user, $target ) );
180 }
181
182 /**
183 * Return an array of conditions to select or update the appropriate database
184 * row.
185 *
186 * @param User $user
187 * @param LinkTarget $target
188 *
189 * @return array
190 */
191 private function dbCond( User $user, LinkTarget $target ) {
192 return [
193 'wl_user' => $user->getId(),
194 'wl_namespace' => $target->getNamespace(),
195 'wl_title' => $target->getDBkey(),
196 ];
197 }
198
199 /**
200 * @param int $dbIndex DB_MASTER or DB_REPLICA
201 *
202 * @return IDatabase
203 * @throws MWException
204 */
205 private function getConnectionRef( $dbIndex ) {
206 return $this->loadBalancer->getConnectionRef( $dbIndex, [ 'watchlist' ] );
207 }
208
209 /**
210 * Count the number of individual items that are watched by the user.
211 * If a subject and corresponding talk page are watched this will return 2.
212 *
213 * @param User $user
214 *
215 * @return int
216 */
217 public function countWatchedItems( User $user ) {
218 $dbr = $this->getConnectionRef( DB_REPLICA );
219 $return = (int)$dbr->selectField(
220 'watchlist',
221 'COUNT(*)',
222 [
223 'wl_user' => $user->getId()
224 ],
225 __METHOD__
226 );
227
228 return $return;
229 }
230
231 /**
232 * @param LinkTarget $target
233 *
234 * @return int
235 */
236 public function countWatchers( LinkTarget $target ) {
237 $dbr = $this->getConnectionRef( DB_REPLICA );
238 $return = (int)$dbr->selectField(
239 'watchlist',
240 'COUNT(*)',
241 [
242 'wl_namespace' => $target->getNamespace(),
243 'wl_title' => $target->getDBkey(),
244 ],
245 __METHOD__
246 );
247
248 return $return;
249 }
250
251 /**
252 * Number of page watchers who also visited a "recent" edit
253 *
254 * @param LinkTarget $target
255 * @param mixed $threshold timestamp accepted by wfTimestamp
256 *
257 * @return int
258 * @throws DBUnexpectedError
259 * @throws MWException
260 */
261 public function countVisitingWatchers( LinkTarget $target, $threshold ) {
262 $dbr = $this->getConnectionRef( DB_REPLICA );
263 $visitingWatchers = (int)$dbr->selectField(
264 'watchlist',
265 'COUNT(*)',
266 [
267 'wl_namespace' => $target->getNamespace(),
268 'wl_title' => $target->getDBkey(),
269 'wl_notificationtimestamp >= ' .
270 $dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
271 ' OR wl_notificationtimestamp IS NULL'
272 ],
273 __METHOD__
274 );
275
276 return $visitingWatchers;
277 }
278
279 /**
280 * @param LinkTarget[] $targets
281 * @param array $options Allowed keys:
282 * 'minimumWatchers' => int
283 *
284 * @return array multi dimensional like $return[$namespaceId][$titleString] = int $watchers
285 * All targets will be present in the result. 0 either means no watchers or the number
286 * of watchers was below the minimumWatchers option if passed.
287 */
288 public function countWatchersMultiple( array $targets, array $options = [] ) {
289 $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
290
291 $dbr = $this->getConnectionRef( DB_REPLICA );
292
293 if ( array_key_exists( 'minimumWatchers', $options ) ) {
294 $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
295 }
296
297 $lb = new LinkBatch( $targets );
298 $res = $dbr->select(
299 'watchlist',
300 [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
301 [ $lb->constructSet( 'wl', $dbr ) ],
302 __METHOD__,
303 $dbOptions
304 );
305
306 $watchCounts = [];
307 foreach ( $targets as $linkTarget ) {
308 $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
309 }
310
311 foreach ( $res as $row ) {
312 $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
313 }
314
315 return $watchCounts;
316 }
317
318 /**
319 * Number of watchers of each page who have visited recent edits to that page
320 *
321 * @param array $targetsWithVisitThresholds array of pairs (LinkTarget $target, mixed $threshold),
322 * $threshold is:
323 * - a timestamp of the recent edit if $target exists (format accepted by wfTimestamp)
324 * - null if $target doesn't exist
325 * @param int|null $minimumWatchers
326 * @return array multi-dimensional like $return[$namespaceId][$titleString] = $watchers,
327 * where $watchers is an int:
328 * - if the page exists, number of users watching who have visited the page recently
329 * - if the page doesn't exist, number of users that have the page on their watchlist
330 * - 0 means there are no visiting watchers or their number is below the minimumWatchers
331 * option (if passed).
332 */
333 public function countVisitingWatchersMultiple(
334 array $targetsWithVisitThresholds,
335 $minimumWatchers = null
336 ) {
337 $dbr = $this->getConnectionRef( DB_REPLICA );
338
339 $conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds );
340
341 $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
342 if ( $minimumWatchers !== null ) {
343 $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
344 }
345 $res = $dbr->select(
346 'watchlist',
347 [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
348 $conds,
349 __METHOD__,
350 $dbOptions
351 );
352
353 $watcherCounts = [];
354 foreach ( $targetsWithVisitThresholds as list( $target ) ) {
355 /* @var LinkTarget $target */
356 $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
357 }
358
359 foreach ( $res as $row ) {
360 $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
361 }
362
363 return $watcherCounts;
364 }
365
366 /**
367 * Generates condition for the query used in a batch count visiting watchers.
368 *
369 * @param IDatabase $db
370 * @param array $targetsWithVisitThresholds array of pairs (LinkTarget, last visit threshold)
371 * @return string
372 */
373 private function getVisitingWatchersCondition(
374 IDatabase $db,
375 array $targetsWithVisitThresholds
376 ) {
377 $missingTargets = [];
378 $namespaceConds = [];
379 foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
380 if ( $threshold === null ) {
381 $missingTargets[] = $target;
382 continue;
383 }
384 /* @var LinkTarget $target */
385 $namespaceConds[$target->getNamespace()][] = $db->makeList( [
386 'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
387 $db->makeList( [
388 'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
389 'wl_notificationtimestamp IS NULL'
390 ], LIST_OR )
391 ], LIST_AND );
392 }
393
394 $conds = [];
395 foreach ( $namespaceConds as $namespace => $pageConds ) {
396 $conds[] = $db->makeList( [
397 'wl_namespace = ' . $namespace,
398 '(' . $db->makeList( $pageConds, LIST_OR ) . ')'
399 ], LIST_AND );
400 }
401
402 if ( $missingTargets ) {
403 $lb = new LinkBatch( $missingTargets );
404 $conds[] = $lb->constructSet( 'wl', $db );
405 }
406
407 return $db->makeList( $conds, LIST_OR );
408 }
409
410 /**
411 * Get an item (may be cached)
412 *
413 * @param User $user
414 * @param LinkTarget $target
415 *
416 * @return WatchedItem|false
417 */
418 public function getWatchedItem( User $user, LinkTarget $target ) {
419 if ( $user->isAnon() ) {
420 return false;
421 }
422
423 $cached = $this->getCached( $user, $target );
424 if ( $cached ) {
425 $this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' );
426 return $cached;
427 }
428 $this->stats->increment( 'WatchedItemStore.getWatchedItem.load' );
429 return $this->loadWatchedItem( $user, $target );
430 }
431
432 /**
433 * Loads an item from the db
434 *
435 * @param User $user
436 * @param LinkTarget $target
437 *
438 * @return WatchedItem|false
439 */
440 public function loadWatchedItem( User $user, LinkTarget $target ) {
441 // Only loggedin user can have a watchlist
442 if ( $user->isAnon() ) {
443 return false;
444 }
445
446 $dbr = $this->getConnectionRef( DB_REPLICA );
447 $row = $dbr->selectRow(
448 'watchlist',
449 'wl_notificationtimestamp',
450 $this->dbCond( $user, $target ),
451 __METHOD__
452 );
453
454 if ( !$row ) {
455 return false;
456 }
457
458 $item = new WatchedItem(
459 $user,
460 $target,
461 $row->wl_notificationtimestamp
462 );
463 $this->cache( $item );
464
465 return $item;
466 }
467
468 /**
469 * @param User $user
470 * @param array $options Allowed keys:
471 * 'forWrite' => bool defaults to false
472 * 'sort' => string optional sorting by namespace ID and title
473 * one of the self::SORT_* constants
474 *
475 * @return WatchedItem[]
476 */
477 public function getWatchedItemsForUser( User $user, array $options = [] ) {
478 $options += [ 'forWrite' => false ];
479
480 $dbOptions = [];
481 if ( array_key_exists( 'sort', $options ) ) {
482 Assert::parameter(
483 ( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
484 '$options[\'sort\']',
485 'must be SORT_ASC or SORT_DESC'
486 );
487 $dbOptions['ORDER BY'] = [
488 "wl_namespace {$options['sort']}",
489 "wl_title {$options['sort']}"
490 ];
491 }
492 $db = $this->getConnectionRef( $options['forWrite'] ? DB_MASTER : DB_REPLICA );
493
494 $res = $db->select(
495 'watchlist',
496 [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
497 [ 'wl_user' => $user->getId() ],
498 __METHOD__,
499 $dbOptions
500 );
501
502 $watchedItems = [];
503 foreach ( $res as $row ) {
504 // @todo: Should we add these to the process cache?
505 $watchedItems[] = new WatchedItem(
506 $user,
507 new TitleValue( (int)$row->wl_namespace, $row->wl_title ),
508 $row->wl_notificationtimestamp
509 );
510 }
511
512 return $watchedItems;
513 }
514
515 /**
516 * Must be called separately for Subject & Talk namespaces
517 *
518 * @param User $user
519 * @param LinkTarget $target
520 *
521 * @return bool
522 */
523 public function isWatched( User $user, LinkTarget $target ) {
524 return (bool)$this->getWatchedItem( $user, $target );
525 }
526
527 /**
528 * @param User $user
529 * @param LinkTarget[] $targets
530 *
531 * @return array multi-dimensional like $return[$namespaceId][$titleString] = $timestamp,
532 * where $timestamp is:
533 * - string|null value of wl_notificationtimestamp,
534 * - false if $target is not watched by $user.
535 */
536 public function getNotificationTimestampsBatch( User $user, array $targets ) {
537 $timestamps = [];
538 foreach ( $targets as $target ) {
539 $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
540 }
541
542 if ( $user->isAnon() ) {
543 return $timestamps;
544 }
545
546 $targetsToLoad = [];
547 foreach ( $targets as $target ) {
548 $cachedItem = $this->getCached( $user, $target );
549 if ( $cachedItem ) {
550 $timestamps[$target->getNamespace()][$target->getDBkey()] =
551 $cachedItem->getNotificationTimestamp();
552 } else {
553 $targetsToLoad[] = $target;
554 }
555 }
556
557 if ( !$targetsToLoad ) {
558 return $timestamps;
559 }
560
561 $dbr = $this->getConnectionRef( DB_REPLICA );
562
563 $lb = new LinkBatch( $targetsToLoad );
564 $res = $dbr->select(
565 'watchlist',
566 [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
567 [
568 $lb->constructSet( 'wl', $dbr ),
569 'wl_user' => $user->getId(),
570 ],
571 __METHOD__
572 );
573
574 foreach ( $res as $row ) {
575 $timestamps[$row->wl_namespace][$row->wl_title] = $row->wl_notificationtimestamp;
576 }
577
578 return $timestamps;
579 }
580
581 /**
582 * Must be called separately for Subject & Talk namespaces
583 *
584 * @param User $user
585 * @param LinkTarget $target
586 */
587 public function addWatch( User $user, LinkTarget $target ) {
588 $this->addWatchBatchForUser( $user, [ $target ] );
589 }
590
591 /**
592 * @param User $user
593 * @param LinkTarget[] $targets
594 *
595 * @return bool success
596 */
597 public function addWatchBatchForUser( User $user, array $targets ) {
598 if ( $this->loadBalancer->getReadOnlyReason() !== false ) {
599 return false;
600 }
601 // Only loggedin user can have a watchlist
602 if ( $user->isAnon() ) {
603 return false;
604 }
605
606 if ( !$targets ) {
607 return true;
608 }
609
610 $rows = [];
611 $items = [];
612 foreach ( $targets as $target ) {
613 $rows[] = [
614 'wl_user' => $user->getId(),
615 'wl_namespace' => $target->getNamespace(),
616 'wl_title' => $target->getDBkey(),
617 'wl_notificationtimestamp' => null,
618 ];
619 $items[] = new WatchedItem(
620 $user,
621 $target,
622 null
623 );
624 $this->uncache( $user, $target );
625 }
626
627 $dbw = $this->getConnectionRef( DB_MASTER );
628 foreach ( array_chunk( $rows, 100 ) as $toInsert ) {
629 // Use INSERT IGNORE to avoid overwriting the notification timestamp
630 // if there's already an entry for this page
631 $dbw->insert( 'watchlist', $toInsert, __METHOD__, 'IGNORE' );
632 }
633 // Update process cache to ensure skin doesn't claim that the current
634 // page is unwatched in the response of action=watch itself (T28292).
635 // This would otherwise be re-queried from a slave by isWatched().
636 foreach ( $items as $item ) {
637 $this->cache( $item );
638 }
639
640 return true;
641 }
642
643 /**
644 * Removes the an entry for the User watching the LinkTarget
645 * Must be called separately for Subject & Talk namespaces
646 *
647 * @param User $user
648 * @param LinkTarget $target
649 *
650 * @return bool success
651 * @throws DBUnexpectedError
652 * @throws MWException
653 */
654 public function removeWatch( User $user, LinkTarget $target ) {
655 // Only logged in user can have a watchlist
656 if ( $this->loadBalancer->getReadOnlyReason() !== false || $user->isAnon() ) {
657 return false;
658 }
659
660 $this->uncache( $user, $target );
661
662 $dbw = $this->getConnectionRef( DB_MASTER );
663 $dbw->delete( 'watchlist',
664 [
665 'wl_user' => $user->getId(),
666 'wl_namespace' => $target->getNamespace(),
667 'wl_title' => $target->getDBkey(),
668 ], __METHOD__
669 );
670 $success = (bool)$dbw->affectedRows();
671
672 return $success;
673 }
674
675 /**
676 * @param User $user The user to set the timestamp for
677 * @param string|null $timestamp Set the update timestamp to this value
678 * @param LinkTarget[] $targets List of targets to update. Default to all targets
679 *
680 * @return bool success
681 */
682 public function setNotificationTimestampsForUser( User $user, $timestamp, array $targets = [] ) {
683 // Only loggedin user can have a watchlist
684 if ( $user->isAnon() ) {
685 return false;
686 }
687
688 $dbw = $this->getConnectionRef( DB_MASTER );
689
690 $conds = [ 'wl_user' => $user->getId() ];
691 if ( $targets ) {
692 $batch = new LinkBatch( $targets );
693 $conds[] = $batch->constructSet( 'wl', $dbw );
694 }
695
696 if ( $timestamp !== null ) {
697 $timestamp = $dbw->timestamp( $timestamp );
698 }
699
700 $success = $dbw->update(
701 'watchlist',
702 [ 'wl_notificationtimestamp' => $timestamp ],
703 $conds,
704 __METHOD__
705 );
706
707 $this->uncacheUser( $user );
708
709 return $success;
710 }
711
712 /**
713 * @param User $editor The editor that triggered the update. Their notification
714 * timestamp will not be updated(they have already seen it)
715 * @param LinkTarget $target The target to update timestamps for
716 * @param string $timestamp Set the update timestamp to this value
717 *
718 * @return int[] Array of user IDs the timestamp has been updated for
719 */
720 public function updateNotificationTimestamp( User $editor, LinkTarget $target, $timestamp ) {
721 $dbw = $this->getConnectionRef( DB_MASTER );
722 $uids = $dbw->selectFieldValues(
723 'watchlist',
724 'wl_user',
725 [
726 'wl_user != ' . intval( $editor->getId() ),
727 'wl_namespace' => $target->getNamespace(),
728 'wl_title' => $target->getDBkey(),
729 'wl_notificationtimestamp IS NULL',
730 ],
731 __METHOD__
732 );
733
734 $watchers = array_map( 'intval', $uids );
735 if ( $watchers ) {
736 // Update wl_notificationtimestamp for all watching users except the editor
737 $fname = __METHOD__;
738 DeferredUpdates::addCallableUpdate(
739 function () use ( $timestamp, $watchers, $target, $fname ) {
740 global $wgUpdateRowsPerQuery;
741
742 $dbw = $this->getConnectionRef( DB_MASTER );
743 $factory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
744 $ticket = $factory->getEmptyTransactionTicket( __METHOD__ );
745
746 $watchersChunks = array_chunk( $watchers, $wgUpdateRowsPerQuery );
747 foreach ( $watchersChunks as $watchersChunk ) {
748 $dbw->update( 'watchlist',
749 [ /* SET */
750 'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
751 ], [ /* WHERE - TODO Use wl_id T130067 */
752 'wl_user' => $watchersChunk,
753 'wl_namespace' => $target->getNamespace(),
754 'wl_title' => $target->getDBkey(),
755 ], $fname
756 );
757 if ( count( $watchersChunks ) > 1 ) {
758 $factory->commitAndWaitForReplication(
759 __METHOD__, $ticket, [ 'wiki' => $dbw->getWikiID() ]
760 );
761 }
762 }
763 $this->uncacheLinkTarget( $target );
764 },
765 DeferredUpdates::POSTSEND,
766 $dbw
767 );
768 }
769
770 return $watchers;
771 }
772
773 /**
774 * Reset the notification timestamp of this entry
775 *
776 * @param User $user
777 * @param Title $title
778 * @param string $force Whether to force the write query to be executed even if the
779 * page is not watched or the notification timestamp is already NULL.
780 * 'force' in order to force
781 * @param int $oldid The revision id being viewed. If not given or 0, latest revision is assumed.
782 *
783 * @return bool success
784 */
785 public function resetNotificationTimestamp( User $user, Title $title, $force = '', $oldid = 0 ) {
786 // Only loggedin user can have a watchlist
787 if ( $this->loadBalancer->getReadOnlyReason() !== false || $user->isAnon() ) {
788 return false;
789 }
790
791 $item = null;
792 if ( $force != 'force' ) {
793 $item = $this->loadWatchedItem( $user, $title );
794 if ( !$item || $item->getNotificationTimestamp() === null ) {
795 return false;
796 }
797 }
798
799 // If the page is watched by the user (or may be watched), update the timestamp
800 $job = new ActivityUpdateJob(
801 $title,
802 [
803 'type' => 'updateWatchlistNotification',
804 'userid' => $user->getId(),
805 'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
806 'curTime' => time()
807 ]
808 );
809
810 // Try to run this post-send
811 // Calls DeferredUpdates::addCallableUpdate in normal operation
812 call_user_func(
813 $this->deferredUpdatesAddCallableUpdateCallback,
814 function() use ( $job ) {
815 $job->run();
816 }
817 );
818
819 $this->uncache( $user, $title );
820
821 return true;
822 }
823
824 private function getNotificationTimestamp( User $user, Title $title, $item, $force, $oldid ) {
825 if ( !$oldid ) {
826 // No oldid given, assuming latest revision; clear the timestamp.
827 return null;
828 }
829
830 if ( !$title->getNextRevisionID( $oldid ) ) {
831 // Oldid given and is the latest revision for this title; clear the timestamp.
832 return null;
833 }
834
835 if ( $item === null ) {
836 $item = $this->loadWatchedItem( $user, $title );
837 }
838
839 if ( !$item ) {
840 // This can only happen if $force is enabled.
841 return null;
842 }
843
844 // Oldid given and isn't the latest; update the timestamp.
845 // This will result in no further notification emails being sent!
846 // Calls Revision::getTimestampFromId in normal operation
847 $notificationTimestamp = call_user_func(
848 $this->revisionGetTimestampFromIdCallback,
849 $title,
850 $oldid
851 );
852
853 // We need to go one second to the future because of various strict comparisons
854 // throughout the codebase
855 $ts = new MWTimestamp( $notificationTimestamp );
856 $ts->timestamp->add( new DateInterval( 'PT1S' ) );
857 $notificationTimestamp = $ts->getTimestamp( TS_MW );
858
859 if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
860 if ( $force != 'force' ) {
861 return false;
862 } else {
863 // This is a little silly…
864 return $item->getNotificationTimestamp();
865 }
866 }
867
868 return $notificationTimestamp;
869 }
870
871 /**
872 * @param User $user
873 * @param int $unreadLimit
874 *
875 * @return int|bool The number of unread notifications
876 * true if greater than or equal to $unreadLimit
877 */
878 public function countUnreadNotifications( User $user, $unreadLimit = null ) {
879 $queryOptions = [];
880 if ( $unreadLimit !== null ) {
881 $unreadLimit = (int)$unreadLimit;
882 $queryOptions['LIMIT'] = $unreadLimit;
883 }
884
885 $dbr = $this->getConnectionRef( DB_REPLICA );
886 $rowCount = $dbr->selectRowCount(
887 'watchlist',
888 '1',
889 [
890 'wl_user' => $user->getId(),
891 'wl_notificationtimestamp IS NOT NULL',
892 ],
893 __METHOD__,
894 $queryOptions
895 );
896
897 if ( !isset( $unreadLimit ) ) {
898 return $rowCount;
899 }
900
901 if ( $rowCount >= $unreadLimit ) {
902 return true;
903 }
904
905 return $rowCount;
906 }
907
908 /**
909 * Check if the given title already is watched by the user, and if so
910 * add a watch for the new title.
911 *
912 * To be used for page renames and such.
913 *
914 * @param LinkTarget $oldTarget
915 * @param LinkTarget $newTarget
916 */
917 public function duplicateAllAssociatedEntries( LinkTarget $oldTarget, LinkTarget $newTarget ) {
918 $oldTarget = Title::newFromLinkTarget( $oldTarget );
919 $newTarget = Title::newFromLinkTarget( $newTarget );
920
921 $this->duplicateEntry( $oldTarget->getSubjectPage(), $newTarget->getSubjectPage() );
922 $this->duplicateEntry( $oldTarget->getTalkPage(), $newTarget->getTalkPage() );
923 }
924
925 /**
926 * Check if the given title already is watched by the user, and if so
927 * add a watch for the new title.
928 *
929 * To be used for page renames and such.
930 * This must be called separately for Subject and Talk pages
931 *
932 * @param LinkTarget $oldTarget
933 * @param LinkTarget $newTarget
934 */
935 public function duplicateEntry( LinkTarget $oldTarget, LinkTarget $newTarget ) {
936 $dbw = $this->getConnectionRef( DB_MASTER );
937
938 $result = $dbw->select(
939 'watchlist',
940 [ 'wl_user', 'wl_notificationtimestamp' ],
941 [
942 'wl_namespace' => $oldTarget->getNamespace(),
943 'wl_title' => $oldTarget->getDBkey(),
944 ],
945 __METHOD__,
946 [ 'FOR UPDATE' ]
947 );
948
949 $newNamespace = $newTarget->getNamespace();
950 $newDBkey = $newTarget->getDBkey();
951
952 # Construct array to replace into the watchlist
953 $values = [];
954 foreach ( $result as $row ) {
955 $values[] = [
956 'wl_user' => $row->wl_user,
957 'wl_namespace' => $newNamespace,
958 'wl_title' => $newDBkey,
959 'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
960 ];
961 }
962
963 if ( !empty( $values ) ) {
964 # Perform replace
965 # Note that multi-row replace is very efficient for MySQL but may be inefficient for
966 # some other DBMSes, mostly due to poor simulation by us
967 $dbw->replace(
968 'watchlist',
969 [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
970 $values,
971 __METHOD__
972 );
973 }
974 }
975
976 }