Completed
Branch master (8ef871)
by
unknown
29:40
created

WatchedItemStore::getNotificationTimestamp()   C

Complexity

Conditions 7
Paths 10

Size

Total Lines 46
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
dl 0
loc 46
rs 6.7272
c 1
b 0
f 0
cc 7
eloc 22
nc 10
nop 5
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 BagOStuff
22
	 */
23
	private $cache;
24
25
	/**
26
	 * @var callable|null
27
	 */
28
	private $deferredUpdatesAddCallableUpdateCallback;
29
30
	/**
31
	 * @var callable|null
32
	 */
33
	private $revisionGetTimestampFromIdCallback;
34
35
	/**
36
	 * @var self|null
37
	 */
38
	private static $instance;
39
40
	public function __construct( LoadBalancer $loadBalancer, BagOStuff $cache ) {
41
		$this->loadBalancer = $loadBalancer;
42
		$this->cache = $cache;
43
		$this->deferredUpdatesAddCallableUpdateCallback = [ 'DeferredUpdates', 'addCallableUpdate' ];
44
		$this->revisionGetTimestampFromIdCallback = [ 'Revision', 'getTimestampFromId' ];
45
	}
46
47
	/**
48
	 * Overrides the DeferredUpdates::addCallableUpdate callback
49
	 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
50
	 *
51
	 * @param callable $callback
52
	 * @see DeferredUpdates::addCallableUpdate for callback signiture
53
	 *
54
	 * @throws MWException
55
	 */
56 View Code Duplication
	public function overrideDeferredUpdatesAddCallableUpdateCallback( $callback ) {
57
		if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
58
			throw new MWException(
59
				'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
60
			);
61
		}
62
		Assert::parameterType( 'callable', $callback, '$callback' );
63
		$this->deferredUpdatesAddCallableUpdateCallback = $callback;
64
	}
65
66
	/**
67
	 * Overrides the Revision::getTimestampFromId callback
68
	 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
69
	 *
70
	 * @param callable $callback
71
	 * @see Revision::getTimestampFromId for callback signiture
72
	 *
73
	 * @throws MWException
74
	 */
75 View Code Duplication
	public function overrideRevisionGetTimestampFromIdCallback( $callback ) {
76
		if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
77
			throw new MWException(
78
				'Cannot override Revision::getTimestampFromId callback in operation.'
79
			);
80
		}
81
		Assert::parameterType( 'callable', $callback, '$callback' );
82
		$this->revisionGetTimestampFromIdCallback = $callback;
83
	}
84
85
	/**
86
	 * Overrides the default instance of this class
87
	 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
88
	 *
89
	 * @param WatchedItemStore $store
90
	 *
91
	 * @throws MWException
92
	 */
93
	public static function overrideDefaultInstance( WatchedItemStore $store ) {
94
		if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
95
			throw new MWException(
96
				'Cannot override ' . __CLASS__ . 'default instance in operation.'
97
			);
98
		}
99
		self::$instance = $store;
100
	}
101
102
	/**
103
	 * @return self
104
	 */
105
	public static function getDefaultInstance() {
106
		if ( !self::$instance ) {
107
			self::$instance = new self(
108
				wfGetLB(),
109
				new HashBagOStuff( [ 'maxKeys' => 100 ] )
110
			);
111
		}
112
		return self::$instance;
113
	}
114
115
	private function getCacheKey( User $user, LinkTarget $target ) {
116
		return $this->cache->makeKey(
117
			(string)$target->getNamespace(),
118
			$target->getDBkey(),
119
			(string)$user->getId()
120
		);
121
	}
122
123
	private function cache( WatchedItem $item ) {
124
		$this->cache->set(
125
			$this->getCacheKey( $item->getUser(), $item->getLinkTarget() ),
126
			$item
127
		);
128
	}
129
130
	private function uncache( User $user, LinkTarget $target ) {
131
		$this->cache->delete( $this->getCacheKey( $user, $target ) );
132
	}
133
134
	/**
135
	 * @param User $user
136
	 * @param LinkTarget $target
137
	 *
138
	 * @return WatchedItem|null
139
	 */
140
	private function getCached( User $user, LinkTarget $target ) {
141
		return $this->cache->get( $this->getCacheKey( $user, $target ) );
142
	}
143
144
	/**
145
	 * Return an array of conditions to select or update the appropriate database
146
	 * row.
147
	 *
148
	 * @param User $user
149
	 * @param LinkTarget $target
150
	 *
151
	 * @return array
152
	 */
153
	private function dbCond( User $user, LinkTarget $target ) {
154
		return [
155
			'wl_user' => $user->getId(),
156
			'wl_namespace' => $target->getNamespace(),
157
			'wl_title' => $target->getDBkey(),
158
		];
159
	}
160
161
	/**
162
	 * Get an item (may be cached)
163
	 *
164
	 * @param User $user
165
	 * @param LinkTarget $target
166
	 *
167
	 * @return WatchedItem|false
168
	 */
169
	public function getWatchedItem( User $user, LinkTarget $target ) {
170
		$cached = $this->getCached( $user, $target );
171
		if ( $cached ) {
172
			return $cached;
173
		}
174
		return $this->loadWatchedItem( $user, $target );
175
	}
176
177
	/**
178
	 * Loads an item from the db
179
	 *
180
	 * @param User $user
181
	 * @param LinkTarget $target
182
	 *
183
	 * @return WatchedItem|false
184
	 */
185
	public function loadWatchedItem( User $user, LinkTarget $target ) {
186
		// Only loggedin user can have a watchlist
187
		if ( $user->isAnon() ) {
188
			return false;
189
		}
190
191
		$dbr = $this->loadBalancer->getConnection( DB_SLAVE, [ 'watchlist' ] );
192
		$row = $dbr->selectRow(
193
			'watchlist',
194
			'wl_notificationtimestamp',
195
			$this->dbCond( $user, $target ),
196
			__METHOD__
197
		);
198
		$this->loadBalancer->reuseConnection( $dbr );
199
200
		if ( !$row ) {
201
			return false;
202
		}
203
204
		$item = new WatchedItem(
205
			$user,
206
			$target,
207
			$row->wl_notificationtimestamp
208
		);
209
		$this->cache( $item );
210
211
		return $item;
212
	}
213
214
	/**
215
	 * Must be called separately for Subject & Talk namespaces
216
	 *
217
	 * @param User $user
218
	 * @param LinkTarget $target
219
	 *
220
	 * @return bool
221
	 */
222
	public function isWatched( User $user, LinkTarget $target ) {
223
		return (bool)$this->getWatchedItem( $user, $target );
224
	}
225
226
	/**
227
	 * Must be called separately for Subject & Talk namespaces
228
	 *
229
	 * @param User $user
230
	 * @param LinkTarget $target
231
	 */
232
	public function addWatch( User $user, LinkTarget $target ) {
233
		$this->addWatchBatch( [ [ $user, $target ] ] );
234
	}
235
236
	/**
237
	 * @param array[] $userTargetCombinations array of arrays containing [0] => User [1] => LinkTarget
238
	 *
239
	 * @return bool success
240
	 */
241
	public function addWatchBatch( array $userTargetCombinations ) {
242
		if ( $this->loadBalancer->getReadOnlyReason() !== false ) {
243
			return false;
244
		}
245
246
		$rows = [];
247
		foreach ( $userTargetCombinations as list( $user, $target ) ) {
248
			/**
249
			 * @var User $user
250
			 * @var LinkTarget $target
251
			 */
252
253
			// Only loggedin user can have a watchlist
254
			if ( $user->isAnon() ) {
255
				continue;
256
			}
257
			$rows[] = [
258
				'wl_user' => $user->getId(),
259
				'wl_namespace' => $target->getNamespace(),
260
				'wl_title' => $target->getDBkey(),
261
				'wl_notificationtimestamp' => null,
262
			];
263
			$this->uncache( $user, $target );
264
		}
265
266
		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...
267
			return false;
268
		}
269
270
		$dbw = $this->loadBalancer->getConnection( DB_MASTER, [ 'watchlist' ] );
271
		foreach ( array_chunk( $rows, 100 ) as $toInsert ) {
272
			// Use INSERT IGNORE to avoid overwriting the notification timestamp
273
			// if there's already an entry for this page
274
			$dbw->insert( 'watchlist', $toInsert, __METHOD__, 'IGNORE' );
275
		}
276
		$this->loadBalancer->reuseConnection( $dbw );
277
278
		return true;
279
	}
280
281
	/**
282
	 * Removes the an entry for the User watching the LinkTarget
283
	 * Must be called separately for Subject & Talk namespaces
284
	 *
285
	 * @param User $user
286
	 * @param LinkTarget $target
287
	 *
288
	 * @return bool success
289
	 * @throws DBUnexpectedError
290
	 * @throws MWException
291
	 */
292
	public function removeWatch( User $user, LinkTarget $target ) {
293
		// Only logged in user can have a watchlist
294
		if ( $this->loadBalancer->getReadOnlyReason() !== false || $user->isAnon() ) {
295
			return false;
296
		}
297
298
		$this->uncache( $user, $target );
299
300
		$dbw = $this->loadBalancer->getConnection( DB_MASTER, [ 'watchlist' ] );
301
		$dbw->delete( 'watchlist',
302
			[
303
				'wl_user' => $user->getId(),
304
				'wl_namespace' => $target->getNamespace(),
305
				'wl_title' => $target->getDBkey(),
306
			], __METHOD__
307
		);
308
		$success = (bool)$dbw->affectedRows();
309
		$this->loadBalancer->reuseConnection( $dbw );
310
311
		return $success;
312
	}
313
314
	/**
315
	 * @param User $editor The editor that triggered the update. Their notification
316
	 *  timestamp will not be updated(they have already seen it)
317
	 * @param LinkTarget $target The target to update timestamps for
318
	 * @param string $timestamp Set the update timestamp to this value
319
	 *
320
	 * @return int[] Array of user IDs the timestamp has been updated for
321
	 */
322
	public function updateNotificationTimestamp( User $editor, LinkTarget $target, $timestamp ) {
323
		$dbw = $this->loadBalancer->getConnection( DB_MASTER, [ 'watchlist' ] );
324
		$res = $dbw->select( [ 'watchlist' ],
325
			[ 'wl_user' ],
326
			[
327
				'wl_user != ' . intval( $editor->getId() ),
328
				'wl_namespace' => $target->getNamespace(),
329
				'wl_title' => $target->getDBkey(),
330
				'wl_notificationtimestamp IS NULL',
331
			], __METHOD__
332
		);
333
334
		$watchers = [];
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
			$watchers[] = intval( $row->wl_user );
337
		}
338
339
		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...
340
			// Update wl_notificationtimestamp for all watching users except the editor
341
			$fname = __METHOD__;
342
			$dbw->onTransactionIdle(
343
				function () use ( $dbw, $timestamp, $watchers, $target, $fname ) {
344
					$dbw->update( 'watchlist',
345
						[ /* SET */
346
							'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
347
						], [ /* WHERE */
348
							'wl_user' => $watchers,
349
							'wl_namespace' => $target->getNamespace(),
350
							'wl_title' => $target->getDBkey(),
351
						], $fname
352
					);
353
				}
354
			);
355
		}
356
357
		$this->loadBalancer->reuseConnection( $dbw );
358
359
		return $watchers;
360
	}
361
362
	/**
363
	 * Reset the notification timestamp of this entry
364
	 *
365
	 * @param User $user
366
	 * @param Title $title
367
	 * @param string $force Whether to force the write query to be executed even if the
368
	 *    page is not watched or the notification timestamp is already NULL.
369
	 *    'force' in order to force
370
	 * @param int $oldid The revision id being viewed. If not given or 0, latest revision is assumed.
371
	 *
372
	 * @return bool success
373
	 */
374
	public function resetNotificationTimestamp( User $user, Title $title, $force = '', $oldid = 0 ) {
375
		// Only loggedin user can have a watchlist
376
		if ( $this->loadBalancer->getReadOnlyReason() !== false || $user->isAnon() ) {
377
			return false;
378
		}
379
380
		$item = null;
381
		if ( $force != 'force' ) {
382
			$item = $this->loadWatchedItem( $user, $title );
383
			if ( !$item || $item->getNotificationTimestamp() === null ) {
384
				return false;
385
			}
386
		}
387
388
		// If the page is watched by the user (or may be watched), update the timestamp
389
		$job = new ActivityUpdateJob(
390
			$title,
391
			[
392
				'type'      => 'updateWatchlistNotification',
393
				'userid'    => $user->getId(),
394
				'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
395
				'curTime'   => time()
396
			]
397
		);
398
399
		// Try to run this post-send
400
		// Calls DeferredUpdates::addCallableUpdate in normal operation
401
		call_user_func(
402
			$this->deferredUpdatesAddCallableUpdateCallback,
403
			function() use ( $job ) {
404
				$job->run();
405
			}
406
		);
407
408
		$this->uncache( $user, $title );
409
410
		return true;
411
	}
412
413
	private function getNotificationTimestamp( User $user, Title $title, $item, $force, $oldid ) {
414
		if ( !$oldid ) {
415
			// No oldid given, assuming latest revision; clear the timestamp.
416
			return null;
417
		}
418
419
		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...
420
			// Oldid given and is the latest revision for this title; clear the timestamp.
421
			return null;
422
		}
423
424
		if ( $item === null ) {
425
			$item = $this->loadWatchedItem( $user, $title );
426
		}
427
428
		if ( !$item ) {
429
			// This can only happen if $force is enabled.
430
			return null;
431
		}
432
433
		// Oldid given and isn't the latest; update the timestamp.
434
		// This will result in no further notification emails being sent!
435
		// Calls Revision::getTimestampFromId in normal operation
436
		$notificationTimestamp = call_user_func(
437
			$this->revisionGetTimestampFromIdCallback,
438
			$title,
439
			$oldid
440
		);
441
442
		// We need to go one second to the future because of various strict comparisons
443
		// throughout the codebase
444
		$ts = new MWTimestamp( $notificationTimestamp );
445
		$ts->timestamp->add( new DateInterval( 'PT1S' ) );
446
		$notificationTimestamp = $ts->getTimestamp( TS_MW );
447
448
		if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
449
			if ( $force != 'force' ) {
450
				return false;
451
			} else {
452
				// This is a little silly…
453
				return $item->getNotificationTimestamp();
454
			}
455
		}
456
457
		return $notificationTimestamp;
458
	}
459
460
	/**
461
	 * Check if the given title already is watched by the user, and if so
462
	 * add a watch for the new title.
463
	 *
464
	 * To be used for page renames and such.
465
	 *
466
	 * @param LinkTarget $oldTarget
467
	 * @param LinkTarget $newTarget
468
	 */
469
	public function duplicateAllAssociatedEntries( LinkTarget $oldTarget, LinkTarget $newTarget ) {
470
		if ( !$oldTarget instanceof Title ) {
471
			$oldTarget = Title::newFromLinkTarget( $oldTarget );
472
		}
473
		if ( !$newTarget instanceof Title ) {
474
			$newTarget = Title::newFromLinkTarget( $newTarget );
475
		}
476
477
		$this->duplicateEntry( $oldTarget->getSubjectPage(), $newTarget->getSubjectPage() );
478
		$this->duplicateEntry( $oldTarget->getTalkPage(), $newTarget->getTalkPage() );
479
	}
480
481
	/**
482
	 * Check if the given title already is watched by the user, and if so
483
	 * add a watch for the new title.
484
	 *
485
	 * To be used for page renames and such.
486
	 * This must be called separately for Subject and Talk pages
487
	 *
488
	 * @param LinkTarget $oldTarget
489
	 * @param LinkTarget $newTarget
490
	 */
491
	public function duplicateEntry( LinkTarget $oldTarget, LinkTarget $newTarget ) {
492
		$dbw = $this->loadBalancer->getConnection( DB_MASTER, [ 'watchlist' ] );
493
494
		$result = $dbw->select(
495
			'watchlist',
496
			[ 'wl_user', 'wl_notificationtimestamp' ],
497
			[
498
				'wl_namespace' => $oldTarget->getNamespace(),
499
				'wl_title' => $oldTarget->getDBkey(),
500
			],
501
			__METHOD__,
502
			[ 'FOR UPDATE' ]
503
		);
504
505
		$newNamespace = $newTarget->getNamespace();
506
		$newDBkey = $newTarget->getDBkey();
507
508
		# Construct array to replace into the watchlist
509
		$values = [];
510
		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...
511
			$values[] = [
512
				'wl_user' => $row->wl_user,
513
				'wl_namespace' => $newNamespace,
514
				'wl_title' => $newDBkey,
515
				'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
516
			];
517
		}
518
519
		if ( !empty( $values ) ) {
520
			# Perform replace
521
			# Note that multi-row replace is very efficient for MySQL but may be inefficient for
522
			# some other DBMSes, mostly due to poor simulation by us
523
			$dbw->replace(
524
				'watchlist',
525
				[ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
526
				$values,
527
				__METHOD__
528
			);
529
		}
530
531
		$this->loadBalancer->reuseConnection( $dbw );
532
	}
533
534
}
535