Completed
Branch master (1655eb)
by
unknown
22:24
created

WatchedItemStore::addWatchBatchForUser()   C

Complexity

Conditions 7
Paths 11

Size

Total Lines 45
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 26
nc 11
nop 2
dl 0
loc 45
rs 6.7272
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
use Wikimedia\ScopedCallback;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, ScopedCallback.

Let’s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let’s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

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