Completed
Branch master (771964)
by
unknown
26:13
created

WatchedItemStore::addWatchBatch()   B

Complexity

Conditions 6
Paths 10

Size

Total Lines 39
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
c 1
b 0
f 1
dl 0
loc 39
rs 8.439
cc 6
eloc 20
nc 10
nop 1
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
	const SORT_DESC = 'DESC';
16
	const SORT_ASC = 'ASC';
17
18
	/**
19
	 * @var LoadBalancer
20
	 */
21
	private $loadBalancer;
22
23
	/**
24
	 * @var HashBagOStuff
25
	 */
26
	private $cache;
27
28
	/**
29
	 * @var array[] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key'
30
	 * The index is needed so that on mass changes all relevant items can be un-cached.
31
	 * For example: Clearing a users watchlist of all items or updating notification timestamps
32
	 *              for all users watching a single target.
33
	 */
34
	private $cacheIndex = [];
35
36
	/**
37
	 * @var callable|null
38
	 */
39
	private $deferredUpdatesAddCallableUpdateCallback;
40
41
	/**
42
	 * @var callable|null
43
	 */
44
	private $revisionGetTimestampFromIdCallback;
45
46
	/**
47
	 * @var self|null
48
	 */
49
	private static $instance;
50
51
	/**
52
	 * @param LoadBalancer $loadBalancer
53
	 * @param HashBagOStuff $cache
54
	 */
55
	public function __construct(
56
		LoadBalancer $loadBalancer,
57
		HashBagOStuff $cache
58
	) {
59
		$this->loadBalancer = $loadBalancer;
60
		$this->cache = $cache;
61
		$this->deferredUpdatesAddCallableUpdateCallback = [ 'DeferredUpdates', 'addCallableUpdate' ];
62
		$this->revisionGetTimestampFromIdCallback = [ 'Revision', 'getTimestampFromId' ];
63
	}
64
65
	/**
66
	 * Overrides the DeferredUpdates::addCallableUpdate callback
67
	 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
68
	 *
69
	 * @param callable $callback
70
	 *
71
	 * @see DeferredUpdates::addCallableUpdate for callback signiture
72
	 *
73
	 * @return ScopedCallback to reset the overridden value
74
	 * @throws MWException
75
	 */
76 View Code Duplication
	public function overrideDeferredUpdatesAddCallableUpdateCallback( $callback ) {
77
		if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
78
			throw new MWException(
79
				'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
80
			);
81
		}
82
		Assert::parameterType( 'callable', $callback, '$callback' );
83
84
		$previousValue = $this->deferredUpdatesAddCallableUpdateCallback;
85
		$this->deferredUpdatesAddCallableUpdateCallback = $callback;
86
		return new ScopedCallback( function() use ( $previousValue ) {
87
			$this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
88
		} );
89
	}
90
91
	/**
92
	 * Overrides the Revision::getTimestampFromId callback
93
	 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
94
	 *
95
	 * @param callable $callback
96
	 * @see Revision::getTimestampFromId for callback signiture
97
	 *
98
	 * @return ScopedCallback to reset the overridden value
99
	 * @throws MWException
100
	 */
101 View Code Duplication
	public function overrideRevisionGetTimestampFromIdCallback( $callback ) {
102
		if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
103
			throw new MWException(
104
				'Cannot override Revision::getTimestampFromId callback in operation.'
105
			);
106
		}
107
		Assert::parameterType( 'callable', $callback, '$callback' );
108
109
		$previousValue = $this->revisionGetTimestampFromIdCallback;
110
		$this->revisionGetTimestampFromIdCallback = $callback;
111
		return new ScopedCallback( function() use ( $previousValue ) {
112
			$this->revisionGetTimestampFromIdCallback = $previousValue;
113
		} );
114
	}
115
116
	/**
117
	 * Overrides the default instance of this class
118
	 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
119
	 *
120
	 * If this method is used it MUST also be called with null after a test to ensure a new
121
	 * default instance is created next time getDefaultInstance is called.
122
	 *
123
	 * @param WatchedItemStore|null $store
124
	 *
125
	 * @return ScopedCallback to reset the overridden value
126
	 * @throws MWException
127
	 */
128
	public static function overrideDefaultInstance( WatchedItemStore $store = null ) {
129
		if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
130
			throw new MWException(
131
				'Cannot override ' . __CLASS__ . 'default instance in operation.'
132
			);
133
		}
134
135
		$previousValue = self::$instance;
136
		self::$instance = $store;
137
		return new ScopedCallback( function() use ( $previousValue ) {
138
			self::$instance = $previousValue;
139
		} );
140
	}
141
142
	/**
143
	 * @return self
144
	 */
145
	public static function getDefaultInstance() {
146
		if ( !self::$instance ) {
147
			self::$instance = new self(
148
				wfGetLB(),
149
				new HashBagOStuff( [ 'maxKeys' => 100 ] )
150
			);
151
		}
152
		return self::$instance;
153
	}
154
155
	private function getCacheKey( User $user, LinkTarget $target ) {
156
		return $this->cache->makeKey(
157
			(string)$target->getNamespace(),
158
			$target->getDBkey(),
159
			(string)$user->getId()
160
		);
161
	}
162
163
	private function cache( WatchedItem $item ) {
164
		$user = $item->getUser();
165
		$target = $item->getLinkTarget();
166
		$key = $this->getCacheKey( $user, $target );
167
		$this->cache->set( $key, $item );
168
		$this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
169
	}
170
171
	private function uncache( User $user, LinkTarget $target ) {
172
		$this->cache->delete( $this->getCacheKey( $user, $target ) );
173
		unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
174
	}
175
176
	private function uncacheLinkTarget( LinkTarget $target ) {
177
		if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
178
			return;
179
		}
180
		foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
181
			$this->cache->delete( $key );
182
		}
183
	}
184
185
	/**
186
	 * @param User $user
187
	 * @param LinkTarget $target
188
	 *
189
	 * @return WatchedItem|null
190
	 */
191
	private function getCached( User $user, LinkTarget $target ) {
192
		return $this->cache->get( $this->getCacheKey( $user, $target ) );
193
	}
194
195
	/**
196
	 * Return an array of conditions to select or update the appropriate database
197
	 * row.
198
	 *
199
	 * @param User $user
200
	 * @param LinkTarget $target
201
	 *
202
	 * @return array
203
	 */
204
	private function dbCond( User $user, LinkTarget $target ) {
205
		return [
206
			'wl_user' => $user->getId(),
207
			'wl_namespace' => $target->getNamespace(),
208
			'wl_title' => $target->getDBkey(),
209
		];
210
	}
211
212
	/**
213
	 * @param int $slaveOrMaster DB_MASTER or DB_SLAVE
214
	 *
215
	 * @return DatabaseBase
216
	 * @throws MWException
217
	 */
218
	private function getConnection( $slaveOrMaster ) {
219
		return $this->loadBalancer->getConnection( $slaveOrMaster, [ 'watchlist' ] );
220
	}
221
222
	/**
223
	 * @param DatabaseBase $connection
224
	 *
225
	 * @throws MWException
226
	 */
227
	private function reuseConnection( $connection ) {
228
		$this->loadBalancer->reuseConnection( $connection );
229
	}
230
231
	/**
232
	 * Count the number of individual items that are watched by the user.
233
	 * If a subject and corresponding talk page are watched this will return 2.
234
	 *
235
	 * @param User $user
236
	 *
237
	 * @return int
238
	 */
239
	public function countWatchedItems( User $user ) {
240
		$dbr = $this->getConnection( DB_SLAVE );
241
		$return = (int)$dbr->selectField(
242
			'watchlist',
243
			'COUNT(*)',
244
			[
245
				'wl_user' => $user->getId()
246
			],
247
			__METHOD__
248
		);
249
		$this->reuseConnection( $dbr );
250
251
		return $return;
252
	}
253
254
	/**
255
	 * @param LinkTarget $target
256
	 *
257
	 * @return int
258
	 */
259
	public function countWatchers( LinkTarget $target ) {
260
		$dbr = $this->getConnection( DB_SLAVE );
261
		$return = (int)$dbr->selectField(
262
			'watchlist',
263
			'COUNT(*)',
264
			[
265
				'wl_namespace' => $target->getNamespace(),
266
				'wl_title' => $target->getDBkey(),
267
			],
268
			__METHOD__
269
		);
270
		$this->reuseConnection( $dbr );
271
272
		return $return;
273
	}
274
275
	/**
276
	 * Number of page watchers who also visited a "recent" edit
277
	 *
278
	 * @param LinkTarget $target
279
	 * @param mixed $threshold timestamp accepted by wfTimestamp
280
	 *
281
	 * @return int
282
	 * @throws DBUnexpectedError
283
	 * @throws MWException
284
	 */
285
	public function countVisitingWatchers( LinkTarget $target, $threshold ) {
286
		$dbr = $this->getConnection( DB_SLAVE );
287
		$visitingWatchers = (int)$dbr->selectField(
288
			'watchlist',
289
			'COUNT(*)',
290
			[
291
				'wl_namespace' => $target->getNamespace(),
292
				'wl_title' => $target->getDBkey(),
293
				'wl_notificationtimestamp >= ' .
294
				$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...
295
				' OR wl_notificationtimestamp IS NULL'
296
			],
297
			__METHOD__
298
		);
299
		$this->reuseConnection( $dbr );
300
301
		return $visitingWatchers;
302
	}
303
304
	/**
305
	 * @param LinkTarget[] $targets
306
	 * @param array $options Allowed keys:
307
	 *        'minimumWatchers' => int
308
	 *
309
	 * @return array multi dimensional like $return[$namespaceId][$titleString] = int $watchers
310
	 *         All targets will be present in the result. 0 either means no watchers or the number
311
	 *         of watchers was below the minimumWatchers option if passed.
312
	 */
313
	public function countWatchersMultiple( array $targets, array $options = [] ) {
314
		$dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
315
316
		$dbr = $this->getConnection( DB_SLAVE );
317
318
		if ( array_key_exists( 'minimumWatchers', $options ) ) {
319
			$dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
320
		}
321
322
		$lb = new LinkBatch( $targets );
323
		$res = $dbr->select(
324
			'watchlist',
325
			[ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
326
			[ $lb->constructSet( 'wl', $dbr ) ],
327
			__METHOD__,
328
			$dbOptions
329
		);
330
331
		$this->reuseConnection( $dbr );
332
333
		$watchCounts = [];
334
		foreach ( $targets as $linkTarget ) {
335
			$watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
336
		}
337
338
		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...
339
			$watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
340
		}
341
342
		return $watchCounts;
343
	}
344
345
	/**
346
	 * Number of watchers of each page who have visited recent edits to that page
347
	 *
348
	 * @param array $targetsWithVisitThresholds array of pairs (LinkTarget $target, mixed $threshold),
349
	 *        $threshold is:
350
	 *        - a timestamp of the recent edit if $target exists (format accepted by wfTimestamp)
351
	 *        - null if $target doesn't exist
352
	 * @param int|null $minimumWatchers
353
	 * @return array multi-dimensional like $return[$namespaceId][$titleString] = $watchers,
354
	 *         where $watchers is an int:
355
	 *         - if the page exists, number of users watching who have visited the page recently
356
	 *         - if the page doesn't exist, number of users that have the page on their watchlist
357
	 *         - 0 means there are no visiting watchers or their number is below the minimumWatchers
358
	 *         option (if passed).
359
	 */
360
	public function countVisitingWatchersMultiple(
361
		array $targetsWithVisitThresholds,
362
		$minimumWatchers = null
363
	) {
364
		$dbr = $this->getConnection( DB_SLAVE );
365
366
		$conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds );
367
368
		$dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
369
		if ( $minimumWatchers !== null ) {
370
			$dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
371
		}
372
		$res = $dbr->select(
373
			'watchlist',
374
			[ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
375
			$conds,
376
			__METHOD__,
377
			$dbOptions
378
		);
379
380
		$this->reuseConnection( $dbr );
381
382
		$watcherCounts = [];
383
		foreach ( $targetsWithVisitThresholds as list( $target ) ) {
384
			/* @var LinkTarget $target */
385
			$watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
386
		}
387
388
		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...
389
			$watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
390
		}
391
392
		return $watcherCounts;
393
	}
394
395
	/**
396
	 * Generates condition for the query used in a batch count visiting watchers.
397
	 *
398
	 * @param IDatabase $db
399
	 * @param array $targetsWithVisitThresholds array of pairs (LinkTarget, last visit threshold)
400
	 * @return string
401
	 */
402
	private function getVisitingWatchersCondition(
403
		IDatabase $db,
404
		array $targetsWithVisitThresholds
405
	) {
406
		$missingTargets = [];
407
		$namespaceConds = [];
408
		foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
409
			if ( $threshold === null ) {
410
				$missingTargets[] = $target;
411
				continue;
412
			}
413
			/* @var LinkTarget $target */
414
			$namespaceConds[$target->getNamespace()][] = $db->makeList( [
415
				'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
416
				$db->makeList( [
417
					'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
418
					'wl_notificationtimestamp IS NULL'
419
				], LIST_OR )
420
			], LIST_AND );
421
		}
422
423
		$conds = [];
424
		foreach ( $namespaceConds as $namespace => $pageConds ) {
425
			$conds[] = $db->makeList( [
426
				'wl_namespace = ' . $namespace,
427
				'(' . $db->makeList( $pageConds, LIST_OR ) . ')'
428
			], LIST_AND );
429
		}
430
431
		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...
432
			$lb = new LinkBatch( $missingTargets );
433
			$conds[] = $lb->constructSet( 'wl', $db );
434
		}
435
436
		return $db->makeList( $conds, LIST_OR );
437
	}
438
439
	/**
440
	 * Get an item (may be cached)
441
	 *
442
	 * @param User $user
443
	 * @param LinkTarget $target
444
	 *
445
	 * @return WatchedItem|false
446
	 */
447
	public function getWatchedItem( User $user, LinkTarget $target ) {
448
		if ( $user->isAnon() ) {
449
			return false;
450
		}
451
452
		$cached = $this->getCached( $user, $target );
453
		if ( $cached ) {
454
			return $cached;
455
		}
456
		return $this->loadWatchedItem( $user, $target );
457
	}
458
459
	/**
460
	 * Loads an item from the db
461
	 *
462
	 * @param User $user
463
	 * @param LinkTarget $target
464
	 *
465
	 * @return WatchedItem|false
466
	 */
467
	public function loadWatchedItem( User $user, LinkTarget $target ) {
468
		// Only loggedin user can have a watchlist
469
		if ( $user->isAnon() ) {
470
			return false;
471
		}
472
473
		$dbr = $this->getConnection( DB_SLAVE );
474
		$row = $dbr->selectRow(
475
			'watchlist',
476
			'wl_notificationtimestamp',
477
			$this->dbCond( $user, $target ),
478
			__METHOD__
479
		);
480
		$this->reuseConnection( $dbr );
481
482
		if ( !$row ) {
483
			return false;
484
		}
485
486
		$item = new WatchedItem(
487
			$user,
488
			$target,
489
			$row->wl_notificationtimestamp
490
		);
491
		$this->cache( $item );
492
493
		return $item;
494
	}
495
496
	/**
497
	 * @param User $user
498
	 * @param array $options Allowed keys:
499
	 *        'forWrite' => bool defaults to false
500
	 *        'sort' => string optional sorting by namespace ID and title
501
	 *                     one of the self::SORT_* constants
502
	 *
503
	 * @return WatchedItem[]
504
	 */
505
	public function getWatchedItemsForUser( User $user, array $options = [] ) {
506
		$options += [ 'forWrite' => false ];
507
508
		$dbOptions = [];
509
		if ( array_key_exists( 'sort', $options ) ) {
510
			Assert::parameter(
511
				( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
512
				'$options[\'sort\']',
513
				'must be SORT_ASC or SORT_DESC'
514
			);
515
			$dbOptions['ORDER BY'] = [
516
				"wl_namespace {$options['sort']}",
517
				"wl_title {$options['sort']}"
518
			];
519
		}
520
		$db = $this->getConnection( $options['forWrite'] ? DB_MASTER : DB_SLAVE );
521
522
		$res = $db->select(
523
			'watchlist',
524
			[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
525
			[ 'wl_user' => $user->getId() ],
526
			__METHOD__,
527
			$dbOptions
528
		);
529
		$this->reuseConnection( $db );
530
531
		$watchedItems = [];
532
		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...
533
			// todo these could all be cached at some point?
534
			$watchedItems[] = new WatchedItem(
535
				$user,
536
				new TitleValue( (int)$row->wl_namespace, $row->wl_title ),
537
				$row->wl_notificationtimestamp
538
			);
539
		}
540
541
		return $watchedItems;
542
	}
543
544
	/**
545
	 * Must be called separately for Subject & Talk namespaces
546
	 *
547
	 * @param User $user
548
	 * @param LinkTarget $target
549
	 *
550
	 * @return bool
551
	 */
552
	public function isWatched( User $user, LinkTarget $target ) {
553
		return (bool)$this->getWatchedItem( $user, $target );
554
	}
555
556
	/**
557
	 * @param User $user
558
	 * @param LinkTarget[] $targets
559
	 *
560
	 * @return array multi-dimensional like $return[$namespaceId][$titleString] = $timestamp,
561
	 *         where $timestamp is:
562
	 *         - string|null value of wl_notificationtimestamp,
563
	 *         - false if $target is not watched by $user.
564
	 */
565
	public function getNotificationTimestampsBatch( User $user, array $targets ) {
566
		$timestamps = [];
567
		foreach ( $targets as $target ) {
568
			$timestamps[$target->getNamespace()][$target->getDBkey()] = false;
569
		}
570
571
		if ( $user->isAnon() ) {
572
			return $timestamps;
573
		}
574
575
		$targetsToLoad = [];
576
		foreach ( $targets as $target ) {
577
			$cachedItem = $this->getCached( $user, $target );
578
			if ( $cachedItem ) {
579
				$timestamps[$target->getNamespace()][$target->getDBkey()] =
580
					$cachedItem->getNotificationTimestamp();
581
			} else {
582
				$targetsToLoad[] = $target;
583
			}
584
		}
585
586
		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...
587
			return $timestamps;
588
		}
589
590
		$dbr = $this->getConnection( DB_SLAVE );
591
592
		$lb = new LinkBatch( $targetsToLoad );
593
		$res = $dbr->select(
594
			'watchlist',
595
			[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
596
			[
597
				$lb->constructSet( 'wl', $dbr ),
598
				'wl_user' => $user->getId(),
599
			],
600
			__METHOD__
601
		);
602
		$this->reuseConnection( $dbr );
603
604
		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...
605
			$timestamps[(int)$row->wl_namespace][$row->wl_title] = $row->wl_notificationtimestamp;
606
		}
607
608
		return $timestamps;
609
	}
610
611
	/**
612
	 * Must be called separately for Subject & Talk namespaces
613
	 *
614
	 * @param User $user
615
	 * @param LinkTarget $target
616
	 */
617
	public function addWatch( User $user, LinkTarget $target ) {
618
		$this->addWatchBatchForUser( $user, [ $target ] );
619
	}
620
621
	/**
622
	 * @param User $user
623
	 * @param LinkTarget[] $targets
624
	 *
625
	 * @return bool success
626
	 */
627
	public function addWatchBatchForUser( User $user, array $targets ) {
628
		if ( $this->loadBalancer->getReadOnlyReason() !== false ) {
629
			return false;
630
		}
631
		// Only loggedin user can have a watchlist
632
		if ( $user->isAnon() ) {
633
			return false;
634
		}
635
636
		if ( !$targets ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $targets of type LinkTarget[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

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

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

Loading history...
637
			return true;
638
		}
639
640
		$rows = [];
641
		foreach ( $targets as $target ) {
642
			$rows[] = [
643
				'wl_user' => $user->getId(),
644
				'wl_namespace' => $target->getNamespace(),
645
				'wl_title' => $target->getDBkey(),
646
				'wl_notificationtimestamp' => null,
647
			];
648
			$this->uncache( $user, $target );
649
		}
650
651
		$dbw = $this->getConnection( DB_MASTER );
652
		foreach ( array_chunk( $rows, 100 ) as $toInsert ) {
653
			// Use INSERT IGNORE to avoid overwriting the notification timestamp
654
			// if there's already an entry for this page
655
			$dbw->insert( 'watchlist', $toInsert, __METHOD__, 'IGNORE' );
656
		}
657
		$this->reuseConnection( $dbw );
658
659
		return true;
660
	}
661
662
	/**
663
	 * Removes the an entry for the User watching the LinkTarget
664
	 * Must be called separately for Subject & Talk namespaces
665
	 *
666
	 * @param User $user
667
	 * @param LinkTarget $target
668
	 *
669
	 * @return bool success
670
	 * @throws DBUnexpectedError
671
	 * @throws MWException
672
	 */
673
	public function removeWatch( User $user, LinkTarget $target ) {
674
		// Only logged in user can have a watchlist
675
		if ( $this->loadBalancer->getReadOnlyReason() !== false || $user->isAnon() ) {
676
			return false;
677
		}
678
679
		$this->uncache( $user, $target );
680
681
		$dbw = $this->getConnection( DB_MASTER );
682
		$dbw->delete( 'watchlist',
683
			[
684
				'wl_user' => $user->getId(),
685
				'wl_namespace' => $target->getNamespace(),
686
				'wl_title' => $target->getDBkey(),
687
			], __METHOD__
688
		);
689
		$success = (bool)$dbw->affectedRows();
690
		$this->reuseConnection( $dbw );
691
692
		return $success;
693
	}
694
695
	/**
696
	 * @param User $editor The editor that triggered the update. Their notification
697
	 *  timestamp will not be updated(they have already seen it)
698
	 * @param LinkTarget $target The target to update timestamps for
699
	 * @param string $timestamp Set the update timestamp to this value
700
	 *
701
	 * @return int[] Array of user IDs the timestamp has been updated for
702
	 */
703
	public function updateNotificationTimestamp( User $editor, LinkTarget $target, $timestamp ) {
704
		$dbw = $this->getConnection( DB_MASTER );
705
		$res = $dbw->select( [ 'watchlist' ],
706
			[ 'wl_user' ],
707
			[
708
				'wl_user != ' . intval( $editor->getId() ),
709
				'wl_namespace' => $target->getNamespace(),
710
				'wl_title' => $target->getDBkey(),
711
				'wl_notificationtimestamp IS NULL',
712
			], __METHOD__
713
		);
714
715
		$watchers = [];
716
		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...
717
			$watchers[] = intval( $row->wl_user );
718
		}
719
720
		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...
721
			// Update wl_notificationtimestamp for all watching users except the editor
722
			$fname = __METHOD__;
723
			$dbw->onTransactionIdle(
724
				function () use ( $dbw, $timestamp, $watchers, $target, $fname ) {
725
					$dbw->update( 'watchlist',
726
						[ /* SET */
727
							'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
728
						], [ /* WHERE */
729
							'wl_user' => $watchers,
730
							'wl_namespace' => $target->getNamespace(),
731
							'wl_title' => $target->getDBkey(),
732
						], $fname
733
					);
734
					$this->uncacheLinkTarget( $target );
735
				}
736
			);
737
		}
738
739
		$this->reuseConnection( $dbw );
740
741
		return $watchers;
742
	}
743
744
	/**
745
	 * Reset the notification timestamp of this entry
746
	 *
747
	 * @param User $user
748
	 * @param Title $title
749
	 * @param string $force Whether to force the write query to be executed even if the
750
	 *    page is not watched or the notification timestamp is already NULL.
751
	 *    'force' in order to force
752
	 * @param int $oldid The revision id being viewed. If not given or 0, latest revision is assumed.
753
	 *
754
	 * @return bool success
755
	 */
756
	public function resetNotificationTimestamp( User $user, Title $title, $force = '', $oldid = 0 ) {
757
		// Only loggedin user can have a watchlist
758
		if ( $this->loadBalancer->getReadOnlyReason() !== false || $user->isAnon() ) {
759
			return false;
760
		}
761
762
		$item = null;
763
		if ( $force != 'force' ) {
764
			$item = $this->loadWatchedItem( $user, $title );
765
			if ( !$item || $item->getNotificationTimestamp() === null ) {
766
				return false;
767
			}
768
		}
769
770
		// If the page is watched by the user (or may be watched), update the timestamp
771
		$job = new ActivityUpdateJob(
772
			$title,
773
			[
774
				'type'      => 'updateWatchlistNotification',
775
				'userid'    => $user->getId(),
776
				'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
777
				'curTime'   => time()
778
			]
779
		);
780
781
		// Try to run this post-send
782
		// Calls DeferredUpdates::addCallableUpdate in normal operation
783
		call_user_func(
784
			$this->deferredUpdatesAddCallableUpdateCallback,
785
			function() use ( $job ) {
786
				$job->run();
787
			}
788
		);
789
790
		$this->uncache( $user, $title );
791
792
		return true;
793
	}
794
795
	private function getNotificationTimestamp( User $user, Title $title, $item, $force, $oldid ) {
796
		if ( !$oldid ) {
797
			// No oldid given, assuming latest revision; clear the timestamp.
798
			return null;
799
		}
800
801
		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...
802
			// Oldid given and is the latest revision for this title; clear the timestamp.
803
			return null;
804
		}
805
806
		if ( $item === null ) {
807
			$item = $this->loadWatchedItem( $user, $title );
808
		}
809
810
		if ( !$item ) {
811
			// This can only happen if $force is enabled.
812
			return null;
813
		}
814
815
		// Oldid given and isn't the latest; update the timestamp.
816
		// This will result in no further notification emails being sent!
817
		// Calls Revision::getTimestampFromId in normal operation
818
		$notificationTimestamp = call_user_func(
819
			$this->revisionGetTimestampFromIdCallback,
820
			$title,
821
			$oldid
822
		);
823
824
		// We need to go one second to the future because of various strict comparisons
825
		// throughout the codebase
826
		$ts = new MWTimestamp( $notificationTimestamp );
827
		$ts->timestamp->add( new DateInterval( 'PT1S' ) );
828
		$notificationTimestamp = $ts->getTimestamp( TS_MW );
829
830
		if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
831
			if ( $force != 'force' ) {
832
				return false;
833
			} else {
834
				// This is a little silly…
835
				return $item->getNotificationTimestamp();
836
			}
837
		}
838
839
		return $notificationTimestamp;
840
	}
841
842
	/**
843
	 * @param User $user
844
	 * @param int $unreadLimit
845
	 *
846
	 * @return int|bool The number of unread notifications
847
	 *                  true if greater than or equal to $unreadLimit
848
	 */
849
	public function countUnreadNotifications( User $user, $unreadLimit = null ) {
850
		$queryOptions = [];
851
		if ( $unreadLimit !== null ) {
852
			$unreadLimit = (int)$unreadLimit;
853
			$queryOptions['LIMIT'] = $unreadLimit;
854
		}
855
856
		$dbr = $this->getConnection( DB_SLAVE );
857
		$rowCount = $dbr->selectRowCount(
858
			'watchlist',
859
			'1',
860
			[
861
				'wl_user' => $user->getId(),
862
				'wl_notificationtimestamp IS NOT NULL',
863
			],
864
			__METHOD__,
865
			$queryOptions
866
		);
867
		$this->reuseConnection( $dbr );
868
869
		if ( !isset( $unreadLimit ) ) {
870
			return $rowCount;
871
		}
872
873
		if ( $rowCount >= $unreadLimit ) {
874
			return true;
875
		}
876
877
		return $rowCount;
878
	}
879
880
	/**
881
	 * Check if the given title already is watched by the user, and if so
882
	 * add a watch for the new title.
883
	 *
884
	 * To be used for page renames and such.
885
	 *
886
	 * @param LinkTarget $oldTarget
887
	 * @param LinkTarget $newTarget
888
	 */
889
	public function duplicateAllAssociatedEntries( LinkTarget $oldTarget, LinkTarget $newTarget ) {
890
		if ( !$oldTarget instanceof Title ) {
891
			$oldTarget = Title::newFromLinkTarget( $oldTarget );
892
		}
893
		if ( !$newTarget instanceof Title ) {
894
			$newTarget = Title::newFromLinkTarget( $newTarget );
895
		}
896
897
		$this->duplicateEntry( $oldTarget->getSubjectPage(), $newTarget->getSubjectPage() );
898
		$this->duplicateEntry( $oldTarget->getTalkPage(), $newTarget->getTalkPage() );
899
	}
900
901
	/**
902
	 * Check if the given title already is watched by the user, and if so
903
	 * add a watch for the new title.
904
	 *
905
	 * To be used for page renames and such.
906
	 * This must be called separately for Subject and Talk pages
907
	 *
908
	 * @param LinkTarget $oldTarget
909
	 * @param LinkTarget $newTarget
910
	 */
911
	public function duplicateEntry( LinkTarget $oldTarget, LinkTarget $newTarget ) {
912
		$dbw = $this->getConnection( DB_MASTER );
913
914
		$result = $dbw->select(
915
			'watchlist',
916
			[ 'wl_user', 'wl_notificationtimestamp' ],
917
			[
918
				'wl_namespace' => $oldTarget->getNamespace(),
919
				'wl_title' => $oldTarget->getDBkey(),
920
			],
921
			__METHOD__,
922
			[ 'FOR UPDATE' ]
923
		);
924
925
		$newNamespace = $newTarget->getNamespace();
926
		$newDBkey = $newTarget->getDBkey();
927
928
		# Construct array to replace into the watchlist
929
		$values = [];
930
		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...
931
			$values[] = [
932
				'wl_user' => $row->wl_user,
933
				'wl_namespace' => $newNamespace,
934
				'wl_title' => $newDBkey,
935
				'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
936
			];
937
		}
938
939
		if ( !empty( $values ) ) {
940
			# Perform replace
941
			# Note that multi-row replace is very efficient for MySQL but may be inefficient for
942
			# some other DBMSes, mostly due to poor simulation by us
943
			$dbw->replace(
944
				'watchlist',
945
				[ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
946
				$values,
947
				__METHOD__
948
			);
949
		}
950
951
		$this->reuseConnection( $dbw );
952
	}
953
954
}
955