Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
95.51% |
766 / 802 |
|
74.55% |
41 / 55 |
CRAP | |
0.00% |
0 / 1 |
WatchedItemStore | |
95.51% |
766 / 802 |
|
74.55% |
41 / 55 |
192 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
1 | |||
setStatsdDataFactory | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
overrideDeferredUpdatesAddCallableUpdateCallback | |
66.67% |
6 / 9 |
|
0.00% |
0 / 1 |
2.15 | |||
getCacheKey | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
cache | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
uncache | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
uncacheLinkTarget | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
uncacheUser | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
getCached | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
modifyQueryBuilderForExpiry | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
clearUserWatchedItems | |
100.00% |
27 / 27 |
|
100.00% |
1 / 1 |
4 | |||
mustClearWatchedItemsUsingJobQueue | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
uncacheAllItemsForUser | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
8 | |||
clearUserWatchedItemsUsingJobQueue | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
maybeEnqueueWatchlistExpiryJob | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
getMaxId | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
countWatchedItems | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
countWatchers | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
countVisitingWatchers | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
1 | |||
removeWatchBatchForUser | |
95.00% |
38 / 40 |
|
0.00% |
0 / 1 |
10 | |||
countWatchersMultiple | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
5 | |||
countVisitingWatchersMultiple | |
94.74% |
18 / 19 |
|
0.00% |
0 / 1 |
5.00 | |||
getVisitingWatchersCondition | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
5 | |||
getWatchedItem | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
loadWatchedItem | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
loadWatchedItemsBatch | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
4 | |||
getWatchedItemsForUser | |
100.00% |
31 / 31 |
|
100.00% |
1 / 1 |
7 | |||
getWatchedItemFromRow | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
fetchWatchedItems | |
80.65% |
25 / 31 |
|
0.00% |
0 / 1 |
8.46 | |||
isWatched | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isTempWatched | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
getNotificationTimestampsBatch | |
100.00% |
31 / 31 |
|
100.00% |
1 / 1 |
7 | |||
addWatch | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
3 | |||
addWatchBatchForUser | |
96.77% |
30 / 31 |
|
0.00% |
0 / 1 |
9 | |||
updateOrDeleteExpiries | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
4 | |||
updateExpiries | |
95.65% |
22 / 23 |
|
0.00% |
0 / 1 |
3 | |||
removeWatch | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setNotificationTimestampsForUser | |
94.59% |
35 / 37 |
|
0.00% |
0 / 1 |
9.01 | |||
getLatestNotificationTimestamp | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
5 | |||
resetAllNotificationTimestampsForUser | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
2.00 | |||
updateNotificationTimestamp | |
97.62% |
41 / 42 |
|
0.00% |
0 / 1 |
5 | |||
resetNotificationTimestamp | |
90.20% |
46 / 51 |
|
0.00% |
0 / 1 |
13.16 | |||
getPageSeenTimestamps | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
3 | |||
getPageSeenTimestampsKey | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
getPageSeenKey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getNotificationTimestamp | |
95.24% |
20 / 21 |
|
0.00% |
0 / 1 |
8 | |||
countUnreadNotifications | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
4 | |||
duplicateAllAssociatedEntries | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
duplicateEntry | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
6 | |||
fetchWatchedItemsForPage | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
2 | |||
updateExpiriesAfterMove | |
100.00% |
30 / 30 |
|
100.00% |
1 / 1 |
4 | |||
getTitleDbKeysGroupedByNamespace | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
uncacheTitlesForUser | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
countExpired | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
removeExpired | |
100.00% |
36 / 36 |
|
100.00% |
1 / 1 |
4 |
1 | <?php |
2 | |
3 | use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface; |
4 | use MediaWiki\Cache\LinkBatchFactory; |
5 | use MediaWiki\Config\ServiceOptions; |
6 | use MediaWiki\Deferred\DeferredUpdates; |
7 | use MediaWiki\Linker\LinkTarget; |
8 | use MediaWiki\MainConfigNames; |
9 | use MediaWiki\Page\PageIdentity; |
10 | use MediaWiki\Revision\RevisionLookup; |
11 | use MediaWiki\Title\NamespaceInfo; |
12 | use MediaWiki\Title\TitleValue; |
13 | use MediaWiki\User\UserIdentity; |
14 | use MediaWiki\Utils\MWTimestamp; |
15 | use Wikimedia\Assert\Assert; |
16 | use Wikimedia\ParamValidator\TypeDef\ExpiryDef; |
17 | use Wikimedia\Rdbms\IDatabase; |
18 | use Wikimedia\Rdbms\ILBFactory; |
19 | use Wikimedia\Rdbms\IReadableDatabase; |
20 | use Wikimedia\Rdbms\IResultWrapper; |
21 | use Wikimedia\Rdbms\ReadOnlyMode; |
22 | use Wikimedia\Rdbms\SelectQueryBuilder; |
23 | use Wikimedia\ScopedCallback; |
24 | |
25 | /** |
26 | * Storage layer class for WatchedItems. |
27 | * Database interaction & caching |
28 | * TODO caching should be factored out into a CachingWatchedItemStore class |
29 | * |
30 | * @author Addshore |
31 | * @since 1.27 |
32 | */ |
33 | class WatchedItemStore implements WatchedItemStoreInterface, StatsdAwareInterface { |
34 | |
35 | /** |
36 | * @internal For use by ServiceWiring |
37 | */ |
38 | public const CONSTRUCTOR_OPTIONS = [ |
39 | MainConfigNames::UpdateRowsPerQuery, |
40 | MainConfigNames::WatchlistExpiry, |
41 | MainConfigNames::WatchlistExpiryMaxDuration, |
42 | MainConfigNames::WatchlistPurgeRate, |
43 | ]; |
44 | |
45 | /** |
46 | * @var ILBFactory |
47 | */ |
48 | private $lbFactory; |
49 | |
50 | /** |
51 | * @var JobQueueGroup |
52 | */ |
53 | private $queueGroup; |
54 | |
55 | /** |
56 | * @var BagOStuff |
57 | */ |
58 | private $stash; |
59 | |
60 | /** |
61 | * @var ReadOnlyMode |
62 | */ |
63 | private $readOnlyMode; |
64 | |
65 | /** |
66 | * @var HashBagOStuff |
67 | */ |
68 | private $cache; |
69 | |
70 | /** |
71 | * @var HashBagOStuff |
72 | */ |
73 | private $latestUpdateCache; |
74 | |
75 | /** |
76 | * @var array[][] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key' |
77 | * The index is needed so that on mass changes all relevant items can be un-cached. |
78 | * For example: Clearing a users watchlist of all items or updating notification timestamps |
79 | * for all users watching a single target. |
80 | * @phan-var array<int,array<string,array<int,string>>> |
81 | */ |
82 | private $cacheIndex = []; |
83 | |
84 | /** |
85 | * @var callable|null |
86 | */ |
87 | private $deferredUpdatesAddCallableUpdateCallback; |
88 | |
89 | /** |
90 | * @var int |
91 | */ |
92 | private $updateRowsPerQuery; |
93 | |
94 | /** |
95 | * @var NamespaceInfo |
96 | */ |
97 | private $nsInfo; |
98 | |
99 | /** |
100 | * @var RevisionLookup |
101 | */ |
102 | private $revisionLookup; |
103 | |
104 | /** |
105 | * @var StatsdDataFactoryInterface |
106 | */ |
107 | private $stats; |
108 | |
109 | /** |
110 | * @var bool Correlates to $wgWatchlistExpiry feature flag. |
111 | */ |
112 | private $expiryEnabled; |
113 | |
114 | /** |
115 | * @var LinkBatchFactory |
116 | */ |
117 | private $linkBatchFactory; |
118 | |
119 | /** |
120 | * @var string|null Maximum configured relative expiry. |
121 | */ |
122 | private $maxExpiryDuration; |
123 | |
124 | /** @var float corresponds to $wgWatchlistPurgeRate value */ |
125 | private $watchlistPurgeRate; |
126 | |
127 | /** |
128 | * @param ServiceOptions $options |
129 | * @param ILBFactory $lbFactory |
130 | * @param JobQueueGroup $queueGroup |
131 | * @param BagOStuff $stash |
132 | * @param HashBagOStuff $cache |
133 | * @param ReadOnlyMode $readOnlyMode |
134 | * @param NamespaceInfo $nsInfo |
135 | * @param RevisionLookup $revisionLookup |
136 | * @param LinkBatchFactory $linkBatchFactory |
137 | */ |
138 | public function __construct( |
139 | ServiceOptions $options, |
140 | ILBFactory $lbFactory, |
141 | JobQueueGroup $queueGroup, |
142 | BagOStuff $stash, |
143 | HashBagOStuff $cache, |
144 | ReadOnlyMode $readOnlyMode, |
145 | NamespaceInfo $nsInfo, |
146 | RevisionLookup $revisionLookup, |
147 | LinkBatchFactory $linkBatchFactory |
148 | ) { |
149 | $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); |
150 | $this->updateRowsPerQuery = $options->get( MainConfigNames::UpdateRowsPerQuery ); |
151 | $this->expiryEnabled = $options->get( MainConfigNames::WatchlistExpiry ); |
152 | $this->maxExpiryDuration = $options->get( MainConfigNames::WatchlistExpiryMaxDuration ); |
153 | $this->watchlistPurgeRate = $options->get( MainConfigNames::WatchlistPurgeRate ); |
154 | |
155 | $this->lbFactory = $lbFactory; |
156 | $this->queueGroup = $queueGroup; |
157 | $this->stash = $stash; |
158 | $this->cache = $cache; |
159 | $this->readOnlyMode = $readOnlyMode; |
160 | $this->stats = new NullStatsdDataFactory(); |
161 | $this->deferredUpdatesAddCallableUpdateCallback = |
162 | [ DeferredUpdates::class, 'addCallableUpdate' ]; |
163 | $this->nsInfo = $nsInfo; |
164 | $this->revisionLookup = $revisionLookup; |
165 | $this->linkBatchFactory = $linkBatchFactory; |
166 | |
167 | $this->latestUpdateCache = new HashBagOStuff( [ 'maxKeys' => 3 ] ); |
168 | } |
169 | |
170 | /** |
171 | * @param StatsdDataFactoryInterface $stats |
172 | */ |
173 | public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) { |
174 | $this->stats = $stats; |
175 | } |
176 | |
177 | /** |
178 | * Overrides the DeferredUpdates::addCallableUpdate callback |
179 | * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined. |
180 | * |
181 | * @param callable $callback |
182 | * |
183 | * @see DeferredUpdates::addCallableUpdate for callback signiture |
184 | * |
185 | * @return ScopedCallback to reset the overridden value |
186 | */ |
187 | public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) { |
188 | if ( !defined( 'MW_PHPUNIT_TEST' ) ) { |
189 | throw new LogicException( |
190 | 'Cannot override DeferredUpdates::addCallableUpdate callback in operation.' |
191 | ); |
192 | } |
193 | $previousValue = $this->deferredUpdatesAddCallableUpdateCallback; |
194 | $this->deferredUpdatesAddCallableUpdateCallback = $callback; |
195 | return new ScopedCallback( function () use ( $previousValue ) { |
196 | $this->deferredUpdatesAddCallableUpdateCallback = $previousValue; |
197 | } ); |
198 | } |
199 | |
200 | /** |
201 | * @param UserIdentity $user |
202 | * @param LinkTarget|PageIdentity $target |
203 | * @return string |
204 | */ |
205 | private function getCacheKey( UserIdentity $user, $target ): string { |
206 | return $this->cache->makeKey( |
207 | (string)$target->getNamespace(), |
208 | $target->getDBkey(), |
209 | (string)$user->getId() |
210 | ); |
211 | } |
212 | |
213 | /** |
214 | * @param WatchedItem $item |
215 | */ |
216 | private function cache( WatchedItem $item ) { |
217 | $user = $item->getUserIdentity(); |
218 | $target = $item->getTarget(); |
219 | $key = $this->getCacheKey( $user, $target ); |
220 | $this->cache->set( $key, $item ); |
221 | $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key; |
222 | $this->stats->increment( 'WatchedItemStore.cache' ); |
223 | } |
224 | |
225 | /** |
226 | * @param UserIdentity $user |
227 | * @param LinkTarget|PageIdentity $target |
228 | */ |
229 | private function uncache( UserIdentity $user, $target ) { |
230 | $this->cache->delete( $this->getCacheKey( $user, $target ) ); |
231 | unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] ); |
232 | $this->stats->increment( 'WatchedItemStore.uncache' ); |
233 | } |
234 | |
235 | /** |
236 | * @param LinkTarget|PageIdentity $target |
237 | */ |
238 | private function uncacheLinkTarget( $target ) { |
239 | $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' ); |
240 | if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) { |
241 | return; |
242 | } |
243 | foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) { |
244 | $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' ); |
245 | $this->cache->delete( $key ); |
246 | } |
247 | } |
248 | |
249 | /** |
250 | * @param UserIdentity $user |
251 | */ |
252 | private function uncacheUser( UserIdentity $user ) { |
253 | $this->stats->increment( 'WatchedItemStore.uncacheUser' ); |
254 | foreach ( $this->cacheIndex as $dbKeyArray ) { |
255 | foreach ( $dbKeyArray as $userArray ) { |
256 | if ( isset( $userArray[$user->getId()] ) ) { |
257 | $this->stats->increment( 'WatchedItemStore.uncacheUser.items' ); |
258 | $this->cache->delete( $userArray[$user->getId()] ); |
259 | } |
260 | } |
261 | } |
262 | |
263 | $pageSeenKey = $this->getPageSeenTimestampsKey( $user ); |
264 | $this->latestUpdateCache->delete( $pageSeenKey ); |
265 | $this->stash->delete( $pageSeenKey ); |
266 | } |
267 | |
268 | /** |
269 | * @param UserIdentity $user |
270 | * @param LinkTarget|PageIdentity $target |
271 | * |
272 | * @return WatchedItem|false |
273 | */ |
274 | private function getCached( UserIdentity $user, $target ) { |
275 | return $this->cache->get( $this->getCacheKey( $user, $target ) ); |
276 | } |
277 | |
278 | /** |
279 | * Helper method to deduplicate logic around queries that need to be modified |
280 | * if watchlist expiration is enabled |
281 | * |
282 | * @param SelectQueryBuilder $queryBuilder |
283 | * @param IReadableDatabase $db |
284 | */ |
285 | private function modifyQueryBuilderForExpiry( |
286 | SelectQueryBuilder $queryBuilder, |
287 | IReadableDatabase $db |
288 | ) { |
289 | if ( $this->expiryEnabled ) { |
290 | $queryBuilder->where( 'we_expiry IS NULL OR we_expiry > ' . $db->addQuotes( $db->timestamp() ) ); |
291 | $queryBuilder->leftJoin( 'watchlist_expiry', null, 'wl_id = we_item' ); |
292 | } |
293 | } |
294 | |
295 | /** |
296 | * Deletes ALL watched items for the given user when under |
297 | * $updateRowsPerQuery entries exist. |
298 | * |
299 | * @since 1.30 |
300 | * |
301 | * @param UserIdentity $user |
302 | * |
303 | * @return bool true on success, false when too many items are watched |
304 | */ |
305 | public function clearUserWatchedItems( UserIdentity $user ): bool { |
306 | if ( $this->mustClearWatchedItemsUsingJobQueue( $user ) ) { |
307 | return false; |
308 | } |
309 | |
310 | $dbw = $this->lbFactory->getPrimaryDatabase(); |
311 | |
312 | if ( $this->expiryEnabled ) { |
313 | $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ); |
314 | // First fetch the wl_ids. |
315 | $wlIds = $dbw->newSelectQueryBuilder() |
316 | ->select( 'wl_id' ) |
317 | ->from( 'watchlist' ) |
318 | ->where( [ 'wl_user' => $user->getId() ] ) |
319 | ->caller( __METHOD__ ) |
320 | ->fetchFieldValues(); |
321 | if ( $wlIds ) { |
322 | // Delete rows from both the watchlist and watchlist_expiry tables. |
323 | $dbw->newDeleteQueryBuilder() |
324 | ->deleteFrom( 'watchlist' ) |
325 | ->where( [ 'wl_id' => $wlIds ] ) |
326 | ->caller( __METHOD__ )->execute(); |
327 | |
328 | $dbw->newDeleteQueryBuilder() |
329 | ->deleteFrom( 'watchlist_expiry' ) |
330 | ->where( [ 'we_item' => $wlIds ] ) |
331 | ->caller( __METHOD__ )->execute(); |
332 | } |
333 | $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket ); |
334 | } else { |
335 | $dbw->newDeleteQueryBuilder() |
336 | ->deleteFrom( 'watchlist' ) |
337 | ->where( [ 'wl_user' => $user->getId() ] ) |
338 | ->caller( __METHOD__ )->execute(); |
339 | } |
340 | |
341 | $this->uncacheAllItemsForUser( $user ); |
342 | |
343 | return true; |
344 | } |
345 | |
346 | /** |
347 | * @param UserIdentity $user |
348 | * @return bool |
349 | */ |
350 | public function mustClearWatchedItemsUsingJobQueue( UserIdentity $user ): bool { |
351 | return $this->countWatchedItems( $user ) > $this->updateRowsPerQuery; |
352 | } |
353 | |
354 | /** |
355 | * @param UserIdentity $user |
356 | */ |
357 | private function uncacheAllItemsForUser( UserIdentity $user ) { |
358 | $userId = $user->getId(); |
359 | foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) { |
360 | foreach ( $dbKeyIndex as $dbKey => $userIndex ) { |
361 | if ( array_key_exists( $userId, $userIndex ) ) { |
362 | $this->cache->delete( $userIndex[$userId] ); |
363 | unset( $this->cacheIndex[$ns][$dbKey][$userId] ); |
364 | } |
365 | } |
366 | } |
367 | |
368 | // Cleanup empty cache keys |
369 | foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) { |
370 | foreach ( $dbKeyIndex as $dbKey => $userIndex ) { |
371 | if ( empty( $this->cacheIndex[$ns][$dbKey] ) ) { |
372 | unset( $this->cacheIndex[$ns][$dbKey] ); |
373 | } |
374 | } |
375 | if ( empty( $this->cacheIndex[$ns] ) ) { |
376 | unset( $this->cacheIndex[$ns] ); |
377 | } |
378 | } |
379 | } |
380 | |
381 | /** |
382 | * Queues a job that will clear the users watchlist using the Job Queue. |
383 | * |
384 | * @since 1.31 |
385 | * |
386 | * @param UserIdentity $user |
387 | */ |
388 | public function clearUserWatchedItemsUsingJobQueue( UserIdentity $user ) { |
389 | $job = ClearUserWatchlistJob::newForUser( $user, $this->getMaxId() ); |
390 | $this->queueGroup->push( $job ); |
391 | } |
392 | |
393 | /** |
394 | * @inheritDoc |
395 | */ |
396 | public function maybeEnqueueWatchlistExpiryJob(): void { |
397 | if ( !$this->expiryEnabled ) { |
398 | // No need to purge expired entries if there are none |
399 | return; |
400 | } |
401 | |
402 | $max = mt_getrandmax(); |
403 | if ( mt_rand( 0, $max ) < $max * $this->watchlistPurgeRate ) { |
404 | // The higher the watchlist purge rate, the more likely we are to enqueue a job. |
405 | $this->queueGroup->lazyPush( new WatchlistExpiryJob() ); |
406 | } |
407 | } |
408 | |
409 | /** |
410 | * @since 1.31 |
411 | * @return int The maximum current wl_id |
412 | */ |
413 | public function getMaxId(): int { |
414 | return (int)$this->lbFactory->getReplicaDatabase()->newSelectQueryBuilder() |
415 | ->select( 'MAX(wl_id)' ) |
416 | ->from( 'watchlist' ) |
417 | ->caller( __METHOD__ ) |
418 | ->fetchField(); |
419 | } |
420 | |
421 | /** |
422 | * @since 1.31 |
423 | * @param UserIdentity $user |
424 | * @return int |
425 | */ |
426 | public function countWatchedItems( UserIdentity $user ): int { |
427 | $dbr = $this->lbFactory->getReplicaDatabase(); |
428 | $queryBuilder = $dbr->newSelectQueryBuilder() |
429 | ->select( 'COUNT(*)' ) |
430 | ->from( 'watchlist' ) |
431 | ->where( [ 'wl_user' => $user->getId() ] ) |
432 | ->caller( __METHOD__ ); |
433 | |
434 | $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr ); |
435 | |
436 | return (int)$queryBuilder->fetchField(); |
437 | } |
438 | |
439 | /** |
440 | * @since 1.27 |
441 | * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36 |
442 | * @return int |
443 | */ |
444 | public function countWatchers( $target ): int { |
445 | $dbr = $this->lbFactory->getReplicaDatabase(); |
446 | $queryBuilder = $dbr->newSelectQueryBuilder() |
447 | ->select( 'COUNT(*)' ) |
448 | ->from( 'watchlist' ) |
449 | ->where( [ |
450 | 'wl_namespace' => $target->getNamespace(), |
451 | 'wl_title' => $target->getDBkey() |
452 | ] ) |
453 | ->caller( __METHOD__ ); |
454 | |
455 | $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr ); |
456 | |
457 | return (int)$queryBuilder->fetchField(); |
458 | } |
459 | |
460 | /** |
461 | * @since 1.27 |
462 | * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36 |
463 | * @param string|int $threshold |
464 | * @return int |
465 | */ |
466 | public function countVisitingWatchers( $target, $threshold ): int { |
467 | $dbr = $this->lbFactory->getReplicaDatabase(); |
468 | $queryBuilder = $dbr->newSelectQueryBuilder() |
469 | ->select( 'COUNT(*)' ) |
470 | ->from( 'watchlist' ) |
471 | ->where( [ |
472 | 'wl_namespace' => $target->getNamespace(), |
473 | 'wl_title' => $target->getDBkey(), |
474 | 'wl_notificationtimestamp >= ' . |
475 | $dbr->addQuotes( $dbr->timestamp( $threshold ) ) . |
476 | ' OR wl_notificationtimestamp IS NULL' |
477 | ] ) |
478 | ->caller( __METHOD__ ); |
479 | |
480 | $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr ); |
481 | |
482 | return (int)$queryBuilder->fetchField(); |
483 | } |
484 | |
485 | /** |
486 | * @param UserIdentity $user |
487 | * @param LinkTarget[]|PageIdentity[] $titles deprecated passing LinkTarget[] since 1.36 |
488 | * @return bool |
489 | */ |
490 | public function removeWatchBatchForUser( UserIdentity $user, array $titles ): bool { |
491 | if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) { |
492 | return false; |
493 | } |
494 | if ( !$titles ) { |
495 | return true; |
496 | } |
497 | |
498 | $rows = $this->getTitleDbKeysGroupedByNamespace( $titles ); |
499 | $this->uncacheTitlesForUser( $user, $titles ); |
500 | |
501 | $dbw = $this->lbFactory->getPrimaryDatabase(); |
502 | $ticket = count( $titles ) > $this->updateRowsPerQuery ? |
503 | $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null; |
504 | $affectedRows = 0; |
505 | |
506 | // Batch delete items per namespace. |
507 | foreach ( $rows as $namespace => $namespaceTitles ) { |
508 | $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery ); |
509 | foreach ( $rowBatches as $toDelete ) { |
510 | // First fetch the wl_ids. |
511 | $wlIds = $dbw->newSelectQueryBuilder() |
512 | ->select( 'wl_id' ) |
513 | ->from( 'watchlist' ) |
514 | ->where( |
515 | [ |
516 | 'wl_user' => $user->getId(), |
517 | 'wl_namespace' => $namespace, |
518 | 'wl_title' => $toDelete |
519 | ] |
520 | ) |
521 | ->caller( __METHOD__ ) |
522 | ->fetchFieldValues(); |
523 | |
524 | if ( $wlIds ) { |
525 | // Delete rows from both the watchlist and watchlist_expiry tables. |
526 | $dbw->newDeleteQueryBuilder() |
527 | ->deleteFrom( 'watchlist' ) |
528 | ->where( [ 'wl_id' => $wlIds ] ) |
529 | ->caller( __METHOD__ )->execute(); |
530 | $affectedRows += $dbw->affectedRows(); |
531 | |
532 | if ( $this->expiryEnabled ) { |
533 | $dbw->newDeleteQueryBuilder() |
534 | ->deleteFrom( 'watchlist_expiry' ) |
535 | ->where( [ 'we_item' => $wlIds ] ) |
536 | ->caller( __METHOD__ )->execute(); |
537 | $affectedRows += $dbw->affectedRows(); |
538 | } |
539 | } |
540 | |
541 | if ( $ticket ) { |
542 | $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket ); |
543 | } |
544 | } |
545 | } |
546 | |
547 | return (bool)$affectedRows; |
548 | } |
549 | |
550 | /** |
551 | * @since 1.27 |
552 | * @param LinkTarget[]|PageIdentity[] $targets deprecated passing LinkTarget[] since 1.36 |
553 | * @param array $options Supported options are: |
554 | * - 'minimumWatchers': filter for pages that have at least a minimum number of watchers |
555 | * @return array |
556 | */ |
557 | public function countWatchersMultiple( array $targets, array $options = [] ): array { |
558 | $linkTargets = array_map( static function ( $target ) { |
559 | if ( !$target instanceof LinkTarget ) { |
560 | return new TitleValue( $target->getNamespace(), $target->getDBkey() ); |
561 | } |
562 | return $target; |
563 | }, $targets ); |
564 | $lb = $this->linkBatchFactory->newLinkBatch( $linkTargets ); |
565 | $dbr = $this->lbFactory->getReplicaDatabase(); |
566 | $queryBuilder = $dbr->newSelectQueryBuilder(); |
567 | $queryBuilder |
568 | ->select( [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ] ) |
569 | ->from( 'watchlist' ) |
570 | ->where( [ $lb->constructSet( 'wl', $dbr ) ] ) |
571 | ->groupBy( [ 'wl_namespace', 'wl_title' ] ) |
572 | ->caller( __METHOD__ ); |
573 | |
574 | if ( array_key_exists( 'minimumWatchers', $options ) ) { |
575 | $queryBuilder->having( 'COUNT(*) >= ' . (int)$options['minimumWatchers'] ); |
576 | } |
577 | |
578 | $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr ); |
579 | |
580 | $res = $queryBuilder->fetchResultSet(); |
581 | |
582 | $watchCounts = []; |
583 | foreach ( $targets as $linkTarget ) { |
584 | $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0; |
585 | } |
586 | |
587 | foreach ( $res as $row ) { |
588 | $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers; |
589 | } |
590 | |
591 | return $watchCounts; |
592 | } |
593 | |
594 | /** |
595 | * @since 1.27 |
596 | * @param array $targetsWithVisitThresholds array of LinkTarget[]|PageIdentity[] (not type |
597 | * hinted since it annoys phan) - deprecated passing LinkTarget[] since 1.36 |
598 | * @param int|null $minimumWatchers |
599 | * @return int[][] two dimensional array, first is namespace, second is database key, |
600 | * value is the number of watchers |
601 | */ |
602 | public function countVisitingWatchersMultiple( |
603 | array $targetsWithVisitThresholds, |
604 | $minimumWatchers = null |
605 | ): array { |
606 | if ( $targetsWithVisitThresholds === [] ) { |
607 | // No titles requested => no results returned |
608 | return []; |
609 | } |
610 | |
611 | $dbr = $this->lbFactory->getReplicaDatabase(); |
612 | $queryBuilder = $dbr->newSelectQueryBuilder() |
613 | ->select( [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ] ) |
614 | ->from( 'watchlist' ) |
615 | ->where( [ $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds ) ] ) |
616 | ->groupBy( [ 'wl_namespace', 'wl_title' ] ) |
617 | ->caller( __METHOD__ ); |
618 | if ( $minimumWatchers !== null ) { |
619 | $queryBuilder->having( 'COUNT(*) >= ' . (int)$minimumWatchers ); |
620 | } |
621 | $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr ); |
622 | |
623 | $res = $queryBuilder->fetchResultSet(); |
624 | |
625 | $watcherCounts = []; |
626 | foreach ( $targetsWithVisitThresholds as [ $target ] ) { |
627 | /** @var LinkTarget|PageIdentity $target */ |
628 | $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0; |
629 | } |
630 | |
631 | foreach ( $res as $row ) { |
632 | $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers; |
633 | } |
634 | |
635 | return $watcherCounts; |
636 | } |
637 | |
638 | /** |
639 | * Generates condition for the query used in a batch count visiting watchers. |
640 | * |
641 | * @param IReadableDatabase $db |
642 | * @param array $targetsWithVisitThresholds array of pairs (LinkTarget|PageIdentity, |
643 | * last visit threshold) - deprecated passing LinkTarget since 1.36 |
644 | * @return string |
645 | */ |
646 | private function getVisitingWatchersCondition( |
647 | IReadableDatabase $db, |
648 | array $targetsWithVisitThresholds |
649 | ): string { |
650 | $missingTargets = []; |
651 | $namespaceConds = []; |
652 | foreach ( $targetsWithVisitThresholds as [ $target, $threshold ] ) { |
653 | if ( $threshold === null ) { |
654 | $missingTargets[] = $target; |
655 | continue; |
656 | } |
657 | /** @var LinkTarget|PageIdentity $target */ |
658 | $namespaceConds[$target->getNamespace()][] = $db->makeList( [ |
659 | 'wl_title = ' . $db->addQuotes( $target->getDBkey() ), |
660 | $db->makeList( [ |
661 | 'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ), |
662 | 'wl_notificationtimestamp IS NULL' |
663 | ], LIST_OR ) |
664 | ], LIST_AND ); |
665 | } |
666 | |
667 | $conds = []; |
668 | foreach ( $namespaceConds as $namespace => $pageConds ) { |
669 | $conds[] = $db->makeList( [ |
670 | 'wl_namespace = ' . $namespace, |
671 | '(' . $db->makeList( $pageConds, LIST_OR ) . ')' |
672 | ], LIST_AND ); |
673 | } |
674 | |
675 | if ( $missingTargets ) { |
676 | $lb = $this->linkBatchFactory->newLinkBatch( $missingTargets ); |
677 | $conds[] = $lb->constructSet( 'wl', $db ); |
678 | } |
679 | |
680 | return $db->makeList( $conds, LIST_OR ); |
681 | } |
682 | |
683 | /** |
684 | * @since 1.27 |
685 | * @param UserIdentity $user |
686 | * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36 |
687 | * @return WatchedItem|false |
688 | */ |
689 | public function getWatchedItem( UserIdentity $user, $target ) { |
690 | if ( !$user->isRegistered() ) { |
691 | return false; |
692 | } |
693 | |
694 | $cached = $this->getCached( $user, $target ); |
695 | if ( $cached && !$cached->isExpired() ) { |
696 | $this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' ); |
697 | return $cached; |
698 | } |
699 | $this->stats->increment( 'WatchedItemStore.getWatchedItem.load' ); |
700 | return $this->loadWatchedItem( $user, $target ); |
701 | } |
702 | |
703 | /** |
704 | * @since 1.27 |
705 | * @param UserIdentity $user |
706 | * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36 |
707 | * @return WatchedItem|false |
708 | */ |
709 | public function loadWatchedItem( UserIdentity $user, $target ) { |
710 | $item = $this->loadWatchedItemsBatch( $user, [ $target ] ); |
711 | return $item ? $item[0] : false; |
712 | } |
713 | |
714 | /** |
715 | * @since 1.36 |
716 | * @param UserIdentity $user |
717 | * @param LinkTarget[]|PageIdentity[] $targets deprecated passing LinkTarget[] since 1.36 |
718 | * @return WatchedItem[]|false |
719 | */ |
720 | public function loadWatchedItemsBatch( UserIdentity $user, array $targets ) { |
721 | // Only registered user can have a watchlist |
722 | if ( !$user->isRegistered() ) { |
723 | return false; |
724 | } |
725 | |
726 | $dbr = $this->lbFactory->getReplicaDatabase(); |
727 | |
728 | $rows = $this->fetchWatchedItems( |
729 | $dbr, |
730 | $user, |
731 | [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], |
732 | [], |
733 | $targets |
734 | ); |
735 | |
736 | if ( !$rows ) { |
737 | return false; |
738 | } |
739 | |
740 | $items = []; |
741 | foreach ( $rows as $row ) { |
742 | // TODO: convert to PageIdentity |
743 | $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title ); |
744 | $item = $this->getWatchedItemFromRow( $user, $target, $row ); |
745 | $this->cache( $item ); |
746 | $items[] = $item; |
747 | } |
748 | |
749 | return $items; |
750 | } |
751 | |
752 | /** |
753 | * @since 1.27 |
754 | * @param UserIdentity $user |
755 | * @param array $options Supported options are: |
756 | * - 'forWrite': whether to use the primary database instead of a replica |
757 | * - 'sort': how to sort the titles, either SORT_ASC or SORT_DESC |
758 | * - 'sortByExpiry': whether to also sort results by expiration, with temporarily watched titles |
759 | * above titles watched indefinitely and titles expiring soonest at the top |
760 | * @return WatchedItem[] |
761 | */ |
762 | public function getWatchedItemsForUser( UserIdentity $user, array $options = [] ): array { |
763 | $options += [ 'forWrite' => false ]; |
764 | $vars = [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ]; |
765 | $orderBy = []; |
766 | if ( $options['forWrite'] ) { |
767 | $db = $this->lbFactory->getPrimaryDatabase(); |
768 | } else { |
769 | $db = $this->lbFactory->getReplicaDatabase(); |
770 | } |
771 | if ( array_key_exists( 'sort', $options ) ) { |
772 | Assert::parameter( |
773 | ( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ), |
774 | '$options[\'sort\']', |
775 | 'must be SORT_ASC or SORT_DESC' |
776 | ); |
777 | $orderBy[] = "wl_namespace {$options['sort']}"; |
778 | if ( $this->expiryEnabled |
779 | && array_key_exists( 'sortByExpiry', $options ) |
780 | && $options['sortByExpiry'] |
781 | ) { |
782 | // Add `wl_has_expiry` column to allow sorting by watched titles that have an expiration date first. |
783 | $vars['wl_has_expiry'] = $db->conditional( 'we_expiry IS NULL', '0', '1' ); |
784 | // Display temporarily watched titles first. |
785 | // Order by expiration date, with the titles that will expire soonest at the top. |
786 | $orderBy[] = "wl_has_expiry DESC"; |
787 | $orderBy[] = "we_expiry ASC"; |
788 | } |
789 | |
790 | $orderBy[] = "wl_title {$options['sort']}"; |
791 | } |
792 | |
793 | $res = $this->fetchWatchedItems( |
794 | $db, |
795 | $user, |
796 | $vars, |
797 | $orderBy |
798 | ); |
799 | |
800 | $watchedItems = []; |
801 | foreach ( $res as $row ) { |
802 | // TODO: convert to PageIdentity |
803 | $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title ); |
804 | // @todo: Should we add these to the process cache? |
805 | $watchedItems[] = $this->getWatchedItemFromRow( $user, $target, $row ); |
806 | } |
807 | |
808 | return $watchedItems; |
809 | } |
810 | |
811 | /** |
812 | * Construct a new WatchedItem given a row from watchlist/watchlist_expiry. |
813 | * @param UserIdentity $user |
814 | * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36 |
815 | * @param stdClass $row |
816 | * @return WatchedItem |
817 | */ |
818 | private function getWatchedItemFromRow( |
819 | UserIdentity $user, |
820 | $target, |
821 | stdClass $row |
822 | ): WatchedItem { |
823 | return new WatchedItem( |
824 | $user, |
825 | $target, |
826 | $this->getLatestNotificationTimestamp( |
827 | $row->wl_notificationtimestamp, $user, $target ), |
828 | wfTimestampOrNull( TS_ISO_8601, $row->we_expiry ?? null ) |
829 | ); |
830 | } |
831 | |
832 | /** |
833 | * Fetches either a single or all watched items for the given user, or a specific set of items. |
834 | * If a $target is given, IDatabase::selectRow() is called, otherwise select(). |
835 | * If $wgWatchlistExpiry is enabled, expired items are not returned. |
836 | * |
837 | * @param IReadableDatabase $db |
838 | * @param UserIdentity $user |
839 | * @param array $vars we_expiry is added when $wgWatchlistExpiry is enabled. |
840 | * @param array $orderBy array of columns |
841 | * @param LinkTarget|LinkTarget[]|PageIdentity|PageIdentity[]|null $target null if selecting all |
842 | * watched items - deprecated passing LinkTarget or LinkTarget[] since 1.36 |
843 | * @return IResultWrapper|stdClass|false |
844 | */ |
845 | private function fetchWatchedItems( |
846 | IReadableDatabase $db, |
847 | UserIdentity $user, |
848 | array $vars, |
849 | array $orderBy = [], |
850 | $target = null |
851 | ) { |
852 | $dbMethod = 'select'; |
853 | $queryBuilder = $db->newSelectQueryBuilder() |
854 | ->select( $vars ) |
855 | ->from( 'watchlist' ) |
856 | ->where( [ 'wl_user' => $user->getId() ] ) |
857 | ->caller( __METHOD__ ); |
858 | if ( $target ) { |
859 | if ( $target instanceof LinkTarget || $target instanceof PageIdentity ) { |
860 | $queryBuilder->where( [ |
861 | 'wl_namespace' => $target->getNamespace(), |
862 | 'wl_title' => $target->getDBkey(), |
863 | ] ); |
864 | $dbMethod = 'selectRow'; |
865 | } else { |
866 | $titleConds = []; |
867 | foreach ( $target as $linkTarget ) { |
868 | $titleConds[] = $db->makeList( |
869 | [ |
870 | 'wl_namespace' => $linkTarget->getNamespace(), |
871 | 'wl_title' => $linkTarget->getDBkey(), |
872 | ], |
873 | $db::LIST_AND |
874 | ); |
875 | } |
876 | $queryBuilder->where( $db->makeList( $titleConds, $db::LIST_OR ) ); |
877 | } |
878 | } |
879 | |
880 | $this->modifyQueryBuilderForExpiry( $queryBuilder, $db ); |
881 | if ( $this->expiryEnabled ) { |
882 | $queryBuilder->field( 'we_expiry' ); |
883 | } |
884 | if ( $orderBy ) { |
885 | $queryBuilder->orderBy( $orderBy ); |
886 | } |
887 | |
888 | if ( $dbMethod == 'selectRow' ) { |
889 | return $queryBuilder->fetchRow(); |
890 | } |
891 | return $queryBuilder->fetchResultSet(); |
892 | } |
893 | |
894 | /** |
895 | * @since 1.27 |
896 | * @param UserIdentity $user |
897 | * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36 |
898 | * @return bool |
899 | */ |
900 | public function isWatched( UserIdentity $user, $target ): bool { |
901 | return (bool)$this->getWatchedItem( $user, $target ); |
902 | } |
903 | |
904 | /** |
905 | * Check if the user is temporarily watching the page. |
906 | * @since 1.35 |
907 | * @param UserIdentity $user |
908 | * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36 |
909 | * @return bool |
910 | */ |
911 | public function isTempWatched( UserIdentity $user, $target ): bool { |
912 | $item = $this->getWatchedItem( $user, $target ); |
913 | return $item && $item->getExpiry(); |
914 | } |
915 | |
916 | /** |
917 | * @since 1.27 |
918 | * @param UserIdentity $user |
919 | * @param LinkTarget[] $targets |
920 | * @return (string|null|false)[][] two dimensional array, first is namespace, second is database key, |
921 | * value is the notification timestamp or null, or false if not available |
922 | */ |
923 | public function getNotificationTimestampsBatch( UserIdentity $user, array $targets ): array { |
924 | $timestamps = []; |
925 | foreach ( $targets as $target ) { |
926 | $timestamps[$target->getNamespace()][$target->getDBkey()] = false; |
927 | } |
928 | |
929 | if ( !$user->isRegistered() ) { |
930 | return $timestamps; |
931 | } |
932 | |
933 | $targetsToLoad = []; |
934 | foreach ( $targets as $target ) { |
935 | $cachedItem = $this->getCached( $user, $target ); |
936 | if ( $cachedItem ) { |
937 | $timestamps[$target->getNamespace()][$target->getDBkey()] = |
938 | $cachedItem->getNotificationTimestamp(); |
939 | } else { |
940 | $targetsToLoad[] = $target; |
941 | } |
942 | } |
943 | |
944 | if ( !$targetsToLoad ) { |
945 | return $timestamps; |
946 | } |
947 | |
948 | $dbr = $this->lbFactory->getReplicaDatabase(); |
949 | |
950 | $lb = $this->linkBatchFactory->newLinkBatch( $targetsToLoad ); |
951 | $res = $dbr->newSelectQueryBuilder() |
952 | ->select( [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ] ) |
953 | ->from( 'watchlist' ) |
954 | ->where( [ |
955 | $lb->constructSet( 'wl', $dbr ), |
956 | 'wl_user' => $user->getId(), |
957 | ] ) |
958 | ->caller( __METHOD__ ) |
959 | ->fetchResultSet(); |
960 | |
961 | foreach ( $res as $row ) { |
962 | // TODO: convert to PageIdentity |
963 | $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title ); |
964 | $timestamps[$row->wl_namespace][$row->wl_title] = |
965 | $this->getLatestNotificationTimestamp( |
966 | $row->wl_notificationtimestamp, $user, $target ); |
967 | } |
968 | |
969 | return $timestamps; |
970 | } |
971 | |
972 | /** |
973 | * @since 1.27 Method added. |
974 | * @since 1.35 Accepts $expiry parameter. |
975 | * @param UserIdentity $user |
976 | * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36 |
977 | * @param string|null $expiry Optional expiry in any format acceptable to wfTimestamp(). |
978 | * null will not create an expiry, or leave it unchanged should one already exist. |
979 | */ |
980 | public function addWatch( UserIdentity $user, $target, ?string $expiry = null ) { |
981 | $this->addWatchBatchForUser( $user, [ $target ], $expiry ); |
982 | |
983 | if ( $this->expiryEnabled && !$expiry ) { |
984 | // When re-watching a page with a null $expiry, any existing expiry is left unchanged. |
985 | // However we must re-fetch the preexisting expiry or else the cached WatchedItem will |
986 | // incorrectly have a null expiry. Note that loadWatchedItem() does the caching. |
987 | // See T259379 |
988 | $this->loadWatchedItem( $user, $target ); |
989 | } else { |
990 | // Create a new WatchedItem and add it to the process cache. |
991 | // In this case we don't need to re-fetch the expiry. |
992 | $expiry = ExpiryDef::normalizeUsingMaxExpiry( $expiry, $this->maxExpiryDuration, TS_ISO_8601 ); |
993 | $item = new WatchedItem( |
994 | $user, |
995 | $target, |
996 | null, |
997 | $expiry |
998 | ); |
999 | $this->cache( $item ); |
1000 | } |
1001 | } |
1002 | |
1003 | /** |
1004 | * Add multiple items to the user's watchlist. |
1005 | * If you know you're adding a single page (and/or its talk page) use self::addWatch(), |
1006 | * since it will add the WatchedItem to the process cache. |
1007 | * |
1008 | * @since 1.27 Method added. |
1009 | * @since 1.35 Accepts $expiry parameter. |
1010 | * @param UserIdentity $user |
1011 | * @param LinkTarget[] $targets |
1012 | * @param string|null $expiry Optional expiry in a format acceptable to wfTimestamp(), |
1013 | * null will not create expiries, or leave them unchanged should they already exist. |
1014 | * @return bool Whether database transactions were performed. |
1015 | */ |
1016 | public function addWatchBatchForUser( |
1017 | UserIdentity $user, |
1018 | array $targets, |
1019 | ?string $expiry = null |
1020 | ): bool { |
1021 | // Only registered user can have a watchlist |
1022 | if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) { |
1023 | return false; |
1024 | } |
1025 | |
1026 | if ( !$targets ) { |
1027 | return true; |
1028 | } |
1029 | $expiry = ExpiryDef::normalizeUsingMaxExpiry( $expiry, $this->maxExpiryDuration, TS_ISO_8601 ); |
1030 | $rows = []; |
1031 | foreach ( $targets as $target ) { |
1032 | $rows[] = [ |
1033 | 'wl_user' => $user->getId(), |
1034 | 'wl_namespace' => $target->getNamespace(), |
1035 | 'wl_title' => $target->getDBkey(), |
1036 | 'wl_notificationtimestamp' => null, |
1037 | ]; |
1038 | $this->uncache( $user, $target ); |
1039 | } |
1040 | |
1041 | $dbw = $this->lbFactory->getPrimaryDatabase(); |
1042 | $ticket = count( $targets ) > $this->updateRowsPerQuery ? |
1043 | $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null; |
1044 | $affectedRows = 0; |
1045 | $rowBatches = array_chunk( $rows, $this->updateRowsPerQuery ); |
1046 | foreach ( $rowBatches as $toInsert ) { |
1047 | // Use INSERT IGNORE to avoid overwriting the notification timestamp |
1048 | // if there's already an entry for this page |
1049 | $dbw->newInsertQueryBuilder() |
1050 | ->insertInto( 'watchlist' ) |
1051 | ->ignore() |
1052 | ->rows( $toInsert ) |
1053 | ->caller( __METHOD__ )->execute(); |
1054 | $affectedRows += $dbw->affectedRows(); |
1055 | |
1056 | if ( $this->expiryEnabled ) { |
1057 | $affectedRows += $this->updateOrDeleteExpiries( $dbw, $user->getId(), $toInsert, $expiry ); |
1058 | } |
1059 | |
1060 | if ( $ticket ) { |
1061 | $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket ); |
1062 | } |
1063 | } |
1064 | |
1065 | return (bool)$affectedRows; |
1066 | } |
1067 | |
1068 | /** |
1069 | * Insert/update expiries, or delete them if the expiry is 'infinity'. |
1070 | * |
1071 | * @param IDatabase $dbw |
1072 | * @param int $userId |
1073 | * @param array $rows |
1074 | * @param string|null $expiry |
1075 | * @return int Number of affected rows. |
1076 | */ |
1077 | private function updateOrDeleteExpiries( |
1078 | IDatabase $dbw, |
1079 | int $userId, |
1080 | array $rows, |
1081 | ?string $expiry = null |
1082 | ): int { |
1083 | if ( !$expiry ) { |
1084 | // if expiry is null (shouldn't change), 0 rows affected. |
1085 | return 0; |
1086 | } |
1087 | |
1088 | // Build the giant `(...) OR (...)` part to be used with WHERE. |
1089 | $conds = []; |
1090 | foreach ( $rows as $row ) { |
1091 | $conds[] = $dbw->makeList( |
1092 | [ |
1093 | 'wl_user' => $userId, |
1094 | 'wl_namespace' => $row['wl_namespace'], |
1095 | 'wl_title' => $row['wl_title'] |
1096 | ], |
1097 | $dbw::LIST_AND |
1098 | ); |
1099 | } |
1100 | $cond = $dbw->makeList( $conds, $dbw::LIST_OR ); |
1101 | |
1102 | if ( wfIsInfinity( $expiry ) ) { |
1103 | // Rows should be deleted rather than updated. |
1104 | $dbw->deleteJoin( |
1105 | 'watchlist_expiry', |
1106 | 'watchlist', |
1107 | 'we_item', |
1108 | 'wl_id', |
1109 | [ $cond ], |
1110 | __METHOD__ |
1111 | ); |
1112 | |
1113 | return $dbw->affectedRows(); |
1114 | } |
1115 | |
1116 | return $this->updateExpiries( $dbw, $expiry, $cond ); |
1117 | } |
1118 | |
1119 | /** |
1120 | * Update the expiries for items found with the given $cond. |
1121 | * @param IDatabase $dbw |
1122 | * @param string $expiry |
1123 | * @param string $cond |
1124 | * @return int Number of affected rows. |
1125 | */ |
1126 | private function updateExpiries( IDatabase $dbw, string $expiry, string $cond ): int { |
1127 | // First fetch the wl_ids from the watchlist table. |
1128 | // We'd prefer to do a INSERT/SELECT in the same query with IDatabase::insertSelect(), |
1129 | // but it doesn't allow us to use the "ON DUPLICATE KEY UPDATE" clause. |
1130 | $wlIds = $dbw->newSelectQueryBuilder() |
1131 | ->select( 'wl_id' ) |
1132 | ->from( 'watchlist' ) |
1133 | ->where( $cond ) |
1134 | ->caller( __METHOD__ ) |
1135 | ->fetchFieldValues(); |
1136 | |
1137 | if ( !$wlIds ) { |
1138 | return 0; |
1139 | } |
1140 | |
1141 | $expiry = $dbw->timestamp( $expiry ); |
1142 | $weRows = []; |
1143 | foreach ( $wlIds as $wlId ) { |
1144 | $weRows[] = [ |
1145 | 'we_item' => $wlId, |
1146 | 'we_expiry' => $expiry |
1147 | ]; |
1148 | } |
1149 | |
1150 | // Insert into watchlist_expiry, updating the expiry for duplicate rows. |
1151 | $dbw->newInsertQueryBuilder() |
1152 | ->insertInto( 'watchlist_expiry' ) |
1153 | ->rows( $weRows ) |
1154 | ->onDuplicateKeyUpdate() |
1155 | ->uniqueIndexFields( [ 'we_item' ] ) |
1156 | ->set( [ 'we_expiry' => $expiry ] ) |
1157 | ->caller( __METHOD__ )->execute(); |
1158 | |
1159 | return $dbw->affectedRows(); |
1160 | } |
1161 | |
1162 | /** |
1163 | * @since 1.27 |
1164 | * @param UserIdentity $user |
1165 | * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36 |
1166 | * @return bool |
1167 | */ |
1168 | public function removeWatch( UserIdentity $user, $target ): bool { |
1169 | return $this->removeWatchBatchForUser( $user, [ $target ] ); |
1170 | } |
1171 | |
1172 | /** |
1173 | * Set the "last viewed" timestamps for certain titles on a user's watchlist. |
1174 | * |
1175 | * If the $targets parameter is omitted or set to [], this method simply wraps |
1176 | * resetAllNotificationTimestampsForUser(), and in that case you should instead call that method |
1177 | * directly; support for omitting $targets is for backwards compatibility. |
1178 | * |
1179 | * If $targets is omitted or set to [], timestamps will be updated for every title on the user's |
1180 | * watchlist, and this will be done through a DeferredUpdate. If $targets is a non-empty array, |
1181 | * only the specified titles will be updated, and this will be done immediately (not deferred). |
1182 | * |
1183 | * @since 1.27 |
1184 | * @param UserIdentity $user |
1185 | * @param string|int $timestamp Value to set the "last viewed" timestamp to (null to clear) |
1186 | * @param LinkTarget[] $targets Titles to set the timestamp for; [] means the entire watchlist |
1187 | * @return bool |
1188 | */ |
1189 | public function setNotificationTimestampsForUser( |
1190 | UserIdentity $user, |
1191 | $timestamp, |
1192 | array $targets = [] |
1193 | ): bool { |
1194 | // Only registered user can have a watchlist |
1195 | if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) { |
1196 | return false; |
1197 | } |
1198 | |
1199 | if ( !$targets ) { |
1200 | // Backwards compatibility |
1201 | $this->resetAllNotificationTimestampsForUser( $user, $timestamp ); |
1202 | return true; |
1203 | } |
1204 | |
1205 | $rows = $this->getTitleDbKeysGroupedByNamespace( $targets ); |
1206 | |
1207 | $dbw = $this->lbFactory->getPrimaryDatabase(); |
1208 | if ( $timestamp !== null ) { |
1209 | $timestamp = $dbw->timestamp( $timestamp ); |
1210 | } |
1211 | $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ); |
1212 | $affectedSinceWait = 0; |
1213 | |
1214 | // Batch update items per namespace |
1215 | foreach ( $rows as $namespace => $namespaceTitles ) { |
1216 | $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery ); |
1217 | foreach ( $rowBatches as $toUpdate ) { |
1218 | // First fetch the wl_ids. |
1219 | $wlIds = $dbw->newSelectQueryBuilder() |
1220 | ->select( 'wl_id' ) |
1221 | ->from( 'watchlist' ) |
1222 | ->where( [ |
1223 | 'wl_user' => $user->getId(), |
1224 | 'wl_namespace' => $namespace, |
1225 | 'wl_title' => $toUpdate |
1226 | ] ) |
1227 | ->caller( __METHOD__ ) |
1228 | ->fetchFieldValues(); |
1229 | if ( $wlIds ) { |
1230 | $wlIds = array_map( 'intval', $wlIds ); |
1231 | $dbw->newUpdateQueryBuilder() |
1232 | ->update( 'watchlist' ) |
1233 | ->set( [ 'wl_notificationtimestamp' => $timestamp ] ) |
1234 | ->where( [ 'wl_id' => $wlIds ] ) |
1235 | ->caller( __METHOD__ )->execute(); |
1236 | |
1237 | $affectedSinceWait += $dbw->affectedRows(); |
1238 | // Wait for replication every time we've touched updateRowsPerQuery rows |
1239 | if ( $affectedSinceWait >= $this->updateRowsPerQuery ) { |
1240 | $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket ); |
1241 | $affectedSinceWait = 0; |
1242 | } |
1243 | } |
1244 | } |
1245 | } |
1246 | |
1247 | $this->uncacheUser( $user ); |
1248 | |
1249 | return true; |
1250 | } |
1251 | |
1252 | /** |
1253 | * @param string|null $timestamp |
1254 | * @param UserIdentity $user |
1255 | * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36 |
1256 | * @return bool|string|null |
1257 | */ |
1258 | public function getLatestNotificationTimestamp( |
1259 | $timestamp, |
1260 | UserIdentity $user, |
1261 | $target |
1262 | ) { |
1263 | $timestamp = wfTimestampOrNull( TS_MW, $timestamp ); |
1264 | if ( $timestamp === null ) { |
1265 | return null; // no notification |
1266 | } |
1267 | |
1268 | $seenTimestamps = $this->getPageSeenTimestamps( $user ); |
1269 | if ( $seenTimestamps ) { |
1270 | $seenKey = $this->getPageSeenKey( $target ); |
1271 | if ( isset( $seenTimestamps[$seenKey] ) && $seenTimestamps[$seenKey] >= $timestamp ) { |
1272 | // If a reset job did not yet run, then the "seen" timestamp will be higher |
1273 | return null; |
1274 | } |
1275 | } |
1276 | |
1277 | return $timestamp; |
1278 | } |
1279 | |
1280 | /** |
1281 | * Schedule a DeferredUpdate that sets all of the "last viewed" timestamps for a given user |
1282 | * to the same value. |
1283 | * @param UserIdentity $user |
1284 | * @param string|int|null $timestamp Value to set all timestamps to, null to clear them |
1285 | */ |
1286 | public function resetAllNotificationTimestampsForUser( UserIdentity $user, $timestamp = null ) { |
1287 | // Only registered user can have a watchlist |
1288 | if ( !$user->isRegistered() ) { |
1289 | return; |
1290 | } |
1291 | |
1292 | // If the page is watched by the user (or may be watched), update the timestamp |
1293 | $job = new ClearWatchlistNotificationsJob( [ |
1294 | 'userId' => $user->getId(), 'timestamp' => $timestamp, 'casTime' => time() |
1295 | ] ); |
1296 | |
1297 | // Try to run this post-send |
1298 | // Calls DeferredUpdates::addCallableUpdate in normal operation |
1299 | call_user_func( |
1300 | $this->deferredUpdatesAddCallableUpdateCallback, |
1301 | static function () use ( $job ) { |
1302 | $job->run(); |
1303 | } |
1304 | ); |
1305 | } |
1306 | |
1307 | /** |
1308 | * Update wl_notificationtimestamp for all watching users except the editor |
1309 | * @since 1.27 |
1310 | * @param UserIdentity $editor |
1311 | * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36 |
1312 | * @param string|int $timestamp |
1313 | * @return int[] |
1314 | */ |
1315 | public function updateNotificationTimestamp( |
1316 | UserIdentity $editor, |
1317 | $target, |
1318 | $timestamp |
1319 | ): array { |
1320 | $dbw = $this->lbFactory->getPrimaryDatabase(); |
1321 | $queryBuilder = $dbw->newSelectQueryBuilder() |
1322 | ->select( [ 'wl_id', 'wl_user' ] ) |
1323 | ->from( 'watchlist' ) |
1324 | ->where( |
1325 | [ |
1326 | 'wl_user != ' . $editor->getId(), |
1327 | 'wl_namespace' => $target->getNamespace(), |
1328 | 'wl_title' => $target->getDBkey(), |
1329 | 'wl_notificationtimestamp' => null, |
1330 | ] |
1331 | ) |
1332 | ->caller( __METHOD__ ); |
1333 | |
1334 | $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbw ); |
1335 | |
1336 | $res = $queryBuilder->fetchResultSet(); |
1337 | $watchers = []; |
1338 | $wlIds = []; |
1339 | foreach ( $res as $row ) { |
1340 | $watchers[] = (int)$row->wl_user; |
1341 | $wlIds[] = (int)$row->wl_id; |
1342 | } |
1343 | |
1344 | if ( $wlIds ) { |
1345 | $fname = __METHOD__; |
1346 | // Try to run this post-send |
1347 | // Calls DeferredUpdates::addCallableUpdate in normal operation |
1348 | call_user_func( |
1349 | $this->deferredUpdatesAddCallableUpdateCallback, |
1350 | function () use ( $timestamp, $wlIds, $target, $fname ) { |
1351 | $dbw = $this->lbFactory->getPrimaryDatabase(); |
1352 | $ticket = $this->lbFactory->getEmptyTransactionTicket( $fname ); |
1353 | |
1354 | $wlIdsChunks = array_chunk( $wlIds, $this->updateRowsPerQuery ); |
1355 | foreach ( $wlIdsChunks as $wlIdsChunk ) { |
1356 | $dbw->newUpdateQueryBuilder() |
1357 | ->update( 'watchlist' ) |
1358 | ->set( [ 'wl_notificationtimestamp' => $dbw->timestamp( $timestamp ) ] ) |
1359 | ->where( [ 'wl_id' => $wlIdsChunk ] ) |
1360 | ->caller( $fname )->execute(); |
1361 | |
1362 | if ( count( $wlIdsChunks ) > 1 ) { |
1363 | $this->lbFactory->commitAndWaitForReplication( $fname, $ticket ); |
1364 | } |
1365 | } |
1366 | $this->uncacheLinkTarget( $target ); |
1367 | }, |
1368 | DeferredUpdates::POSTSEND, |
1369 | $dbw |
1370 | ); |
1371 | } |
1372 | |
1373 | return $watchers; |
1374 | } |
1375 | |
1376 | /** |
1377 | * @since 1.27 |
1378 | * @param UserIdentity $user |
1379 | * @param LinkTarget|PageIdentity $title deprecated passing LinkTarget since 1.36 |
1380 | * @param string $force |
1381 | * @param int $oldid |
1382 | * @return bool |
1383 | */ |
1384 | public function resetNotificationTimestamp( |
1385 | UserIdentity $user, |
1386 | $title, |
1387 | $force = '', |
1388 | $oldid = 0 |
1389 | ): bool { |
1390 | $time = time(); |
1391 | |
1392 | // Only registered user can have a watchlist |
1393 | if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) { |
1394 | return false; |
1395 | } |
1396 | |
1397 | $item = null; |
1398 | if ( $force != 'force' ) { |
1399 | $item = $this->getWatchedItem( $user, $title ); |
1400 | if ( !$item || $item->getNotificationTimestamp() === null ) { |
1401 | return false; |
1402 | } |
1403 | } |
1404 | |
1405 | // Get the timestamp (TS_MW) of this revision to track the latest one seen |
1406 | $id = $oldid; |
1407 | $seenTime = null; |
1408 | if ( !$id ) { |
1409 | $latestRev = $this->revisionLookup->getRevisionByTitle( $title ); |
1410 | if ( $latestRev ) { |
1411 | $id = $latestRev->getId(); |
1412 | // Save a DB query |
1413 | $seenTime = $latestRev->getTimestamp(); |
1414 | } |
1415 | } |
1416 | if ( $seenTime === null ) { |
1417 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullable getId does not return null here |
1418 | $seenTime = $this->revisionLookup->getTimestampFromId( $id ); |
1419 | } |
1420 | |
1421 | // Mark the item as read immediately in lightweight storage |
1422 | $this->stash->merge( |
1423 | $this->getPageSeenTimestampsKey( $user ), |
1424 | function ( $cache, $key, $current ) use ( $title, $seenTime ) { |
1425 | if ( !$current ) { |
1426 | $value = new MapCacheLRU( 300 ); |
1427 | } elseif ( is_array( $current ) ) { |
1428 | $value = MapCacheLRU::newFromArray( $current, 300 ); |
1429 | } else { |
1430 | // Backwards compatibility for T282105 |
1431 | $value = $current; |
1432 | } |
1433 | $subKey = $this->getPageSeenKey( $title ); |
1434 | |
1435 | if ( $seenTime > $value->get( $subKey ) ) { |
1436 | // Revision is newer than the last one seen |
1437 | $value->set( $subKey, $seenTime ); |
1438 | |
1439 | $this->latestUpdateCache->set( $key, $value->toArray(), BagOStuff::TTL_PROC_LONG ); |
1440 | } elseif ( $seenTime === false ) { |
1441 | // Revision does not exist |
1442 | $value->set( $subKey, wfTimestamp( TS_MW ) ); |
1443 | $this->latestUpdateCache->set( $key, |
1444 | $value->toArray(), |
1445 | BagOStuff::TTL_PROC_LONG ); |
1446 | } else { |
1447 | return false; // nothing to update |
1448 | } |
1449 | |
1450 | return $value->toArray(); |
1451 | }, |
1452 | BagOStuff::TTL_HOUR |
1453 | ); |
1454 | |
1455 | // If the page is watched by the user (or may be watched), update the timestamp |
1456 | // ActivityUpdateJob accepts both LinkTarget and PageReference |
1457 | $job = new ActivityUpdateJob( |
1458 | $title, |
1459 | [ |
1460 | 'type' => 'updateWatchlistNotification', |
1461 | 'userid' => $user->getId(), |
1462 | 'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ), |
1463 | 'curTime' => $time |
1464 | ] |
1465 | ); |
1466 | // Try to enqueue this post-send |
1467 | $this->queueGroup->lazyPush( $job ); |
1468 | |
1469 | $this->uncache( $user, $title ); |
1470 | |
1471 | return true; |
1472 | } |
1473 | |
1474 | /** |
1475 | * @param UserIdentity $user |
1476 | * @return array|null The map contains prefixed title keys and TS_MW values |
1477 | */ |
1478 | private function getPageSeenTimestamps( UserIdentity $user ) { |
1479 | $key = $this->getPageSeenTimestampsKey( $user ); |
1480 | |
1481 | $cache = $this->latestUpdateCache->getWithSetCallback( |
1482 | $key, |
1483 | BagOStuff::TTL_PROC_LONG, |
1484 | function () use ( $key ) { |
1485 | return $this->stash->get( $key ) ?: null; |
1486 | } |
1487 | ); |
1488 | // Backwards compatibility for T282105 |
1489 | if ( $cache instanceof MapCacheLRU ) { |
1490 | $cache = $cache->toArray(); |
1491 | } |
1492 | return $cache; |
1493 | } |
1494 | |
1495 | /** |
1496 | * @param UserIdentity $user |
1497 | * @return string |
1498 | */ |
1499 | private function getPageSeenTimestampsKey( UserIdentity $user ): string { |
1500 | return $this->stash->makeGlobalKey( |
1501 | 'watchlist-recent-updates', |
1502 | $this->lbFactory->getLocalDomainID(), |
1503 | $user->getId() |
1504 | ); |
1505 | } |
1506 | |
1507 | /** |
1508 | * @param LinkTarget|PageIdentity $target |
1509 | * @return string |
1510 | */ |
1511 | private function getPageSeenKey( $target ): string { |
1512 | return "{$target->getNamespace()}:{$target->getDBkey()}"; |
1513 | } |
1514 | |
1515 | /** |
1516 | * @param UserIdentity $user |
1517 | * @param LinkTarget|PageIdentity $title deprecated passing LinkTarget since 1.36 |
1518 | * @param WatchedItem|null $item |
1519 | * @param string $force |
1520 | * @param int|false $oldid The ID of the last revision that the user viewed |
1521 | * @return string|null|false |
1522 | */ |
1523 | private function getNotificationTimestamp( |
1524 | UserIdentity $user, |
1525 | $title, |
1526 | $item, |
1527 | $force, |
1528 | $oldid |
1529 | ) { |
1530 | if ( !$oldid ) { |
1531 | // No oldid given, assuming latest revision; clear the timestamp. |
1532 | return null; |
1533 | } |
1534 | |
1535 | $oldRev = $this->revisionLookup->getRevisionById( $oldid ); |
1536 | if ( !$oldRev ) { |
1537 | // Oldid given but does not exist (probably deleted) |
1538 | return false; |
1539 | } |
1540 | |
1541 | $nextRev = $this->revisionLookup->getNextRevision( $oldRev ); |
1542 | if ( !$nextRev ) { |
1543 | // Oldid given and is the latest revision for this title; clear the timestamp. |
1544 | return null; |
1545 | } |
1546 | |
1547 | if ( $item === null ) { |
1548 | $item = $this->loadWatchedItem( $user, $title ); |
1549 | } |
1550 | |
1551 | if ( !$item ) { |
1552 | // This can only happen if $force is enabled. |
1553 | return null; |
1554 | } |
1555 | |
1556 | // Oldid given and isn't the latest; update the timestamp. |
1557 | // This will result in no further notification emails being sent! |
1558 | $notificationTimestamp = $this->revisionLookup->getTimestampFromId( $oldid ); |
1559 | // @FIXME: this should use getTimestamp() for consistency with updates on new edits |
1560 | // $notificationTimestamp = $nextRev->getTimestamp(); // first unseen revision timestamp |
1561 | |
1562 | // We need to go one second to the future because of various strict comparisons |
1563 | // throughout the codebase |
1564 | $ts = new MWTimestamp( $notificationTimestamp ); |
1565 | $ts->timestamp->add( new DateInterval( 'PT1S' ) ); |
1566 | $notificationTimestamp = $ts->getTimestamp( TS_MW ); |
1567 | |
1568 | if ( $notificationTimestamp < $item->getNotificationTimestamp() ) { |
1569 | if ( $force != 'force' ) { |
1570 | return false; |
1571 | } else { |
1572 | // This is a little silly… |
1573 | return $item->getNotificationTimestamp(); |
1574 | } |
1575 | } |
1576 | |
1577 | return $notificationTimestamp; |
1578 | } |
1579 | |
1580 | /** |
1581 | * @since 1.27 |
1582 | * @param UserIdentity $user |
1583 | * @param int|null $unreadLimit |
1584 | * @return int|bool |
1585 | */ |
1586 | public function countUnreadNotifications( UserIdentity $user, $unreadLimit = null ) { |
1587 | $queryBuilder = $this->lbFactory->getReplicaDatabase()->newSelectQueryBuilder() |
1588 | ->select( '1' ) |
1589 | ->from( 'watchlist' ) |
1590 | ->where( [ |
1591 | 'wl_user' => $user->getId(), |
1592 | 'wl_notificationtimestamp IS NOT NULL' |
1593 | ] ) |
1594 | ->caller( __METHOD__ ); |
1595 | if ( $unreadLimit !== null ) { |
1596 | $unreadLimit = (int)$unreadLimit; |
1597 | $queryBuilder->limit( $unreadLimit ); |
1598 | } |
1599 | |
1600 | $rowCount = $queryBuilder->fetchRowCount(); |
1601 | |
1602 | if ( $unreadLimit === null ) { |
1603 | return $rowCount; |
1604 | } |
1605 | |
1606 | if ( $rowCount >= $unreadLimit ) { |
1607 | return true; |
1608 | } |
1609 | |
1610 | return $rowCount; |
1611 | } |
1612 | |
1613 | /** |
1614 | * @since 1.27 |
1615 | * @param LinkTarget|PageIdentity $oldTarget deprecated passing LinkTarget since 1.36 |
1616 | * @param LinkTarget|PageIdentity $newTarget deprecated passing LinkTarget since 1.36 |
1617 | */ |
1618 | public function duplicateAllAssociatedEntries( $oldTarget, $newTarget ) { |
1619 | // Duplicate first the subject page, then the talk page |
1620 | // TODO: convert to PageIdentity |
1621 | $this->duplicateEntry( |
1622 | new TitleValue( $this->nsInfo->getSubject( $oldTarget->getNamespace() ), $oldTarget->getDBkey() ), |
1623 | new TitleValue( $this->nsInfo->getSubject( $newTarget->getNamespace() ), $newTarget->getDBkey() ) |
1624 | ); |
1625 | $this->duplicateEntry( |
1626 | new TitleValue( $this->nsInfo->getTalk( $oldTarget->getNamespace() ), $oldTarget->getDBkey() ), |
1627 | new TitleValue( $this->nsInfo->getTalk( $newTarget->getNamespace() ), $newTarget->getDBkey() ) |
1628 | ); |
1629 | } |
1630 | |
1631 | /** |
1632 | * @since 1.27 |
1633 | * @param LinkTarget|PageIdentity $oldTarget deprecated passing LinkTarget since 1.36 |
1634 | * @param LinkTarget|PageIdentity $newTarget deprecated passing LinkTarget since 1.36 |
1635 | */ |
1636 | public function duplicateEntry( $oldTarget, $newTarget ) { |
1637 | $dbw = $this->lbFactory->getPrimaryDatabase(); |
1638 | $result = $this->fetchWatchedItemsForPage( $dbw, $oldTarget ); |
1639 | $newNamespace = $newTarget->getNamespace(); |
1640 | $newDBkey = $newTarget->getDBkey(); |
1641 | |
1642 | # Construct array to replace into the watchlist |
1643 | $values = []; |
1644 | $expiries = []; |
1645 | foreach ( $result as $row ) { |
1646 | $values[] = [ |
1647 | 'wl_user' => $row->wl_user, |
1648 | 'wl_namespace' => $newNamespace, |
1649 | 'wl_title' => $newDBkey, |
1650 | 'wl_notificationtimestamp' => $row->wl_notificationtimestamp, |
1651 | ]; |
1652 | |
1653 | if ( $this->expiryEnabled && $row->we_expiry ) { |
1654 | $expiries[$row->wl_user] = $row->we_expiry; |
1655 | } |
1656 | } |
1657 | |
1658 | if ( !$values ) { |
1659 | return; |
1660 | } |
1661 | |
1662 | // Perform a replace on the watchlist table rows. |
1663 | // Note that multi-row replace is very efficient for MySQL but may be inefficient for |
1664 | // some other DBMSes, mostly due to poor simulation by us. |
1665 | $dbw->newReplaceQueryBuilder() |
1666 | ->replaceInto( 'watchlist' ) |
1667 | ->uniqueIndexFields( [ 'wl_user', 'wl_namespace', 'wl_title' ] ) |
1668 | ->rows( $values ) |
1669 | ->caller( __METHOD__ )->execute(); |
1670 | |
1671 | if ( $this->expiryEnabled ) { |
1672 | $this->updateExpiriesAfterMove( $dbw, $expiries, $newNamespace, $newDBkey ); |
1673 | } |
1674 | } |
1675 | |
1676 | /** |
1677 | * @param IReadableDatabase $dbr |
1678 | * @param LinkTarget|PageIdentity $target |
1679 | * @return IResultWrapper |
1680 | */ |
1681 | private function fetchWatchedItemsForPage( |
1682 | IReadableDatabase $dbr, |
1683 | $target |
1684 | ): IResultWrapper { |
1685 | $queryBuilder = $dbr->newSelectQueryBuilder() |
1686 | ->select( [ 'wl_user', 'wl_notificationtimestamp' ] ) |
1687 | ->from( 'watchlist' ) |
1688 | ->where( [ |
1689 | 'wl_namespace' => $target->getNamespace(), |
1690 | 'wl_title' => $target->getDBkey(), |
1691 | ] ) |
1692 | ->caller( __METHOD__ ) |
1693 | ->forUpdate(); |
1694 | |
1695 | if ( $this->expiryEnabled ) { |
1696 | $queryBuilder->leftJoin( 'watchlist_expiry', null, [ 'wl_id = we_item' ] ) |
1697 | ->field( 'we_expiry' ); |
1698 | } |
1699 | |
1700 | return $queryBuilder->fetchResultSet(); |
1701 | } |
1702 | |
1703 | /** |
1704 | * @param IDatabase $dbw |
1705 | * @param array $expiries |
1706 | * @param int $namespace |
1707 | * @param string $dbKey |
1708 | */ |
1709 | private function updateExpiriesAfterMove( |
1710 | IDatabase $dbw, |
1711 | array $expiries, |
1712 | int $namespace, |
1713 | string $dbKey |
1714 | ): void { |
1715 | $method = __METHOD__; |
1716 | DeferredUpdates::addCallableUpdate( |
1717 | function () use ( $dbw, $expiries, $namespace, $dbKey, $method ) { |
1718 | // First fetch new wl_ids. |
1719 | $res = $dbw->newSelectQueryBuilder() |
1720 | ->select( [ 'wl_user', 'wl_id' ] ) |
1721 | ->from( 'watchlist' ) |
1722 | ->where( [ |
1723 | 'wl_namespace' => $namespace, |
1724 | 'wl_title' => $dbKey, |
1725 | ] ) |
1726 | ->caller( $method ) |
1727 | ->fetchResultSet(); |
1728 | |
1729 | // Build new array to INSERT into multiple rows at once. |
1730 | $expiryData = []; |
1731 | foreach ( $res as $row ) { |
1732 | if ( !empty( $expiries[$row->wl_user] ) ) { |
1733 | $expiryData[] = [ |
1734 | 'we_item' => $row->wl_id, |
1735 | 'we_expiry' => $expiries[$row->wl_user], |
1736 | ]; |
1737 | } |
1738 | } |
1739 | |
1740 | // Batch the insertions. |
1741 | $batches = array_chunk( $expiryData, $this->updateRowsPerQuery ); |
1742 | foreach ( $batches as $toInsert ) { |
1743 | $dbw->newReplaceQueryBuilder() |
1744 | ->replaceInto( 'watchlist_expiry' ) |
1745 | ->uniqueIndexFields( [ 'we_item' ] ) |
1746 | ->rows( $toInsert ) |
1747 | ->caller( $method )->execute(); |
1748 | } |
1749 | }, |
1750 | DeferredUpdates::POSTSEND, |
1751 | $dbw |
1752 | ); |
1753 | } |
1754 | |
1755 | /** |
1756 | * @param LinkTarget[]|PageIdentity[] $titles |
1757 | * @return array |
1758 | */ |
1759 | private function getTitleDbKeysGroupedByNamespace( array $titles ) { |
1760 | $rows = []; |
1761 | foreach ( $titles as $title ) { |
1762 | // Group titles by namespace. |
1763 | $rows[ $title->getNamespace() ][] = $title->getDBkey(); |
1764 | } |
1765 | return $rows; |
1766 | } |
1767 | |
1768 | /** |
1769 | * @param UserIdentity $user |
1770 | * @param LinkTarget[]|PageIdentity[] $titles |
1771 | */ |
1772 | private function uncacheTitlesForUser( UserIdentity $user, array $titles ) { |
1773 | foreach ( $titles as $title ) { |
1774 | $this->uncache( $user, $title ); |
1775 | } |
1776 | } |
1777 | |
1778 | /** |
1779 | * @inheritDoc |
1780 | */ |
1781 | public function countExpired(): int { |
1782 | $dbr = $this->lbFactory->getReplicaDatabase(); |
1783 | return $dbr->newSelectQueryBuilder() |
1784 | ->select( '*' ) |
1785 | ->from( 'watchlist_expiry' ) |
1786 | ->where( [ 'we_expiry <= ' . $dbr->addQuotes( $dbr->timestamp() ) ] ) |
1787 | ->caller( __METHOD__ ) |
1788 | ->fetchRowCount(); |
1789 | } |
1790 | |
1791 | /** |
1792 | * @inheritDoc |
1793 | */ |
1794 | public function removeExpired( int $limit, bool $deleteOrphans = false ): void { |
1795 | $dbr = $this->lbFactory->getReplicaDatabase(); |
1796 | $dbw = $this->lbFactory->getPrimaryDatabase(); |
1797 | $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ); |
1798 | |
1799 | // Get a batch of watchlist IDs to delete. |
1800 | $toDelete = $dbr->newSelectQueryBuilder() |
1801 | ->select( 'we_item' ) |
1802 | ->from( 'watchlist_expiry' ) |
1803 | ->where( [ 'we_expiry <= ' . $dbr->addQuotes( $dbr->timestamp() ) ] ) |
1804 | ->limit( $limit ) |
1805 | ->caller( __METHOD__ ) |
1806 | ->fetchFieldValues(); |
1807 | |
1808 | if ( count( $toDelete ) > 0 ) { |
1809 | // Delete them from the watchlist and watchlist_expiry table. |
1810 | $dbw->newDeleteQueryBuilder() |
1811 | ->deleteFrom( 'watchlist' ) |
1812 | ->where( [ 'wl_id' => $toDelete ] ) |
1813 | ->caller( __METHOD__ )->execute(); |
1814 | $dbw->newDeleteQueryBuilder() |
1815 | ->deleteFrom( 'watchlist_expiry' ) |
1816 | ->where( [ 'we_item' => $toDelete ] ) |
1817 | ->caller( __METHOD__ )->execute(); |
1818 | } |
1819 | |
1820 | // Also delete any orphaned or null-expiry watchlist_expiry rows |
1821 | // (they should not exist, but might because not everywhere knows about the expiry table yet). |
1822 | if ( $deleteOrphans ) { |
1823 | $expiryToDelete = $dbr->newSelectQueryBuilder() |
1824 | ->select( 'we_item' ) |
1825 | ->from( 'watchlist_expiry' ) |
1826 | ->leftJoin( 'watchlist', null, 'wl_id = we_item' ) |
1827 | ->where( $dbr->makeList( |
1828 | [ 'wl_id' => null, 'we_expiry' => null ], |
1829 | $dbr::LIST_OR |
1830 | ) ) |
1831 | ->caller( __METHOD__ ) |
1832 | ->fetchFieldValues(); |
1833 | if ( count( $expiryToDelete ) > 0 ) { |
1834 | $dbw->newDeleteQueryBuilder() |
1835 | ->deleteFrom( 'watchlist_expiry' ) |
1836 | ->where( [ 'we_item' => $expiryToDelete ] ) |
1837 | ->caller( __METHOD__ )->execute(); |
1838 | } |
1839 | } |
1840 | |
1841 | $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket ); |
1842 | } |
1843 | } |