Completed
Branch master (f9128d)
by
unknown
31:52
created

WatchedItemStore::getNotificationTimestampsBatch()   C

Complexity

Conditions 7
Paths 20

Size

Total Lines 45
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 45
rs 6.7272
cc 7
eloc 28
nc 20
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
	 * @param User $user
507
	 * @param LinkTarget[] $targets
508
	 *
509
	 * @return array multi-dimensional like $return[$namespaceId][$titleString] = $timestamp,
510
	 *         where $timestamp is:
511
	 *         - string|null value of wl_notificationtimestamp,
512
	 *         - false if $target is not watched by $user.
513
	 */
514
	public function getNotificationTimestampsBatch( User $user, array $targets ) {
515
		$timestamps = [];
516
		foreach ( $targets as $target ) {
517
			$timestamps[$target->getNamespace()][$target->getDBkey()] = false;
518
		}
519
520
		if ( $user->isAnon() ) {
521
			return $timestamps;
522
		}
523
524
		$targetsToLoad = [];
525
		foreach ( $targets as $target ) {
526
			$cachedItem = $this->getCached( $user, $target );
527
			if ( $cachedItem ) {
528
				$timestamps[$target->getNamespace()][$target->getDBkey()] =
529
					$cachedItem->getNotificationTimestamp();
530
			} else {
531
				$targetsToLoad[] = $target;
532
			}
533
		}
534
535
		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...
536
			return $timestamps;
537
		}
538
539
		$dbr = $this->getConnection( DB_SLAVE );
540
541
		$lb = new LinkBatch( $targetsToLoad );
542
		$res = $dbr->select(
543
			'watchlist',
544
			[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
545
			[
546
				$lb->constructSet( 'wl', $dbr ),
547
				'wl_user' => $user->getId(),
548
			],
549
			__METHOD__
550
		);
551
		$this->reuseConnection( $dbr );
552
553
		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...
554
			$timestamps[(int)$row->wl_namespace][$row->wl_title] = $row->wl_notificationtimestamp;
555
		}
556
557
		return $timestamps;
558
	}
559
560
	/**
561
	 * Must be called separately for Subject & Talk namespaces
562
	 *
563
	 * @param User $user
564
	 * @param LinkTarget $target
565
	 */
566
	public function addWatch( User $user, LinkTarget $target ) {
567
		$this->addWatchBatch( [ [ $user, $target ] ] );
568
	}
569
570
	/**
571
	 * @param array[] $userTargetCombinations array of arrays containing [0] => User [1] => LinkTarget
572
	 *
573
	 * @return bool success
574
	 */
575
	public function addWatchBatch( array $userTargetCombinations ) {
576
		if ( $this->loadBalancer->getReadOnlyReason() !== false ) {
577
			return false;
578
		}
579
580
		$rows = [];
581
		foreach ( $userTargetCombinations as list( $user, $target ) ) {
582
			/**
583
			 * @var User $user
584
			 * @var LinkTarget $target
585
			 */
586
587
			// Only loggedin user can have a watchlist
588
			if ( $user->isAnon() ) {
589
				continue;
590
			}
591
			$rows[] = [
592
				'wl_user' => $user->getId(),
593
				'wl_namespace' => $target->getNamespace(),
594
				'wl_title' => $target->getDBkey(),
595
				'wl_notificationtimestamp' => null,
596
			];
597
			$this->uncache( $user, $target );
598
		}
599
600
		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...
601
			return false;
602
		}
603
604
		$dbw = $this->getConnection( DB_MASTER );
605
		foreach ( array_chunk( $rows, 100 ) as $toInsert ) {
606
			// Use INSERT IGNORE to avoid overwriting the notification timestamp
607
			// if there's already an entry for this page
608
			$dbw->insert( 'watchlist', $toInsert, __METHOD__, 'IGNORE' );
609
		}
610
		$this->reuseConnection( $dbw );
611
612
		return true;
613
	}
614
615
	/**
616
	 * Removes the an entry for the User watching the LinkTarget
617
	 * Must be called separately for Subject & Talk namespaces
618
	 *
619
	 * @param User $user
620
	 * @param LinkTarget $target
621
	 *
622
	 * @return bool success
623
	 * @throws DBUnexpectedError
624
	 * @throws MWException
625
	 */
626
	public function removeWatch( User $user, LinkTarget $target ) {
627
		// Only logged in user can have a watchlist
628
		if ( $this->loadBalancer->getReadOnlyReason() !== false || $user->isAnon() ) {
629
			return false;
630
		}
631
632
		$this->uncache( $user, $target );
633
634
		$dbw = $this->getConnection( DB_MASTER );
635
		$dbw->delete( 'watchlist',
636
			[
637
				'wl_user' => $user->getId(),
638
				'wl_namespace' => $target->getNamespace(),
639
				'wl_title' => $target->getDBkey(),
640
			], __METHOD__
641
		);
642
		$success = (bool)$dbw->affectedRows();
643
		$this->reuseConnection( $dbw );
644
645
		return $success;
646
	}
647
648
	/**
649
	 * @param User $editor The editor that triggered the update. Their notification
650
	 *  timestamp will not be updated(they have already seen it)
651
	 * @param LinkTarget $target The target to update timestamps for
652
	 * @param string $timestamp Set the update timestamp to this value
653
	 *
654
	 * @return int[] Array of user IDs the timestamp has been updated for
655
	 */
656
	public function updateNotificationTimestamp( User $editor, LinkTarget $target, $timestamp ) {
657
		$dbw = $this->getConnection( DB_MASTER );
658
		$res = $dbw->select( [ 'watchlist' ],
659
			[ 'wl_user' ],
660
			[
661
				'wl_user != ' . intval( $editor->getId() ),
662
				'wl_namespace' => $target->getNamespace(),
663
				'wl_title' => $target->getDBkey(),
664
				'wl_notificationtimestamp IS NULL',
665
			], __METHOD__
666
		);
667
668
		$watchers = [];
669
		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...
670
			$watchers[] = intval( $row->wl_user );
671
		}
672
673
		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...
674
			// Update wl_notificationtimestamp for all watching users except the editor
675
			$fname = __METHOD__;
676
			$dbw->onTransactionIdle(
677
				function () use ( $dbw, $timestamp, $watchers, $target, $fname ) {
678
					$dbw->update( 'watchlist',
679
						[ /* SET */
680
							'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
681
						], [ /* WHERE */
682
							'wl_user' => $watchers,
683
							'wl_namespace' => $target->getNamespace(),
684
							'wl_title' => $target->getDBkey(),
685
						], $fname
686
					);
687
					$this->uncacheLinkTarget( $target );
688
				}
689
			);
690
		}
691
692
		$this->reuseConnection( $dbw );
693
694
		return $watchers;
695
	}
696
697
	/**
698
	 * Reset the notification timestamp of this entry
699
	 *
700
	 * @param User $user
701
	 * @param Title $title
702
	 * @param string $force Whether to force the write query to be executed even if the
703
	 *    page is not watched or the notification timestamp is already NULL.
704
	 *    'force' in order to force
705
	 * @param int $oldid The revision id being viewed. If not given or 0, latest revision is assumed.
706
	 *
707
	 * @return bool success
708
	 */
709
	public function resetNotificationTimestamp( User $user, Title $title, $force = '', $oldid = 0 ) {
710
		// Only loggedin user can have a watchlist
711
		if ( $this->loadBalancer->getReadOnlyReason() !== false || $user->isAnon() ) {
712
			return false;
713
		}
714
715
		$item = null;
716
		if ( $force != 'force' ) {
717
			$item = $this->loadWatchedItem( $user, $title );
718
			if ( !$item || $item->getNotificationTimestamp() === null ) {
719
				return false;
720
			}
721
		}
722
723
		// If the page is watched by the user (or may be watched), update the timestamp
724
		$job = new ActivityUpdateJob(
725
			$title,
726
			[
727
				'type'      => 'updateWatchlistNotification',
728
				'userid'    => $user->getId(),
729
				'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
730
				'curTime'   => time()
731
			]
732
		);
733
734
		// Try to run this post-send
735
		// Calls DeferredUpdates::addCallableUpdate in normal operation
736
		call_user_func(
737
			$this->deferredUpdatesAddCallableUpdateCallback,
738
			function() use ( $job ) {
739
				$job->run();
740
			}
741
		);
742
743
		$this->uncache( $user, $title );
744
745
		return true;
746
	}
747
748
	private function getNotificationTimestamp( User $user, Title $title, $item, $force, $oldid ) {
749
		if ( !$oldid ) {
750
			// No oldid given, assuming latest revision; clear the timestamp.
751
			return null;
752
		}
753
754
		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...
755
			// Oldid given and is the latest revision for this title; clear the timestamp.
756
			return null;
757
		}
758
759
		if ( $item === null ) {
760
			$item = $this->loadWatchedItem( $user, $title );
761
		}
762
763
		if ( !$item ) {
764
			// This can only happen if $force is enabled.
765
			return null;
766
		}
767
768
		// Oldid given and isn't the latest; update the timestamp.
769
		// This will result in no further notification emails being sent!
770
		// Calls Revision::getTimestampFromId in normal operation
771
		$notificationTimestamp = call_user_func(
772
			$this->revisionGetTimestampFromIdCallback,
773
			$title,
774
			$oldid
775
		);
776
777
		// We need to go one second to the future because of various strict comparisons
778
		// throughout the codebase
779
		$ts = new MWTimestamp( $notificationTimestamp );
780
		$ts->timestamp->add( new DateInterval( 'PT1S' ) );
781
		$notificationTimestamp = $ts->getTimestamp( TS_MW );
782
783
		if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
784
			if ( $force != 'force' ) {
785
				return false;
786
			} else {
787
				// This is a little silly…
788
				return $item->getNotificationTimestamp();
789
			}
790
		}
791
792
		return $notificationTimestamp;
793
	}
794
795
	/**
796
	 * @param User $user
797
	 * @param int $unreadLimit
798
	 *
799
	 * @return int|bool The number of unread notifications
800
	 *                  true if greater than or equal to $unreadLimit
801
	 */
802
	public function countUnreadNotifications( User $user, $unreadLimit = null ) {
803
		$queryOptions = [];
804
		if ( $unreadLimit !== null ) {
805
			$unreadLimit = (int)$unreadLimit;
806
			$queryOptions['LIMIT'] = $unreadLimit;
807
		}
808
809
		$dbr = $this->getConnection( DB_SLAVE );
810
		$rowCount = $dbr->selectRowCount(
811
			'watchlist',
812
			'1',
813
			[
814
				'wl_user' => $user->getId(),
815
				'wl_notificationtimestamp IS NOT NULL',
816
			],
817
			__METHOD__,
818
			$queryOptions
819
		);
820
		$this->reuseConnection( $dbr );
821
822
		if ( !isset( $unreadLimit ) ) {
823
			return $rowCount;
824
		}
825
826
		if ( $rowCount >= $unreadLimit ) {
827
			return true;
828
		}
829
830
		return $rowCount;
831
	}
832
833
	/**
834
	 * Check if the given title already is watched by the user, and if so
835
	 * add a watch for the new title.
836
	 *
837
	 * To be used for page renames and such.
838
	 *
839
	 * @param LinkTarget $oldTarget
840
	 * @param LinkTarget $newTarget
841
	 */
842
	public function duplicateAllAssociatedEntries( LinkTarget $oldTarget, LinkTarget $newTarget ) {
843
		if ( !$oldTarget instanceof Title ) {
844
			$oldTarget = Title::newFromLinkTarget( $oldTarget );
845
		}
846
		if ( !$newTarget instanceof Title ) {
847
			$newTarget = Title::newFromLinkTarget( $newTarget );
848
		}
849
850
		$this->duplicateEntry( $oldTarget->getSubjectPage(), $newTarget->getSubjectPage() );
851
		$this->duplicateEntry( $oldTarget->getTalkPage(), $newTarget->getTalkPage() );
852
	}
853
854
	/**
855
	 * Check if the given title already is watched by the user, and if so
856
	 * add a watch for the new title.
857
	 *
858
	 * To be used for page renames and such.
859
	 * This must be called separately for Subject and Talk pages
860
	 *
861
	 * @param LinkTarget $oldTarget
862
	 * @param LinkTarget $newTarget
863
	 */
864
	public function duplicateEntry( LinkTarget $oldTarget, LinkTarget $newTarget ) {
865
		$dbw = $this->getConnection( DB_MASTER );
866
867
		$result = $dbw->select(
868
			'watchlist',
869
			[ 'wl_user', 'wl_notificationtimestamp' ],
870
			[
871
				'wl_namespace' => $oldTarget->getNamespace(),
872
				'wl_title' => $oldTarget->getDBkey(),
873
			],
874
			__METHOD__,
875
			[ 'FOR UPDATE' ]
876
		);
877
878
		$newNamespace = $newTarget->getNamespace();
879
		$newDBkey = $newTarget->getDBkey();
880
881
		# Construct array to replace into the watchlist
882
		$values = [];
883
		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...
884
			$values[] = [
885
				'wl_user' => $row->wl_user,
886
				'wl_namespace' => $newNamespace,
887
				'wl_title' => $newDBkey,
888
				'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
889
			];
890
		}
891
892
		if ( !empty( $values ) ) {
893
			# Perform replace
894
			# Note that multi-row replace is very efficient for MySQL but may be inefficient for
895
			# some other DBMSes, mostly due to poor simulation by us
896
			$dbw->replace(
897
				'watchlist',
898
				[ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
899
				$values,
900
				__METHOD__
901
			);
902
		}
903
904
		$this->reuseConnection( $dbw );
905
	}
906
907
}
908