Completed
Branch master (54277f)
by
unknown
24:54
created

WatchedItemStore::getWatchedItemsForUser()   B

Complexity

Conditions 4
Paths 4

Size

Total Lines 38
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 38
rs 8.5806
cc 4
eloc 26
nc 4
nop 2
1
<?php
2
3
use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
4
use Wikimedia\Assert\Assert;
5
6
/**
7
 * Storage layer class for WatchedItems.
8
 * Database interaction.
9
 *
10
 * @author Addshore
11
 *
12
 * @since 1.27
13
 */
14
class WatchedItemStore implements StatsdAwareInterface {
15
16
	const SORT_DESC = 'DESC';
17
	const SORT_ASC = 'ASC';
18
19
	/**
20
	 * @var LoadBalancer
21
	 */
22
	private $loadBalancer;
23
24
	/**
25
	 * @var HashBagOStuff
26
	 */
27
	private $cache;
28
29
	/**
30
	 * @var array[] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key'
31
	 * The index is needed so that on mass changes all relevant items can be un-cached.
32
	 * For example: Clearing a users watchlist of all items or updating notification timestamps
33
	 *              for all users watching a single target.
34
	 */
35
	private $cacheIndex = [];
36
37
	/**
38
	 * @var callable|null
39
	 */
40
	private $deferredUpdatesAddCallableUpdateCallback;
41
42
	/**
43
	 * @var callable|null
44
	 */
45
	private $revisionGetTimestampFromIdCallback;
46
47
	/**
48
	 * @var StatsdDataFactoryInterface
49
	 */
50
	private $stats;
51
52
	/**
53
	 * @var self|null
54
	 */
55
	private static $instance;
56
57
	/**
58
	 * @param LoadBalancer $loadBalancer
59
	 * @param HashBagOStuff $cache
60
	 */
61
	public function __construct(
62
		LoadBalancer $loadBalancer,
63
		HashBagOStuff $cache
64
	) {
65
		$this->loadBalancer = $loadBalancer;
66
		$this->cache = $cache;
67
		$this->stats = new NullStatsdDataFactory();
68
		$this->deferredUpdatesAddCallableUpdateCallback = [ 'DeferredUpdates', 'addCallableUpdate' ];
69
		$this->revisionGetTimestampFromIdCallback = [ 'Revision', 'getTimestampFromId' ];
70
	}
71
72
	public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) {
73
		$this->stats = $stats;
74
	}
75
76
	/**
77
	 * Overrides the DeferredUpdates::addCallableUpdate callback
78
	 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
79
	 *
80
	 * @param callable $callback
81
	 *
82
	 * @see DeferredUpdates::addCallableUpdate for callback signiture
83
	 *
84
	 * @return ScopedCallback to reset the overridden value
85
	 * @throws MWException
86
	 */
87 View Code Duplication
	public function overrideDeferredUpdatesAddCallableUpdateCallback( $callback ) {
88
		if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
89
			throw new MWException(
90
				'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
91
			);
92
		}
93
		Assert::parameterType( 'callable', $callback, '$callback' );
94
95
		$previousValue = $this->deferredUpdatesAddCallableUpdateCallback;
96
		$this->deferredUpdatesAddCallableUpdateCallback = $callback;
97
		return new ScopedCallback( function() use ( $previousValue ) {
98
			$this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
99
		} );
100
	}
101
102
	/**
103
	 * Overrides the Revision::getTimestampFromId callback
104
	 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
105
	 *
106
	 * @param callable $callback
107
	 * @see Revision::getTimestampFromId for callback signiture
108
	 *
109
	 * @return ScopedCallback to reset the overridden value
110
	 * @throws MWException
111
	 */
112 View Code Duplication
	public function overrideRevisionGetTimestampFromIdCallback( $callback ) {
113
		if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
114
			throw new MWException(
115
				'Cannot override Revision::getTimestampFromId callback in operation.'
116
			);
117
		}
118
		Assert::parameterType( 'callable', $callback, '$callback' );
119
120
		$previousValue = $this->revisionGetTimestampFromIdCallback;
121
		$this->revisionGetTimestampFromIdCallback = $callback;
122
		return new ScopedCallback( function() use ( $previousValue ) {
123
			$this->revisionGetTimestampFromIdCallback = $previousValue;
124
		} );
125
	}
126
127
	/**
128
	 * Overrides the default instance of this class
129
	 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
130
	 *
131
	 * If this method is used it MUST also be called with null after a test to ensure a new
132
	 * default instance is created next time getDefaultInstance is called.
133
	 *
134
	 * @param WatchedItemStore|null $store
135
	 *
136
	 * @return ScopedCallback to reset the overridden value
137
	 * @throws MWException
138
	 */
139
	public static function overrideDefaultInstance( WatchedItemStore $store = null ) {
140
		if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
141
			throw new MWException(
142
				'Cannot override ' . __CLASS__ . 'default instance in operation.'
143
			);
144
		}
145
146
		$previousValue = self::$instance;
147
		self::$instance = $store;
148
		return new ScopedCallback( function() use ( $previousValue ) {
149
			self::$instance = $previousValue;
150
		} );
151
	}
152
153
	/**
154
	 * @return self
155
	 */
156
	public static function getDefaultInstance() {
157
		if ( !self::$instance ) {
158
			self::$instance = new self(
159
				wfGetLB(),
160
				new HashBagOStuff( [ 'maxKeys' => 100 ] )
161
			);
162
			self::$instance->setStatsdDataFactory( RequestContext::getMain()->getStats() );
163
		}
164
		return self::$instance;
165
	}
166
167
	private function getCacheKey( User $user, LinkTarget $target ) {
168
		return $this->cache->makeKey(
169
			(string)$target->getNamespace(),
170
			$target->getDBkey(),
171
			(string)$user->getId()
172
		);
173
	}
174
175
	private function cache( WatchedItem $item ) {
176
		$user = $item->getUser();
177
		$target = $item->getLinkTarget();
178
		$key = $this->getCacheKey( $user, $target );
179
		$this->cache->set( $key, $item );
180
		$this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
181
		$this->stats->increment( 'WatchedItemStore.cache' );
182
	}
183
184
	private function uncache( User $user, LinkTarget $target ) {
185
		$this->cache->delete( $this->getCacheKey( $user, $target ) );
186
		unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
187
		$this->stats->increment( 'WatchedItemStore.uncache' );
188
	}
189
190
	private function uncacheLinkTarget( LinkTarget $target ) {
191
		if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
192
			return;
193
		}
194
		$this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' );
195
		foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
196
			$this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
197
			$this->cache->delete( $key );
198
		}
199
	}
200
201
	/**
202
	 * @param User $user
203
	 * @param LinkTarget $target
204
	 *
205
	 * @return WatchedItem|null
206
	 */
207
	private function getCached( User $user, LinkTarget $target ) {
208
		return $this->cache->get( $this->getCacheKey( $user, $target ) );
209
	}
210
211
	/**
212
	 * Return an array of conditions to select or update the appropriate database
213
	 * row.
214
	 *
215
	 * @param User $user
216
	 * @param LinkTarget $target
217
	 *
218
	 * @return array
219
	 */
220
	private function dbCond( User $user, LinkTarget $target ) {
221
		return [
222
			'wl_user' => $user->getId(),
223
			'wl_namespace' => $target->getNamespace(),
224
			'wl_title' => $target->getDBkey(),
225
		];
226
	}
227
228
	/**
229
	 * @param int $slaveOrMaster DB_MASTER or DB_SLAVE
230
	 *
231
	 * @return DatabaseBase
232
	 * @throws MWException
233
	 */
234
	private function getConnection( $slaveOrMaster ) {
235
		return $this->loadBalancer->getConnection( $slaveOrMaster, [ 'watchlist' ] );
236
	}
237
238
	/**
239
	 * @param DatabaseBase $connection
240
	 *
241
	 * @throws MWException
242
	 */
243
	private function reuseConnection( $connection ) {
244
		$this->loadBalancer->reuseConnection( $connection );
245
	}
246
247
	/**
248
	 * Count the number of individual items that are watched by the user.
249
	 * If a subject and corresponding talk page are watched this will return 2.
250
	 *
251
	 * @param User $user
252
	 *
253
	 * @return int
254
	 */
255
	public function countWatchedItems( User $user ) {
256
		$dbr = $this->getConnection( DB_SLAVE );
257
		$return = (int)$dbr->selectField(
258
			'watchlist',
259
			'COUNT(*)',
260
			[
261
				'wl_user' => $user->getId()
262
			],
263
			__METHOD__
264
		);
265
		$this->reuseConnection( $dbr );
266
267
		return $return;
268
	}
269
270
	/**
271
	 * @param LinkTarget $target
272
	 *
273
	 * @return int
274
	 */
275
	public function countWatchers( LinkTarget $target ) {
276
		$dbr = $this->getConnection( DB_SLAVE );
277
		$return = (int)$dbr->selectField(
278
			'watchlist',
279
			'COUNT(*)',
280
			[
281
				'wl_namespace' => $target->getNamespace(),
282
				'wl_title' => $target->getDBkey(),
283
			],
284
			__METHOD__
285
		);
286
		$this->reuseConnection( $dbr );
287
288
		return $return;
289
	}
290
291
	/**
292
	 * Number of page watchers who also visited a "recent" edit
293
	 *
294
	 * @param LinkTarget $target
295
	 * @param mixed $threshold timestamp accepted by wfTimestamp
296
	 *
297
	 * @return int
298
	 * @throws DBUnexpectedError
299
	 * @throws MWException
300
	 */
301
	public function countVisitingWatchers( LinkTarget $target, $threshold ) {
302
		$dbr = $this->getConnection( DB_SLAVE );
303
		$visitingWatchers = (int)$dbr->selectField(
304
			'watchlist',
305
			'COUNT(*)',
306
			[
307
				'wl_namespace' => $target->getNamespace(),
308
				'wl_title' => $target->getDBkey(),
309
				'wl_notificationtimestamp >= ' .
310
				$dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
0 ignored issues
show
Security Bug introduced by
It seems like $dbr->timestamp($threshold) targeting DatabaseBase::timestamp() can also be of type false; however, DatabaseBase::addQuotes() does only seem to accept string|object<Blob>, did you maybe forget to handle an error condition?
Loading history...
311
				' OR wl_notificationtimestamp IS NULL'
312
			],
313
			__METHOD__
314
		);
315
		$this->reuseConnection( $dbr );
316
317
		return $visitingWatchers;
318
	}
319
320
	/**
321
	 * @param LinkTarget[] $targets
322
	 * @param array $options Allowed keys:
323
	 *        'minimumWatchers' => int
324
	 *
325
	 * @return array multi dimensional like $return[$namespaceId][$titleString] = int $watchers
326
	 *         All targets will be present in the result. 0 either means no watchers or the number
327
	 *         of watchers was below the minimumWatchers option if passed.
328
	 */
329
	public function countWatchersMultiple( array $targets, array $options = [] ) {
330
		$dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
331
332
		$dbr = $this->getConnection( DB_SLAVE );
333
334
		if ( array_key_exists( 'minimumWatchers', $options ) ) {
335
			$dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
336
		}
337
338
		$lb = new LinkBatch( $targets );
339
		$res = $dbr->select(
340
			'watchlist',
341
			[ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
342
			[ $lb->constructSet( 'wl', $dbr ) ],
343
			__METHOD__,
344
			$dbOptions
345
		);
346
347
		$this->reuseConnection( $dbr );
348
349
		$watchCounts = [];
350
		foreach ( $targets as $linkTarget ) {
351
			$watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
352
		}
353
354
		foreach ( $res as $row ) {
0 ignored issues
show
Bug introduced by
The expression $res of type boolean|object<ResultWrapper> 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...
355
			$watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
356
		}
357
358
		return $watchCounts;
359
	}
360
361
	/**
362
	 * Number of watchers of each page who have visited recent edits to that page
363
	 *
364
	 * @param array $targetsWithVisitThresholds array of pairs (LinkTarget $target, mixed $threshold),
365
	 *        $threshold is:
366
	 *        - a timestamp of the recent edit if $target exists (format accepted by wfTimestamp)
367
	 *        - null if $target doesn't exist
368
	 * @param int|null $minimumWatchers
369
	 * @return array multi-dimensional like $return[$namespaceId][$titleString] = $watchers,
370
	 *         where $watchers is an int:
371
	 *         - if the page exists, number of users watching who have visited the page recently
372
	 *         - if the page doesn't exist, number of users that have the page on their watchlist
373
	 *         - 0 means there are no visiting watchers or their number is below the minimumWatchers
374
	 *         option (if passed).
375
	 */
376
	public function countVisitingWatchersMultiple(
377
		array $targetsWithVisitThresholds,
378
		$minimumWatchers = null
379
	) {
380
		$dbr = $this->getConnection( DB_SLAVE );
381
382
		$conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds );
383
384
		$dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
385
		if ( $minimumWatchers !== null ) {
386
			$dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
387
		}
388
		$res = $dbr->select(
389
			'watchlist',
390
			[ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
391
			$conds,
392
			__METHOD__,
393
			$dbOptions
394
		);
395
396
		$this->reuseConnection( $dbr );
397
398
		$watcherCounts = [];
399
		foreach ( $targetsWithVisitThresholds as list( $target ) ) {
400
			/* @var LinkTarget $target */
401
			$watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
402
		}
403
404
		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...
405
			$watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
406
		}
407
408
		return $watcherCounts;
409
	}
410
411
	/**
412
	 * Generates condition for the query used in a batch count visiting watchers.
413
	 *
414
	 * @param IDatabase $db
415
	 * @param array $targetsWithVisitThresholds array of pairs (LinkTarget, last visit threshold)
416
	 * @return string
417
	 */
418
	private function getVisitingWatchersCondition(
419
		IDatabase $db,
420
		array $targetsWithVisitThresholds
421
	) {
422
		$missingTargets = [];
423
		$namespaceConds = [];
424
		foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
425
			if ( $threshold === null ) {
426
				$missingTargets[] = $target;
427
				continue;
428
			}
429
			/* @var LinkTarget $target */
430
			$namespaceConds[$target->getNamespace()][] = $db->makeList( [
431
				'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
432
				$db->makeList( [
433
					'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
434
					'wl_notificationtimestamp IS NULL'
435
				], LIST_OR )
436
			], LIST_AND );
437
		}
438
439
		$conds = [];
440
		foreach ( $namespaceConds as $namespace => $pageConds ) {
441
			$conds[] = $db->makeList( [
442
				'wl_namespace = ' . $namespace,
443
				'(' . $db->makeList( $pageConds, LIST_OR ) . ')'
444
			], LIST_AND );
445
		}
446
447
		if ( $missingTargets ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $missingTargets of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
448
			$lb = new LinkBatch( $missingTargets );
449
			$conds[] = $lb->constructSet( 'wl', $db );
450
		}
451
452
		return $db->makeList( $conds, LIST_OR );
453
	}
454
455
	/**
456
	 * Get an item (may be cached)
457
	 *
458
	 * @param User $user
459
	 * @param LinkTarget $target
460
	 *
461
	 * @return WatchedItem|false
462
	 */
463
	public function getWatchedItem( User $user, LinkTarget $target ) {
464
		if ( $user->isAnon() ) {
465
			return false;
466
		}
467
468
		$cached = $this->getCached( $user, $target );
469
		if ( $cached ) {
470
			$this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' );
471
			return $cached;
472
		}
473
		$this->stats->increment( 'WatchedItemStore.getWatchedItem.load' );
474
		return $this->loadWatchedItem( $user, $target );
475
	}
476
477
	/**
478
	 * Loads an item from the db
479
	 *
480
	 * @param User $user
481
	 * @param LinkTarget $target
482
	 *
483
	 * @return WatchedItem|false
484
	 */
485
	public function loadWatchedItem( User $user, LinkTarget $target ) {
486
		// Only loggedin user can have a watchlist
487
		if ( $user->isAnon() ) {
488
			return false;
489
		}
490
491
		$dbr = $this->getConnection( DB_SLAVE );
492
		$row = $dbr->selectRow(
493
			'watchlist',
494
			'wl_notificationtimestamp',
495
			$this->dbCond( $user, $target ),
496
			__METHOD__
497
		);
498
		$this->reuseConnection( $dbr );
499
500
		if ( !$row ) {
501
			return false;
502
		}
503
504
		$item = new WatchedItem(
505
			$user,
506
			$target,
507
			$row->wl_notificationtimestamp
508
		);
509
		$this->cache( $item );
510
511
		return $item;
512
	}
513
514
	/**
515
	 * @param User $user
516
	 * @param array $options Allowed keys:
517
	 *        'forWrite' => bool defaults to false
518
	 *        'sort' => string optional sorting by namespace ID and title
519
	 *                     one of the self::SORT_* constants
520
	 *
521
	 * @return WatchedItem[]
522
	 */
523
	public function getWatchedItemsForUser( User $user, array $options = [] ) {
524
		$options += [ 'forWrite' => false ];
525
526
		$dbOptions = [];
527
		if ( array_key_exists( 'sort', $options ) ) {
528
			Assert::parameter(
529
				( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
530
				'$options[\'sort\']',
531
				'must be SORT_ASC or SORT_DESC'
532
			);
533
			$dbOptions['ORDER BY'] = [
534
				"wl_namespace {$options['sort']}",
535
				"wl_title {$options['sort']}"
536
			];
537
		}
538
		$db = $this->getConnection( $options['forWrite'] ? DB_MASTER : DB_SLAVE );
539
540
		$res = $db->select(
541
			'watchlist',
542
			[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
543
			[ 'wl_user' => $user->getId() ],
544
			__METHOD__,
545
			$dbOptions
546
		);
547
		$this->reuseConnection( $db );
548
549
		$watchedItems = [];
550
		foreach ( $res as $row ) {
0 ignored issues
show
Bug introduced by
The expression $res of type boolean|object<ResultWrapper> 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...
551
			// todo these could all be cached at some point?
552
			$watchedItems[] = new WatchedItem(
553
				$user,
554
				new TitleValue( (int)$row->wl_namespace, $row->wl_title ),
555
				$row->wl_notificationtimestamp
556
			);
557
		}
558
559
		return $watchedItems;
560
	}
561
562
	/**
563
	 * Must be called separately for Subject & Talk namespaces
564
	 *
565
	 * @param User $user
566
	 * @param LinkTarget $target
567
	 *
568
	 * @return bool
569
	 */
570
	public function isWatched( User $user, LinkTarget $target ) {
571
		return (bool)$this->getWatchedItem( $user, $target );
572
	}
573
574
	/**
575
	 * @param User $user
576
	 * @param LinkTarget[] $targets
577
	 *
578
	 * @return array multi-dimensional like $return[$namespaceId][$titleString] = $timestamp,
579
	 *         where $timestamp is:
580
	 *         - string|null value of wl_notificationtimestamp,
581
	 *         - false if $target is not watched by $user.
582
	 */
583
	public function getNotificationTimestampsBatch( User $user, array $targets ) {
584
		$timestamps = [];
585
		foreach ( $targets as $target ) {
586
			$timestamps[$target->getNamespace()][$target->getDBkey()] = false;
587
		}
588
589
		if ( $user->isAnon() ) {
590
			return $timestamps;
591
		}
592
593
		$targetsToLoad = [];
594
		foreach ( $targets as $target ) {
595
			$cachedItem = $this->getCached( $user, $target );
596
			if ( $cachedItem ) {
597
				$timestamps[$target->getNamespace()][$target->getDBkey()] =
598
					$cachedItem->getNotificationTimestamp();
599
			} else {
600
				$targetsToLoad[] = $target;
601
			}
602
		}
603
604
		if ( !$targetsToLoad ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $targetsToLoad of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
605
			return $timestamps;
606
		}
607
608
		$dbr = $this->getConnection( DB_SLAVE );
609
610
		$lb = new LinkBatch( $targetsToLoad );
611
		$res = $dbr->select(
612
			'watchlist',
613
			[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
614
			[
615
				$lb->constructSet( 'wl', $dbr ),
616
				'wl_user' => $user->getId(),
617
			],
618
			__METHOD__
619
		);
620
		$this->reuseConnection( $dbr );
621
622
		foreach ( $res as $row ) {
0 ignored issues
show
Bug introduced by
The expression $res of type boolean|object<ResultWrapper> 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...
623
			$timestamps[(int)$row->wl_namespace][$row->wl_title] = $row->wl_notificationtimestamp;
624
		}
625
626
		return $timestamps;
627
	}
628
629
	/**
630
	 * Must be called separately for Subject & Talk namespaces
631
	 *
632
	 * @param User $user
633
	 * @param LinkTarget $target
634
	 */
635
	public function addWatch( User $user, LinkTarget $target ) {
636
		$this->addWatchBatchForUser( $user, [ $target ] );
637
	}
638
639
	/**
640
	 * @param User $user
641
	 * @param LinkTarget[] $targets
642
	 *
643
	 * @return bool success
644
	 */
645
	public function addWatchBatchForUser( User $user, array $targets ) {
646
		if ( $this->loadBalancer->getReadOnlyReason() !== false ) {
647
			return false;
648
		}
649
		// Only loggedin user can have a watchlist
650
		if ( $user->isAnon() ) {
651
			return false;
652
		}
653
654
		if ( !$targets ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $targets of type LinkTarget[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
655
			return true;
656
		}
657
658
		$rows = [];
659
		foreach ( $targets as $target ) {
660
			$rows[] = [
661
				'wl_user' => $user->getId(),
662
				'wl_namespace' => $target->getNamespace(),
663
				'wl_title' => $target->getDBkey(),
664
				'wl_notificationtimestamp' => null,
665
			];
666
			$this->uncache( $user, $target );
667
		}
668
669
		$dbw = $this->getConnection( DB_MASTER );
670
		foreach ( array_chunk( $rows, 100 ) as $toInsert ) {
671
			// Use INSERT IGNORE to avoid overwriting the notification timestamp
672
			// if there's already an entry for this page
673
			$dbw->insert( 'watchlist', $toInsert, __METHOD__, 'IGNORE' );
674
		}
675
		$this->reuseConnection( $dbw );
676
677
		return true;
678
	}
679
680
	/**
681
	 * Removes the an entry for the User watching the LinkTarget
682
	 * Must be called separately for Subject & Talk namespaces
683
	 *
684
	 * @param User $user
685
	 * @param LinkTarget $target
686
	 *
687
	 * @return bool success
688
	 * @throws DBUnexpectedError
689
	 * @throws MWException
690
	 */
691
	public function removeWatch( User $user, LinkTarget $target ) {
692
		// Only logged in user can have a watchlist
693
		if ( $this->loadBalancer->getReadOnlyReason() !== false || $user->isAnon() ) {
694
			return false;
695
		}
696
697
		$this->uncache( $user, $target );
698
699
		$dbw = $this->getConnection( DB_MASTER );
700
		$dbw->delete( 'watchlist',
701
			[
702
				'wl_user' => $user->getId(),
703
				'wl_namespace' => $target->getNamespace(),
704
				'wl_title' => $target->getDBkey(),
705
			], __METHOD__
706
		);
707
		$success = (bool)$dbw->affectedRows();
708
		$this->reuseConnection( $dbw );
709
710
		return $success;
711
	}
712
713
	/**
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
718
	 *
719
	 * @return int[] Array of user IDs the timestamp has been updated for
720
	 */
721
	public function updateNotificationTimestamp( User $editor, LinkTarget $target, $timestamp ) {
722
		$dbw = $this->getConnection( DB_MASTER );
723
		$res = $dbw->select( [ '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
			], __METHOD__
731
		);
732
733
		$watchers = [];
734
		foreach ( $res as $row ) {
0 ignored issues
show
Bug introduced by
The expression $res of type boolean|object<ResultWrapper> 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...
735
			$watchers[] = intval( $row->wl_user );
736
		}
737
738
		if ( $watchers ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $watchers of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
739
			// Update wl_notificationtimestamp for all watching users except the editor
740
			$fname = __METHOD__;
741
			$dbw->onTransactionIdle(
742
				function () use ( $dbw, $timestamp, $watchers, $target, $fname ) {
743
					$dbw->update( 'watchlist',
744
						[ /* SET */
745
							'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
746
						], [ /* WHERE */
747
							'wl_user' => $watchers,
748
							'wl_namespace' => $target->getNamespace(),
749
							'wl_title' => $target->getDBkey(),
750
						], $fname
751
					);
752
					$this->uncacheLinkTarget( $target );
753
				}
754
			);
755
		}
756
757
		$this->reuseConnection( $dbw );
758
759
		return $watchers;
760
	}
761
762
	/**
763
	 * Reset the notification timestamp of this entry
764
	 *
765
	 * @param User $user
766
	 * @param Title $title
767
	 * @param string $force Whether to force the write query to be executed even if the
768
	 *    page is not watched or the notification timestamp is already NULL.
769
	 *    'force' in order to force
770
	 * @param int $oldid The revision id being viewed. If not given or 0, latest revision is assumed.
771
	 *
772
	 * @return bool success
773
	 */
774
	public function resetNotificationTimestamp( User $user, Title $title, $force = '', $oldid = 0 ) {
775
		// Only loggedin user can have a watchlist
776
		if ( $this->loadBalancer->getReadOnlyReason() !== false || $user->isAnon() ) {
777
			return false;
778
		}
779
780
		$item = null;
781
		if ( $force != 'force' ) {
782
			$item = $this->loadWatchedItem( $user, $title );
783
			if ( !$item || $item->getNotificationTimestamp() === null ) {
784
				return false;
785
			}
786
		}
787
788
		// If the page is watched by the user (or may be watched), update the timestamp
789
		$job = new ActivityUpdateJob(
790
			$title,
791
			[
792
				'type'      => 'updateWatchlistNotification',
793
				'userid'    => $user->getId(),
794
				'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
795
				'curTime'   => time()
796
			]
797
		);
798
799
		// Try to run this post-send
800
		// Calls DeferredUpdates::addCallableUpdate in normal operation
801
		call_user_func(
802
			$this->deferredUpdatesAddCallableUpdateCallback,
803
			function() use ( $job ) {
804
				$job->run();
805
			}
806
		);
807
808
		$this->uncache( $user, $title );
809
810
		return true;
811
	}
812
813
	private function getNotificationTimestamp( User $user, Title $title, $item, $force, $oldid ) {
814
		if ( !$oldid ) {
815
			// No oldid given, assuming latest revision; clear the timestamp.
816
			return null;
817
		}
818
819
		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...
820
			// Oldid given and is the latest revision for this title; clear the timestamp.
821
			return null;
822
		}
823
824
		if ( $item === null ) {
825
			$item = $this->loadWatchedItem( $user, $title );
826
		}
827
828
		if ( !$item ) {
829
			// This can only happen if $force is enabled.
830
			return null;
831
		}
832
833
		// Oldid given and isn't the latest; update the timestamp.
834
		// This will result in no further notification emails being sent!
835
		// Calls Revision::getTimestampFromId in normal operation
836
		$notificationTimestamp = call_user_func(
837
			$this->revisionGetTimestampFromIdCallback,
838
			$title,
839
			$oldid
840
		);
841
842
		// We need to go one second to the future because of various strict comparisons
843
		// throughout the codebase
844
		$ts = new MWTimestamp( $notificationTimestamp );
845
		$ts->timestamp->add( new DateInterval( 'PT1S' ) );
846
		$notificationTimestamp = $ts->getTimestamp( TS_MW );
847
848
		if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
849
			if ( $force != 'force' ) {
850
				return false;
851
			} else {
852
				// This is a little silly…
853
				return $item->getNotificationTimestamp();
854
			}
855
		}
856
857
		return $notificationTimestamp;
858
	}
859
860
	/**
861
	 * @param User $user
862
	 * @param int $unreadLimit
863
	 *
864
	 * @return int|bool The number of unread notifications
865
	 *                  true if greater than or equal to $unreadLimit
866
	 */
867
	public function countUnreadNotifications( User $user, $unreadLimit = null ) {
868
		$queryOptions = [];
869
		if ( $unreadLimit !== null ) {
870
			$unreadLimit = (int)$unreadLimit;
871
			$queryOptions['LIMIT'] = $unreadLimit;
872
		}
873
874
		$dbr = $this->getConnection( DB_SLAVE );
875
		$rowCount = $dbr->selectRowCount(
876
			'watchlist',
877
			'1',
878
			[
879
				'wl_user' => $user->getId(),
880
				'wl_notificationtimestamp IS NOT NULL',
881
			],
882
			__METHOD__,
883
			$queryOptions
884
		);
885
		$this->reuseConnection( $dbr );
886
887
		if ( !isset( $unreadLimit ) ) {
888
			return $rowCount;
889
		}
890
891
		if ( $rowCount >= $unreadLimit ) {
892
			return true;
893
		}
894
895
		return $rowCount;
896
	}
897
898
	/**
899
	 * Check if the given title already is watched by the user, and if so
900
	 * add a watch for the new title.
901
	 *
902
	 * To be used for page renames and such.
903
	 *
904
	 * @param LinkTarget $oldTarget
905
	 * @param LinkTarget $newTarget
906
	 */
907
	public function duplicateAllAssociatedEntries( LinkTarget $oldTarget, LinkTarget $newTarget ) {
908
		if ( !$oldTarget instanceof Title ) {
909
			$oldTarget = Title::newFromLinkTarget( $oldTarget );
910
		}
911
		if ( !$newTarget instanceof Title ) {
912
			$newTarget = Title::newFromLinkTarget( $newTarget );
913
		}
914
915
		$this->duplicateEntry( $oldTarget->getSubjectPage(), $newTarget->getSubjectPage() );
916
		$this->duplicateEntry( $oldTarget->getTalkPage(), $newTarget->getTalkPage() );
917
	}
918
919
	/**
920
	 * Check if the given title already is watched by the user, and if so
921
	 * add a watch for the new title.
922
	 *
923
	 * To be used for page renames and such.
924
	 * This must be called separately for Subject and Talk pages
925
	 *
926
	 * @param LinkTarget $oldTarget
927
	 * @param LinkTarget $newTarget
928
	 */
929
	public function duplicateEntry( LinkTarget $oldTarget, LinkTarget $newTarget ) {
930
		$dbw = $this->getConnection( DB_MASTER );
931
932
		$result = $dbw->select(
933
			'watchlist',
934
			[ 'wl_user', 'wl_notificationtimestamp' ],
935
			[
936
				'wl_namespace' => $oldTarget->getNamespace(),
937
				'wl_title' => $oldTarget->getDBkey(),
938
			],
939
			__METHOD__,
940
			[ 'FOR UPDATE' ]
941
		);
942
943
		$newNamespace = $newTarget->getNamespace();
944
		$newDBkey = $newTarget->getDBkey();
945
946
		# Construct array to replace into the watchlist
947
		$values = [];
948
		foreach ( $result as $row ) {
0 ignored issues
show
Bug introduced by
The expression $result of type boolean|object<ResultWrapper> 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...
949
			$values[] = [
950
				'wl_user' => $row->wl_user,
951
				'wl_namespace' => $newNamespace,
952
				'wl_title' => $newDBkey,
953
				'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
954
			];
955
		}
956
957
		if ( !empty( $values ) ) {
958
			# Perform replace
959
			# Note that multi-row replace is very efficient for MySQL but may be inefficient for
960
			# some other DBMSes, mostly due to poor simulation by us
961
			$dbw->replace(
962
				'watchlist',
963
				[ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
964
				$values,
965
				__METHOD__
966
			);
967
		}
968
969
		$this->reuseConnection( $dbw );
970
	}
971
972
}
973