Completed
Branch master (3592b6)
by
unknown
26:28
created

WatchedItemStore::getVisitingWatchersCondition()   B

Complexity

Conditions 5
Paths 12

Size

Total Lines 36
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

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