Completed
Branch master (7da094)
by
unknown
29:41
created

WatchedItemStore::getConnection()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 1
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
4
use MediaWiki\Linker\LinkTarget;
5
use Wikimedia\Assert\Assert;
6
7
/**
8
 * Storage layer class for WatchedItems.
9
 * Database interaction.
10
 *
11
 * @author Addshore
12
 *
13
 * @since 1.27
14
 */
15
class WatchedItemStore implements StatsdAwareInterface {
16
17
	const SORT_DESC = 'DESC';
18
	const SORT_ASC = 'ASC';
19
20
	/**
21
	 * @var LoadBalancer
22
	 */
23
	private $loadBalancer;
24
25
	/**
26
	 * @var HashBagOStuff
27
	 */
28
	private $cache;
29
30
	/**
31
	 * @var array[] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key'
32
	 * The index is needed so that on mass changes all relevant items can be un-cached.
33
	 * For example: Clearing a users watchlist of all items or updating notification timestamps
34
	 *              for all users watching a single target.
35
	 */
36
	private $cacheIndex = [];
37
38
	/**
39
	 * @var callable|null
40
	 */
41
	private $deferredUpdatesAddCallableUpdateCallback;
42
43
	/**
44
	 * @var callable|null
45
	 */
46
	private $revisionGetTimestampFromIdCallback;
47
48
	/**
49
	 * @var StatsdDataFactoryInterface
50
	 */
51
	private $stats;
52
53
	/**
54
	 * @param LoadBalancer $loadBalancer
55
	 * @param HashBagOStuff $cache
56
	 */
57
	public function __construct(
58
		LoadBalancer $loadBalancer,
59
		HashBagOStuff $cache
60
	) {
61
		$this->loadBalancer = $loadBalancer;
62
		$this->cache = $cache;
63
		$this->stats = new NullStatsdDataFactory();
64
		$this->deferredUpdatesAddCallableUpdateCallback = [ 'DeferredUpdates', 'addCallableUpdate' ];
65
		$this->revisionGetTimestampFromIdCallback = [ 'Revision', 'getTimestampFromId' ];
66
	}
67
68
	public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) {
69
		$this->stats = $stats;
70
	}
71
72
	/**
73
	 * Overrides the DeferredUpdates::addCallableUpdate callback
74
	 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
75
	 *
76
	 * @param callable $callback
77
	 *
78
	 * @see DeferredUpdates::addCallableUpdate for callback signiture
79
	 *
80
	 * @return ScopedCallback to reset the overridden value
81
	 * @throws MWException
82
	 */
83 View Code Duplication
	public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) {
84
		if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
85
			throw new MWException(
86
				'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
87
			);
88
		}
89
		$previousValue = $this->deferredUpdatesAddCallableUpdateCallback;
90
		$this->deferredUpdatesAddCallableUpdateCallback = $callback;
91
		return new ScopedCallback( function() use ( $previousValue ) {
92
			$this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
93
		} );
94
	}
95
96
	/**
97
	 * Overrides the Revision::getTimestampFromId callback
98
	 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
99
	 *
100
	 * @param callable $callback
101
	 * @see Revision::getTimestampFromId for callback signiture
102
	 *
103
	 * @return ScopedCallback to reset the overridden value
104
	 * @throws MWException
105
	 */
106 View Code Duplication
	public function overrideRevisionGetTimestampFromIdCallback( callable $callback ) {
107
		if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
108
			throw new MWException(
109
				'Cannot override Revision::getTimestampFromId callback in operation.'
110
			);
111
		}
112
		$previousValue = $this->revisionGetTimestampFromIdCallback;
113
		$this->revisionGetTimestampFromIdCallback = $callback;
114
		return new ScopedCallback( function() use ( $previousValue ) {
115
			$this->revisionGetTimestampFromIdCallback = $previousValue;
116
		} );
117
	}
118
119
	private function getCacheKey( User $user, LinkTarget $target ) {
120
		return $this->cache->makeKey(
121
			(string)$target->getNamespace(),
122
			$target->getDBkey(),
123
			(string)$user->getId()
124
		);
125
	}
126
127
	private function cache( WatchedItem $item ) {
128
		$user = $item->getUser();
129
		$target = $item->getLinkTarget();
130
		$key = $this->getCacheKey( $user, $target );
131
		$this->cache->set( $key, $item );
132
		$this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
133
		$this->stats->increment( 'WatchedItemStore.cache' );
134
	}
135
136
	private function uncache( User $user, LinkTarget $target ) {
137
		$this->cache->delete( $this->getCacheKey( $user, $target ) );
138
		unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
139
		$this->stats->increment( 'WatchedItemStore.uncache' );
140
	}
141
142
	private function uncacheLinkTarget( LinkTarget $target ) {
143
		$this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' );
144
		if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
145
			return;
146
		}
147
		foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
148
			$this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
149
			$this->cache->delete( $key );
150
		}
151
	}
152
153
	private function uncacheUser( User $user ) {
154
		$this->stats->increment( 'WatchedItemStore.uncacheUser' );
155
		foreach ( $this->cacheIndex as $ns => $dbKeyArray ) {
156
			foreach ( $dbKeyArray as $dbKey => $userArray ) {
157
				if ( isset( $userArray[$user->getId()] ) ) {
158
					$this->stats->increment( 'WatchedItemStore.uncacheUser.items' );
159
					$this->cache->delete( $userArray[$user->getId()] );
160
				}
161
			}
162
		}
163
	}
164
165
	/**
166
	 * @param User $user
167
	 * @param LinkTarget $target
168
	 *
169
	 * @return WatchedItem|null
170
	 */
171
	private function getCached( User $user, LinkTarget $target ) {
172
		return $this->cache->get( $this->getCacheKey( $user, $target ) );
173
	}
174
175
	/**
176
	 * Return an array of conditions to select or update the appropriate database
177
	 * row.
178
	 *
179
	 * @param User $user
180
	 * @param LinkTarget $target
181
	 *
182
	 * @return array
183
	 */
184
	private function dbCond( User $user, LinkTarget $target ) {
185
		return [
186
			'wl_user' => $user->getId(),
187
			'wl_namespace' => $target->getNamespace(),
188
			'wl_title' => $target->getDBkey(),
189
		];
190
	}
191
192
	/**
193
	 * @param int $dbIndex DB_MASTER or DB_REPLICA
194
	 *
195
	 * @return IDatabase
196
	 * @throws MWException
197
	 */
198
	private function getConnectionRef( $dbIndex ) {
199
		return $this->loadBalancer->getConnectionRef( $dbIndex, [ 'watchlist' ] );
200
	}
201
202
	/**
203
	 * Count the number of individual items that are watched by the user.
204
	 * If a subject and corresponding talk page are watched this will return 2.
205
	 *
206
	 * @param User $user
207
	 *
208
	 * @return int
209
	 */
210
	public function countWatchedItems( User $user ) {
211
		$dbr = $this->getConnectionRef( DB_REPLICA );
212
		$return = (int)$dbr->selectField(
213
			'watchlist',
214
			'COUNT(*)',
215
			[
216
				'wl_user' => $user->getId()
217
			],
218
			__METHOD__
219
		);
220
221
		return $return;
222
	}
223
224
	/**
225
	 * @param LinkTarget $target
226
	 *
227
	 * @return int
228
	 */
229
	public function countWatchers( LinkTarget $target ) {
230
		$dbr = $this->getConnectionRef( DB_REPLICA );
231
		$return = (int)$dbr->selectField(
232
			'watchlist',
233
			'COUNT(*)',
234
			[
235
				'wl_namespace' => $target->getNamespace(),
236
				'wl_title' => $target->getDBkey(),
237
			],
238
			__METHOD__
239
		);
240
241
		return $return;
242
	}
243
244
	/**
245
	 * Number of page watchers who also visited a "recent" edit
246
	 *
247
	 * @param LinkTarget $target
248
	 * @param mixed $threshold timestamp accepted by wfTimestamp
249
	 *
250
	 * @return int
251
	 * @throws DBUnexpectedError
252
	 * @throws MWException
253
	 */
254
	public function countVisitingWatchers( LinkTarget $target, $threshold ) {
255
		$dbr = $this->getConnectionRef( DB_REPLICA );
256
		$visitingWatchers = (int)$dbr->selectField(
257
			'watchlist',
258
			'COUNT(*)',
259
			[
260
				'wl_namespace' => $target->getNamespace(),
261
				'wl_title' => $target->getDBkey(),
262
				'wl_notificationtimestamp >= ' .
263
				$dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
264
				' OR wl_notificationtimestamp IS NULL'
265
			],
266
			__METHOD__
267
		);
268
269
		return $visitingWatchers;
270
	}
271
272
	/**
273
	 * @param LinkTarget[] $targets
274
	 * @param array $options Allowed keys:
275
	 *        'minimumWatchers' => int
276
	 *
277
	 * @return array multi dimensional like $return[$namespaceId][$titleString] = int $watchers
278
	 *         All targets will be present in the result. 0 either means no watchers or the number
279
	 *         of watchers was below the minimumWatchers option if passed.
280
	 */
281
	public function countWatchersMultiple( array $targets, array $options = [] ) {
282
		$dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
283
284
		$dbr = $this->getConnectionRef( DB_REPLICA );
285
286
		if ( array_key_exists( 'minimumWatchers', $options ) ) {
287
			$dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
288
		}
289
290
		$lb = new LinkBatch( $targets );
291
		$res = $dbr->select(
292
			'watchlist',
293
			[ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
294
			[ $lb->constructSet( 'wl', $dbr ) ],
295
			__METHOD__,
296
			$dbOptions
297
		);
298
299
		$watchCounts = [];
300
		foreach ( $targets as $linkTarget ) {
301
			$watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
302
		}
303
304
		foreach ( $res as $row ) {
0 ignored issues
show
Bug introduced by
The expression $res of type object<ResultWrapper>|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
305
			$watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
306
		}
307
308
		return $watchCounts;
309
	}
310
311
	/**
312
	 * Number of watchers of each page who have visited recent edits to that page
313
	 *
314
	 * @param array $targetsWithVisitThresholds array of pairs (LinkTarget $target, mixed $threshold),
315
	 *        $threshold is:
316
	 *        - a timestamp of the recent edit if $target exists (format accepted by wfTimestamp)
317
	 *        - null if $target doesn't exist
318
	 * @param int|null $minimumWatchers
319
	 * @return array multi-dimensional like $return[$namespaceId][$titleString] = $watchers,
320
	 *         where $watchers is an int:
321
	 *         - if the page exists, number of users watching who have visited the page recently
322
	 *         - if the page doesn't exist, number of users that have the page on their watchlist
323
	 *         - 0 means there are no visiting watchers or their number is below the minimumWatchers
324
	 *         option (if passed).
325
	 */
326
	public function countVisitingWatchersMultiple(
327
		array $targetsWithVisitThresholds,
328
		$minimumWatchers = null
329
	) {
330
		$dbr = $this->getConnectionRef( DB_REPLICA );
331
332
		$conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds );
333
334
		$dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
335
		if ( $minimumWatchers !== null ) {
336
			$dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
337
		}
338
		$res = $dbr->select(
339
			'watchlist',
340
			[ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
341
			$conds,
342
			__METHOD__,
343
			$dbOptions
344
		);
345
346
		$watcherCounts = [];
347
		foreach ( $targetsWithVisitThresholds as list( $target ) ) {
348
			/* @var LinkTarget $target */
349
			$watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
350
		}
351
352
		foreach ( $res as $row ) {
0 ignored issues
show
Bug introduced by
The expression $res of type object<ResultWrapper>|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
353
			$watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
354
		}
355
356
		return $watcherCounts;
357
	}
358
359
	/**
360
	 * Generates condition for the query used in a batch count visiting watchers.
361
	 *
362
	 * @param IDatabase $db
363
	 * @param array $targetsWithVisitThresholds array of pairs (LinkTarget, last visit threshold)
364
	 * @return string
365
	 */
366
	private function getVisitingWatchersCondition(
367
		IDatabase $db,
368
		array $targetsWithVisitThresholds
369
	) {
370
		$missingTargets = [];
371
		$namespaceConds = [];
372
		foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
373
			if ( $threshold === null ) {
374
				$missingTargets[] = $target;
375
				continue;
376
			}
377
			/* @var LinkTarget $target */
378
			$namespaceConds[$target->getNamespace()][] = $db->makeList( [
379
				'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
380
				$db->makeList( [
381
					'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
382
					'wl_notificationtimestamp IS NULL'
383
				], LIST_OR )
384
			], LIST_AND );
385
		}
386
387
		$conds = [];
388
		foreach ( $namespaceConds as $namespace => $pageConds ) {
389
			$conds[] = $db->makeList( [
390
				'wl_namespace = ' . $namespace,
391
				'(' . $db->makeList( $pageConds, LIST_OR ) . ')'
392
			], LIST_AND );
393
		}
394
395
		if ( $missingTargets ) {
396
			$lb = new LinkBatch( $missingTargets );
397
			$conds[] = $lb->constructSet( 'wl', $db );
398
		}
399
400
		return $db->makeList( $conds, LIST_OR );
401
	}
402
403
	/**
404
	 * Get an item (may be cached)
405
	 *
406
	 * @param User $user
407
	 * @param LinkTarget $target
408
	 *
409
	 * @return WatchedItem|false
410
	 */
411
	public function getWatchedItem( User $user, LinkTarget $target ) {
412
		if ( $user->isAnon() ) {
413
			return false;
414
		}
415
416
		$cached = $this->getCached( $user, $target );
417
		if ( $cached ) {
418
			$this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' );
419
			return $cached;
420
		}
421
		$this->stats->increment( 'WatchedItemStore.getWatchedItem.load' );
422
		return $this->loadWatchedItem( $user, $target );
423
	}
424
425
	/**
426
	 * Loads an item from the db
427
	 *
428
	 * @param User $user
429
	 * @param LinkTarget $target
430
	 *
431
	 * @return WatchedItem|false
432
	 */
433
	public function loadWatchedItem( User $user, LinkTarget $target ) {
434
		// Only loggedin user can have a watchlist
435
		if ( $user->isAnon() ) {
436
			return false;
437
		}
438
439
		$dbr = $this->getConnectionRef( DB_REPLICA );
440
		$row = $dbr->selectRow(
441
			'watchlist',
442
			'wl_notificationtimestamp',
443
			$this->dbCond( $user, $target ),
444
			__METHOD__
445
		);
446
447
		if ( !$row ) {
448
			return false;
449
		}
450
451
		$item = new WatchedItem(
452
			$user,
453
			$target,
454
			$row->wl_notificationtimestamp
455
		);
456
		$this->cache( $item );
457
458
		return $item;
459
	}
460
461
	/**
462
	 * @param User $user
463
	 * @param array $options Allowed keys:
464
	 *        'forWrite' => bool defaults to false
465
	 *        'sort' => string optional sorting by namespace ID and title
466
	 *                     one of the self::SORT_* constants
467
	 *
468
	 * @return WatchedItem[]
469
	 */
470
	public function getWatchedItemsForUser( User $user, array $options = [] ) {
471
		$options += [ 'forWrite' => false ];
472
473
		$dbOptions = [];
474
		if ( array_key_exists( 'sort', $options ) ) {
475
			Assert::parameter(
476
				( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
477
				'$options[\'sort\']',
478
				'must be SORT_ASC or SORT_DESC'
479
			);
480
			$dbOptions['ORDER BY'] = [
481
				"wl_namespace {$options['sort']}",
482
				"wl_title {$options['sort']}"
483
			];
484
		}
485
		$db = $this->getConnectionRef( $options['forWrite'] ? DB_MASTER : DB_REPLICA );
486
487
		$res = $db->select(
488
			'watchlist',
489
			[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
490
			[ 'wl_user' => $user->getId() ],
491
			__METHOD__,
492
			$dbOptions
493
		);
494
495
		$watchedItems = [];
496 View Code Duplication
		foreach ( $res as $row ) {
0 ignored issues
show
Bug introduced by
The expression $res of type object<ResultWrapper>|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
497
			// todo these could all be cached at some point?
498
			$watchedItems[] = new WatchedItem(
499
				$user,
500
				new TitleValue( (int)$row->wl_namespace, $row->wl_title ),
501
				$row->wl_notificationtimestamp
502
			);
503
		}
504
505
		return $watchedItems;
506
	}
507
508
	/**
509
	 * Must be called separately for Subject & Talk namespaces
510
	 *
511
	 * @param User $user
512
	 * @param LinkTarget $target
513
	 *
514
	 * @return bool
515
	 */
516
	public function isWatched( User $user, LinkTarget $target ) {
517
		return (bool)$this->getWatchedItem( $user, $target );
518
	}
519
520
	/**
521
	 * @param User $user
522
	 * @param LinkTarget[] $targets
523
	 *
524
	 * @return array multi-dimensional like $return[$namespaceId][$titleString] = $timestamp,
525
	 *         where $timestamp is:
526
	 *         - string|null value of wl_notificationtimestamp,
527
	 *         - false if $target is not watched by $user.
528
	 */
529
	public function getNotificationTimestampsBatch( User $user, array $targets ) {
530
		$timestamps = [];
531
		foreach ( $targets as $target ) {
532
			$timestamps[$target->getNamespace()][$target->getDBkey()] = false;
533
		}
534
535
		if ( $user->isAnon() ) {
536
			return $timestamps;
537
		}
538
539
		$targetsToLoad = [];
540
		foreach ( $targets as $target ) {
541
			$cachedItem = $this->getCached( $user, $target );
542
			if ( $cachedItem ) {
543
				$timestamps[$target->getNamespace()][$target->getDBkey()] =
544
					$cachedItem->getNotificationTimestamp();
545
			} else {
546
				$targetsToLoad[] = $target;
547
			}
548
		}
549
550
		if ( !$targetsToLoad ) {
551
			return $timestamps;
552
		}
553
554
		$dbr = $this->getConnectionRef( DB_REPLICA );
555
556
		$lb = new LinkBatch( $targetsToLoad );
557
		$res = $dbr->select(
558
			'watchlist',
559
			[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
560
			[
561
				$lb->constructSet( 'wl', $dbr ),
562
				'wl_user' => $user->getId(),
563
			],
564
			__METHOD__
565
		);
566
567
		foreach ( $res as $row ) {
0 ignored issues
show
Bug introduced by
The expression $res of type object<ResultWrapper>|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
568
			$timestamps[$row->wl_namespace][$row->wl_title] = $row->wl_notificationtimestamp;
569
		}
570
571
		return $timestamps;
572
	}
573
574
	/**
575
	 * Must be called separately for Subject & Talk namespaces
576
	 *
577
	 * @param User $user
578
	 * @param LinkTarget $target
579
	 */
580
	public function addWatch( User $user, LinkTarget $target ) {
581
		$this->addWatchBatchForUser( $user, [ $target ] );
582
	}
583
584
	/**
585
	 * @param User $user
586
	 * @param LinkTarget[] $targets
587
	 *
588
	 * @return bool success
589
	 */
590
	public function addWatchBatchForUser( User $user, array $targets ) {
591
		if ( $this->loadBalancer->getReadOnlyReason() !== false ) {
592
			return false;
593
		}
594
		// Only loggedin user can have a watchlist
595
		if ( $user->isAnon() ) {
596
			return false;
597
		}
598
599
		if ( !$targets ) {
600
			return true;
601
		}
602
603
		$rows = [];
604
		foreach ( $targets as $target ) {
605
			$rows[] = [
606
				'wl_user' => $user->getId(),
607
				'wl_namespace' => $target->getNamespace(),
608
				'wl_title' => $target->getDBkey(),
609
				'wl_notificationtimestamp' => null,
610
			];
611
			$this->uncache( $user, $target );
612
		}
613
614
		$dbw = $this->getConnectionRef( DB_MASTER );
615
		foreach ( array_chunk( $rows, 100 ) as $toInsert ) {
616
			// Use INSERT IGNORE to avoid overwriting the notification timestamp
617
			// if there's already an entry for this page
618
			$dbw->insert( 'watchlist', $toInsert, __METHOD__, 'IGNORE' );
619
		}
620
621
		return true;
622
	}
623
624
	/**
625
	 * Removes the an entry for the User watching the LinkTarget
626
	 * Must be called separately for Subject & Talk namespaces
627
	 *
628
	 * @param User $user
629
	 * @param LinkTarget $target
630
	 *
631
	 * @return bool success
632
	 * @throws DBUnexpectedError
633
	 * @throws MWException
634
	 */
635
	public function removeWatch( User $user, LinkTarget $target ) {
636
		// Only logged in user can have a watchlist
637
		if ( $this->loadBalancer->getReadOnlyReason() !== false || $user->isAnon() ) {
638
			return false;
639
		}
640
641
		$this->uncache( $user, $target );
642
643
		$dbw = $this->getConnectionRef( DB_MASTER );
644
		$dbw->delete( 'watchlist',
645
			[
646
				'wl_user' => $user->getId(),
647
				'wl_namespace' => $target->getNamespace(),
648
				'wl_title' => $target->getDBkey(),
649
			], __METHOD__
650
		);
651
		$success = (bool)$dbw->affectedRows();
652
653
		return $success;
654
	}
655
656
	/**
657
	 * @param User $user The user to set the timestamp for
658
	 * @param string $timestamp Set the update timestamp to this value
659
	 * @param LinkTarget[] $targets List of targets to update. Default to all targets
660
	 *
661
	 * @return bool success
662
	 */
663
	public function setNotificationTimestampsForUser( User $user, $timestamp, array $targets = [] ) {
664
		// Only loggedin user can have a watchlist
665
		if ( $user->isAnon() ) {
666
			return false;
667
		}
668
669
		$dbw = $this->getConnectionRef( DB_MASTER );
670
671
		$conds = [ 'wl_user' => $user->getId() ];
672
		if ( $targets ) {
673
			$batch = new LinkBatch( $targets );
674
			$conds[] = $batch->constructSet( 'wl', $dbw );
675
		}
676
677
		$success = $dbw->update(
678
			'watchlist',
679
			[ 'wl_notificationtimestamp' => $dbw->timestamp( $timestamp ) ],
680
			$conds,
681
			__METHOD__
682
		);
683
684
		$this->uncacheUser( $user );
685
686
		return $success;
687
	}
688
689
	/**
690
	 * @param User $editor The editor that triggered the update. Their notification
691
	 *  timestamp will not be updated(they have already seen it)
692
	 * @param LinkTarget $target The target to update timestamps for
693
	 * @param string $timestamp Set the update timestamp to this value
694
	 *
695
	 * @return int[] Array of user IDs the timestamp has been updated for
696
	 */
697
	public function updateNotificationTimestamp( User $editor, LinkTarget $target, $timestamp ) {
698
		$dbw = $this->getConnectionRef( DB_MASTER );
699
		$uids = $dbw->selectFieldValues(
700
			'watchlist',
701
			'wl_user',
702
			[
703
				'wl_user != ' . intval( $editor->getId() ),
704
				'wl_namespace' => $target->getNamespace(),
705
				'wl_title' => $target->getDBkey(),
706
				'wl_notificationtimestamp IS NULL',
707
			],
708
			__METHOD__
709
		);
710
711
		$watchers = array_map( 'intval', $uids );
712
		if ( $watchers ) {
713
			// Update wl_notificationtimestamp for all watching users except the editor
714
			$fname = __METHOD__;
715
			DeferredUpdates::addCallableUpdate(
716
				function () use ( $timestamp, $watchers, $target, $fname ) {
717
					global $wgUpdateRowsPerQuery;
718
719
					$dbw = $this->getConnectionRef( DB_MASTER );
720
					$factory = wfGetLBFactory();
0 ignored issues
show
Deprecated Code introduced by
The function wfGetLBFactory() has been deprecated with message: since 1.27, use MediaWikiServices::getDBLoadBalancerFactory() instead.

This function has been deprecated. The supplier of the file has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed from the class and what other function to use instead.

Loading history...
721
					$ticket = $factory->getEmptyTransactionTicket( __METHOD__ );
722
723
					$watchersChunks = array_chunk( $watchers, $wgUpdateRowsPerQuery );
724
					foreach ( $watchersChunks as $watchersChunk ) {
725
						$dbw->update( 'watchlist',
726
							[ /* SET */
727
								'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
728
							], [ /* WHERE - TODO Use wl_id T130067 */
729
								'wl_user' => $watchersChunk,
730
								'wl_namespace' => $target->getNamespace(),
731
								'wl_title' => $target->getDBkey(),
732
							], $fname
733
						);
734
						if ( count( $watchersChunks ) > 1 ) {
735
							$factory->commitAndWaitForReplication(
736
								__METHOD__, $ticket, [ 'wiki' => $dbw->getWikiID() ]
737
							);
738
						}
739
					}
740
					$this->uncacheLinkTarget( $target );
741
				},
742
				DeferredUpdates::POSTSEND,
743
				$dbw
744
			);
745
		}
746
747
		return $watchers;
748
	}
749
750
	/**
751
	 * Reset the notification timestamp of this entry
752
	 *
753
	 * @param User $user
754
	 * @param Title $title
755
	 * @param string $force Whether to force the write query to be executed even if the
756
	 *    page is not watched or the notification timestamp is already NULL.
757
	 *    'force' in order to force
758
	 * @param int $oldid The revision id being viewed. If not given or 0, latest revision is assumed.
759
	 *
760
	 * @return bool success
761
	 */
762
	public function resetNotificationTimestamp( User $user, Title $title, $force = '', $oldid = 0 ) {
763
		// Only loggedin user can have a watchlist
764
		if ( $this->loadBalancer->getReadOnlyReason() !== false || $user->isAnon() ) {
765
			return false;
766
		}
767
768
		$item = null;
769
		if ( $force != 'force' ) {
770
			$item = $this->loadWatchedItem( $user, $title );
771
			if ( !$item || $item->getNotificationTimestamp() === null ) {
772
				return false;
773
			}
774
		}
775
776
		// If the page is watched by the user (or may be watched), update the timestamp
777
		$job = new ActivityUpdateJob(
778
			$title,
779
			[
780
				'type'      => 'updateWatchlistNotification',
781
				'userid'    => $user->getId(),
782
				'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
783
				'curTime'   => time()
784
			]
785
		);
786
787
		// Try to run this post-send
788
		// Calls DeferredUpdates::addCallableUpdate in normal operation
789
		call_user_func(
790
			$this->deferredUpdatesAddCallableUpdateCallback,
791
			function() use ( $job ) {
792
				$job->run();
793
			}
794
		);
795
796
		$this->uncache( $user, $title );
797
798
		return true;
799
	}
800
801
	private function getNotificationTimestamp( User $user, Title $title, $item, $force, $oldid ) {
802
		if ( !$oldid ) {
803
			// No oldid given, assuming latest revision; clear the timestamp.
804
			return null;
805
		}
806
807
		if ( !$title->getNextRevisionID( $oldid ) ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $title->getNextRevisionID($oldid) of type false|integer is loosely compared to false; this is ambiguous if the integer can be zero. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
808
			// Oldid given and is the latest revision for this title; clear the timestamp.
809
			return null;
810
		}
811
812
		if ( $item === null ) {
813
			$item = $this->loadWatchedItem( $user, $title );
814
		}
815
816
		if ( !$item ) {
817
			// This can only happen if $force is enabled.
818
			return null;
819
		}
820
821
		// Oldid given and isn't the latest; update the timestamp.
822
		// This will result in no further notification emails being sent!
823
		// Calls Revision::getTimestampFromId in normal operation
824
		$notificationTimestamp = call_user_func(
825
			$this->revisionGetTimestampFromIdCallback,
826
			$title,
827
			$oldid
828
		);
829
830
		// We need to go one second to the future because of various strict comparisons
831
		// throughout the codebase
832
		$ts = new MWTimestamp( $notificationTimestamp );
833
		$ts->timestamp->add( new DateInterval( 'PT1S' ) );
834
		$notificationTimestamp = $ts->getTimestamp( TS_MW );
835
836
		if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
837
			if ( $force != 'force' ) {
838
				return false;
839
			} else {
840
				// This is a little silly…
841
				return $item->getNotificationTimestamp();
842
			}
843
		}
844
845
		return $notificationTimestamp;
846
	}
847
848
	/**
849
	 * @param User $user
850
	 * @param int $unreadLimit
851
	 *
852
	 * @return int|bool The number of unread notifications
853
	 *                  true if greater than or equal to $unreadLimit
854
	 */
855
	public function countUnreadNotifications( User $user, $unreadLimit = null ) {
856
		$queryOptions = [];
857
		if ( $unreadLimit !== null ) {
858
			$unreadLimit = (int)$unreadLimit;
859
			$queryOptions['LIMIT'] = $unreadLimit;
860
		}
861
862
		$dbr = $this->getConnectionRef( DB_REPLICA );
863
		$rowCount = $dbr->selectRowCount(
864
			'watchlist',
865
			'1',
866
			[
867
				'wl_user' => $user->getId(),
868
				'wl_notificationtimestamp IS NOT NULL',
869
			],
870
			__METHOD__,
871
			$queryOptions
872
		);
873
874
		if ( !isset( $unreadLimit ) ) {
875
			return $rowCount;
876
		}
877
878
		if ( $rowCount >= $unreadLimit ) {
879
			return true;
880
		}
881
882
		return $rowCount;
883
	}
884
885
	/**
886
	 * Check if the given title already is watched by the user, and if so
887
	 * add a watch for the new title.
888
	 *
889
	 * To be used for page renames and such.
890
	 *
891
	 * @param LinkTarget $oldTarget
892
	 * @param LinkTarget $newTarget
893
	 */
894
	public function duplicateAllAssociatedEntries( LinkTarget $oldTarget, LinkTarget $newTarget ) {
895
		$oldTarget = Title::newFromLinkTarget( $oldTarget );
896
		$newTarget = Title::newFromLinkTarget( $newTarget );
897
898
		$this->duplicateEntry( $oldTarget->getSubjectPage(), $newTarget->getSubjectPage() );
899
		$this->duplicateEntry( $oldTarget->getTalkPage(), $newTarget->getTalkPage() );
900
	}
901
902
	/**
903
	 * Check if the given title already is watched by the user, and if so
904
	 * add a watch for the new title.
905
	 *
906
	 * To be used for page renames and such.
907
	 * This must be called separately for Subject and Talk pages
908
	 *
909
	 * @param LinkTarget $oldTarget
910
	 * @param LinkTarget $newTarget
911
	 */
912
	public function duplicateEntry( LinkTarget $oldTarget, LinkTarget $newTarget ) {
913
		$dbw = $this->getConnectionRef( DB_MASTER );
914
915
		$result = $dbw->select(
916
			'watchlist',
917
			[ 'wl_user', 'wl_notificationtimestamp' ],
918
			[
919
				'wl_namespace' => $oldTarget->getNamespace(),
920
				'wl_title' => $oldTarget->getDBkey(),
921
			],
922
			__METHOD__,
923
			[ 'FOR UPDATE' ]
924
		);
925
926
		$newNamespace = $newTarget->getNamespace();
927
		$newDBkey = $newTarget->getDBkey();
928
929
		# Construct array to replace into the watchlist
930
		$values = [];
931
		foreach ( $result as $row ) {
0 ignored issues
show
Bug introduced by
The expression $result of type object<ResultWrapper>|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
932
			$values[] = [
933
				'wl_user' => $row->wl_user,
934
				'wl_namespace' => $newNamespace,
935
				'wl_title' => $newDBkey,
936
				'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
937
			];
938
		}
939
940
		if ( !empty( $values ) ) {
941
			# Perform replace
942
			# Note that multi-row replace is very efficient for MySQL but may be inefficient for
943
			# some other DBMSes, mostly due to poor simulation by us
944
			$dbw->replace(
945
				'watchlist',
946
				[ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
947
				$values,
948
				__METHOD__
949
			);
950
		}
951
	}
952
953
}
954