Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
95.33% |
776 / 814 |
|
76.79% |
43 / 56 |
CRAP | |
0.00% |
0 / 1 |
WatchedItemStore | |
95.33% |
776 / 814 |
|
76.79% |
43 / 56 |
191 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
18 / 18 |
|
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 | |||
getConnectionRef | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
modifyQueryBuilderForExpiry | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
clearUserWatchedItems | |
100.00% |
30 / 30 |
|
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 | |
93.18% |
41 / 44 |
|
0.00% |
0 / 1 |
10.03 | |||
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% |
29 / 29 |
|
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.55% |
28 / 29 |
|
0.00% |
0 / 1 |
9 | |||
updateOrDeleteExpiries | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
4 | |||
updateExpiries | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
1 | |||
removeWatch | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setNotificationTimestampsForUser | |
94.74% |
36 / 38 |
|
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 | |
93.33% |
42 / 45 |
|
0.00% |
0 / 1 |
5.01 | |||
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% |
25 / 25 |
|
100.00% |
1 / 1 |
6 | |||
fetchWatchedItemsForPage | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
2 | |||
updateExpiriesAfterMove | |
100.00% |
31 / 31 |
|
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% |
39 / 39 |
|
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\Linker\LinkTarget; |
7 | use MediaWiki\MainConfigNames; |
8 | use MediaWiki\Page\PageIdentity; |
9 | use MediaWiki\Revision\RevisionLookup; |
10 | use MediaWiki\User\UserIdentity; |
11 | use Wikimedia\Assert\Assert; |
12 | use Wikimedia\ParamValidator\TypeDef\ExpiryDef; |
13 | use Wikimedia\Rdbms\IDatabase; |
14 | use Wikimedia\Rdbms\ILBFactory; |
15 | use Wikimedia\Rdbms\IResultWrapper; |
16 | use Wikimedia\Rdbms\LoadBalancer; |
17 | use Wikimedia\Rdbms\SelectQueryBuilder; |
18 | use Wikimedia\ScopedCallback; |
19 | |
20 | /** |
21 | * Storage layer class for WatchedItems. |
22 | * Database interaction & caching |
23 | * TODO caching should be factored out into a CachingWatchedItemStore class |
24 | * |
25 | * @author Addshore |
26 | * @since 1.27 |
27 | */ |
28 | class WatchedItemStore implements WatchedItemStoreInterface, StatsdAwareInterface { |
29 | |
30 | /** |
31 | * @internal For use by ServiceWiring |
32 | */ |
33 | public const CONSTRUCTOR_OPTIONS = [ |
34 | MainConfigNames::UpdateRowsPerQuery, |
35 | MainConfigNames::WatchlistExpiry, |
36 | MainConfigNames::WatchlistExpiryMaxDuration, |
37 | MainConfigNames::WatchlistPurgeRate, |
38 | ]; |
39 | |
40 | /** |
41 | * @var ILBFactory |
42 | */ |
43 | private $lbFactory; |
44 | |
45 | /** |
46 | * @var LoadBalancer |
47 | */ |
48 | private $loadBalancer; |
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->loadBalancer = $lbFactory->getMainLB(); |
157 | $this->queueGroup = $queueGroup; |
158 | $this->stash = $stash; |
159 | $this->cache = $cache; |
160 | $this->readOnlyMode = $readOnlyMode; |
161 | $this->stats = new NullStatsdDataFactory(); |
162 | $this->deferredUpdatesAddCallableUpdateCallback = |
163 | [ DeferredUpdates::class, 'addCallableUpdate' ]; |
164 | $this->nsInfo = $nsInfo; |
165 | $this->revisionLookup = $revisionLookup; |
166 | $this->linkBatchFactory = $linkBatchFactory; |
167 | |
168 | $this->latestUpdateCache = new HashBagOStuff( [ 'maxKeys' => 3 ] ); |
169 | } |
170 | |
171 | /** |
172 | * @param StatsdDataFactoryInterface $stats |
173 | */ |
174 | public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) { |
175 | $this->stats = $stats; |
176 | } |
177 | |
178 | /** |
179 | * Overrides the DeferredUpdates::addCallableUpdate callback |
180 | * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined. |
181 | * |
182 | * @param callable $callback |
183 | * |
184 | * @see DeferredUpdates::addCallableUpdate for callback signiture |
185 | * |
186 | * @return ScopedCallback to reset the overridden value |
187 | * @throws MWException |
188 | */ |
189 | public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) { |
190 | if ( !defined( 'MW_PHPUNIT_TEST' ) ) { |
191 | throw new MWException( |
192 | 'Cannot override DeferredUpdates::addCallableUpdate callback in operation.' |
193 | ); |
194 | } |
195 | $previousValue = $this->deferredUpdatesAddCallableUpdateCallback; |
196 | $this->deferredUpdatesAddCallableUpdateCallback = $callback; |
197 | return new ScopedCallback( function () use ( $previousValue ) { |
198 | $this->deferredUpdatesAddCallableUpdateCallback = $previousValue; |
199 | } ); |
200 | } |
201 | |
202 | /** |
203 | * @param UserIdentity $user |
204 | * @param LinkTarget|PageIdentity $target |
205 | * @return string |
206 | */ |
207 | private function getCacheKey( UserIdentity $user, $target ): string { |
208 | return $this->cache->makeKey( |
209 | (string)$target->getNamespace(), |
210 | $target->getDBkey(), |
211 | (string)$user->getId() |
212 | ); |
213 | } |
214 | |
215 | /** |
216 | * @param WatchedItem $item |
217 | */ |
218 | private function cache( WatchedItem $item ) { |
219 | $user = $item->getUserIdentity(); |
220 | $target = $item->getTarget(); |
221 | $key = $this->getCacheKey( $user, $target ); |
222 | $this->cache->set( $key, $item ); |
223 | $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key; |
224 | $this->stats->increment( 'WatchedItemStore.cache' ); |
225 | } |
226 | |
227 | /** |
228 | * @param UserIdentity $user |
229 | * @param LinkTarget|PageIdentity $target |
230 | */ |
231 | private function uncache( UserIdentity $user, $target ) { |
232 | $this->cache->delete( $this->getCacheKey( $user, $target ) ); |
233 | unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] ); |
234 | $this->stats->increment( 'WatchedItemStore.uncache' ); |
235 | } |
236 | |
237 | /** |
238 | * @param LinkTarget|PageIdentity $target |
239 | */ |
240 | private function uncacheLinkTarget( $target ) { |
241 | $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' ); |
242 | if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) { |
243 | return; |
244 | } |
245 | foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) { |
246 | $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' ); |
247 | $this->cache->delete( $key ); |
248 | } |
249 | } |
250 | |
251 | /** |
252 | * @param UserIdentity $user |
253 | */ |
254 | private function uncacheUser( UserIdentity $user ) { |
255 | $this->stats->increment( 'WatchedItemStore.uncacheUser' ); |
256 | foreach ( $this->cacheIndex as $dbKeyArray ) { |
257 | foreach ( $dbKeyArray as $userArray ) { |
258 | if ( isset( $userArray[$user->getId()] ) ) { |
259 | $this->stats->increment( 'WatchedItemStore.uncacheUser.items' ); |
260 | $this->cache->delete( $userArray[$user->getId()] ); |
261 | } |
262 | } |
263 | } |
264 | |
265 | $pageSeenKey = $this->getPageSeenTimestampsKey( $user ); |
266 | $this->latestUpdateCache->delete( $pageSeenKey ); |
267 | $this->stash->delete( $pageSeenKey ); |
268 | } |
269 | |
270 | /** |
271 | * @param UserIdentity $user |
272 | * @param LinkTarget|PageIdentity $target |
273 | * |
274 | * @return WatchedItem|false |
275 | */ |
276 | private function getCached( UserIdentity $user, $target ) { |
277 | return $this->cache->get( $this->getCacheKey( $user, $target ) ); |
278 | } |
279 | |
280 | /** |
281 | * @param int $dbIndex DB_PRIMARY or DB_REPLICA |
282 | * |
283 | * @return IDatabase |
284 | */ |
285 | private function getConnectionRef( $dbIndex ): IDatabase { |
286 | return $this->loadBalancer->getConnectionRef( $dbIndex ); |
287 | } |
288 | |
289 | /** |
290 | * Helper method to deduplicate logic around queries that need to be modified |
291 | * if watchlist expiration is enabled |
292 | * |
293 | * @param SelectQueryBuilder $queryBuilder |
294 | * @param IDatabase $db |
295 | */ |
296 | private function modifyQueryBuilderForExpiry( |
297 | SelectQueryBuilder $queryBuilder, |
298 | IDatabase $db |
299 | ) { |
300 | if ( $this->expiryEnabled ) { |
301 | $queryBuilder->where( 'we_expiry IS NULL OR we_expiry > ' . $db->addQuotes( $db->timestamp() ) ); |
302 | $queryBuilder->leftJoin( 'watchlist_expiry', null, 'wl_id = we_item' ); |
303 | } |
304 | } |
305 | |
306 | /** |
307 | * Deletes ALL watched items for the given user when under |
308 | * $updateRowsPerQuery entries exist. |
309 | * |
310 | * @since 1.30 |
311 | * |
312 | * @param UserIdentity $user |
313 | * |
314 | * @return bool true on success, false when too many items are watched |
315 | */ |
316 | public function clearUserWatchedItems( UserIdentity $user ): bool { |
317 | if ( $this->mustClearWatchedItemsUsingJobQueue( $user ) ) { |
318 | return false; |
319 | } |
320 | |
321 | $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY ); |
322 | |
323 | if ( $this->expiryEnabled ) { |
324 | $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ); |
325 | // First fetch the wl_ids. |
326 | $wlIds = $dbw->newSelectQueryBuilder() |
327 | ->select( 'wl_id' ) |
328 | ->from( 'watchlist' ) |
329 | ->where( [ 'wl_user' => $user->getId() ] ) |
330 | ->caller( __METHOD__ ) |
331 | ->fetchFieldValues(); |
332 | if ( $wlIds ) { |
333 | // Delete rows from both the watchlist and watchlist_expiry tables. |
334 | $dbw->delete( |
335 | 'watchlist', |
336 | [ 'wl_id' => $wlIds ], |
337 | __METHOD__ |
338 | ); |
339 | |
340 | $dbw->delete( |
341 | 'watchlist_expiry', |
342 | [ 'we_item' => $wlIds ], |
343 | __METHOD__ |
344 | ); |
345 | } |
346 | $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket ); |
347 | } else { |
348 | $dbw->delete( |
349 | 'watchlist', |
350 | [ 'wl_user' => $user->getId() ], |
351 | __METHOD__ |
352 | ); |
353 | } |
354 | |
355 | $this->uncacheAllItemsForUser( $user ); |
356 | |
357 | return true; |
358 | } |
359 | |
360 | /** |
361 | * @param UserIdentity $user |
362 | * @return bool |
363 | */ |
364 | public function mustClearWatchedItemsUsingJobQueue( UserIdentity $user ): bool { |
365 | return $this->countWatchedItems( $user ) > $this->updateRowsPerQuery; |
366 | } |
367 | |
368 | /** |
369 | * @param UserIdentity $user |
370 | */ |
371 | private function uncacheAllItemsForUser( UserIdentity $user ) { |
372 | $userId = $user->getId(); |
373 | foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) { |
374 | foreach ( $dbKeyIndex as $dbKey => $userIndex ) { |
375 | if ( array_key_exists( $userId, $userIndex ) ) { |
376 | $this->cache->delete( $userIndex[$userId] ); |
377 | unset( $this->cacheIndex[$ns][$dbKey][$userId] ); |
378 | } |
379 | } |
380 | } |
381 | |
382 | // Cleanup empty cache keys |
383 | foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) { |
384 | foreach ( $dbKeyIndex as $dbKey => $userIndex ) { |
385 | if ( empty( $this->cacheIndex[$ns][$dbKey] ) ) { |
386 | unset( $this->cacheIndex[$ns][$dbKey] ); |
387 | } |
388 | } |
389 | if ( empty( $this->cacheIndex[$ns] ) ) { |
390 | unset( $this->cacheIndex[$ns] ); |
391 | } |
392 | } |
393 | } |
394 | |
395 | /** |
396 | * Queues a job that will clear the users watchlist using the Job Queue. |
397 | * |
398 | * @since 1.31 |
399 | * |
400 | * @param UserIdentity $user |
401 | */ |
402 | public function clearUserWatchedItemsUsingJobQueue( UserIdentity $user ) { |
403 | $job = ClearUserWatchlistJob::newForUser( $user, $this->getMaxId() ); |
404 | $this->queueGroup->push( $job ); |
405 | } |
406 | |
407 | /** |
408 | * @inheritDoc |
409 | */ |
410 | public function maybeEnqueueWatchlistExpiryJob(): void { |
411 | if ( !$this->expiryEnabled ) { |
412 | // No need to purge expired entries if there are none |
413 | return; |
414 | } |
415 | |
416 | $max = mt_getrandmax(); |
417 | if ( mt_rand( 0, $max ) < $max * $this->watchlistPurgeRate ) { |
418 | // The higher the watchlist purge rate, the more likely we are to enqueue a job. |
419 | $this->queueGroup->lazyPush( new WatchlistExpiryJob() ); |
420 | } |
421 | } |
422 | |
423 | /** |
424 | * @since 1.31 |
425 | * @return int The maximum current wl_id |
426 | */ |
427 | public function getMaxId(): int { |
428 | return (int)$this->getConnectionRef( DB_REPLICA )->newSelectQueryBuilder() |
429 | ->select( 'MAX(wl_id)' ) |
430 | ->from( 'watchlist' ) |
431 | ->caller( __METHOD__ ) |
432 | ->fetchField(); |
433 | } |
434 | |
435 | /** |
436 | * @since 1.31 |
437 | * @param UserIdentity $user |
438 | * @return int |
439 | */ |
440 | public function countWatchedItems( UserIdentity $user ): int { |
441 | $dbr = $this->getConnectionRef( DB_REPLICA ); |
442 | $queryBuilder = $this->getConnectionRef( DB_REPLICA )->newSelectQueryBuilder() |
443 | ->select( 'COUNT(*)' ) |
444 | ->from( 'watchlist' ) |
445 | ->where( [ 'wl_user' => $user->getId() ] ) |
446 | ->caller( __METHOD__ ); |
447 | |
448 | $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr ); |
449 | |
450 | return (int)$queryBuilder->fetchField(); |
451 | } |
452 | |
453 | /** |
454 | * @since 1.27 |
455 | * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36 |
456 | * @return int |
457 | */ |
458 | public function countWatchers( $target ): int { |
459 | $dbr = $this->getConnectionRef( DB_REPLICA ); |
460 | $queryBuilder = $dbr->newSelectQueryBuilder() |
461 | ->select( 'COUNT(*)' ) |
462 | ->from( 'watchlist' ) |
463 | ->where( [ |
464 | 'wl_namespace' => $target->getNamespace(), |
465 | 'wl_title' => $target->getDBkey() |
466 | ] ) |
467 | ->caller( __METHOD__ ); |
468 | |
469 | $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr ); |
470 | |
471 | return (int)$queryBuilder->fetchField(); |
472 | } |
473 | |
474 | /** |
475 | * @since 1.27 |
476 | * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36 |
477 | * @param string|int $threshold |
478 | * @return int |
479 | */ |
480 | public function countVisitingWatchers( $target, $threshold ): int { |
481 | $dbr = $this->getConnectionRef( DB_REPLICA ); |
482 | $queryBuilder = $dbr->newSelectQueryBuilder() |
483 | ->select( 'COUNT(*)' ) |
484 | ->from( 'watchlist' ) |
485 | ->where( [ |
486 | 'wl_namespace' => $target->getNamespace(), |
487 | 'wl_title' => $target->getDBkey(), |
488 | 'wl_notificationtimestamp >= ' . |
489 | $dbr->addQuotes( $dbr->timestamp( $threshold ) ) . |
490 | ' OR wl_notificationtimestamp IS NULL' |
491 | ] ) |
492 | ->caller( __METHOD__ ); |
493 | |
494 | $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr ); |
495 | |
496 | return (int)$queryBuilder->fetchField(); |
497 | } |
498 | |
499 | /** |
500 | * @param UserIdentity $user |
501 | * @param LinkTarget[]|PageIdentity[] $titles deprecated passing LinkTarget[] since 1.36 |
502 | * @return bool |
503 | */ |
504 | public function removeWatchBatchForUser( UserIdentity $user, array $titles ): bool { |
505 | if ( $this->readOnlyMode->isReadOnly() ) { |
506 | return false; |
507 | } |
508 | if ( !$user->isRegistered() ) { |
509 | return false; |
510 | } |
511 | if ( !$titles ) { |
512 | return true; |
513 | } |
514 | |
515 | $rows = $this->getTitleDbKeysGroupedByNamespace( $titles ); |
516 | $this->uncacheTitlesForUser( $user, $titles ); |
517 | |
518 | $dbw = $this->getConnectionRef( DB_PRIMARY ); |
519 | $ticket = count( $titles ) > $this->updateRowsPerQuery ? |
520 | $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null; |
521 | $affectedRows = 0; |
522 | |
523 | // Batch delete items per namespace. |
524 | foreach ( $rows as $namespace => $namespaceTitles ) { |
525 | $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery ); |
526 | foreach ( $rowBatches as $toDelete ) { |
527 | // First fetch the wl_ids. |
528 | $wlIds = $dbw->newSelectQueryBuilder() |
529 | ->select( 'wl_id' ) |
530 | ->from( 'watchlist' ) |
531 | ->where( |
532 | [ |
533 | 'wl_user' => $user->getId(), |
534 | 'wl_namespace' => $namespace, |
535 | 'wl_title' => $toDelete |
536 | ] |
537 | ) |
538 | ->caller( __METHOD__ ) |
539 | ->fetchFieldValues(); |
540 | |
541 | if ( $wlIds ) { |
542 | // Delete rows from both the watchlist and watchlist_expiry tables. |
543 | $dbw->delete( |
544 | 'watchlist', |
545 | [ 'wl_id' => $wlIds ], |
546 | __METHOD__ |
547 | ); |
548 | $affectedRows += $dbw->affectedRows(); |
549 | |
550 | if ( $this->expiryEnabled ) { |
551 | $dbw->delete( |
552 | 'watchlist_expiry', |
553 | [ 'we_item' => $wlIds ], |
554 | __METHOD__ |
555 | ); |
556 | $affectedRows += $dbw->affectedRows(); |
557 | } |
558 | } |
559 | |
560 | if ( $ticket ) { |
561 | $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket ); |
562 | } |
563 | } |
564 | } |
565 | |
566 | return (bool)$affectedRows; |
567 | } |
568 | |
569 | /** |
570 | * @since 1.27 |
571 | * @param LinkTarget[]|PageIdentity[] $targets deprecated passing LinkTarget[] since 1.36 |
572 | * @param array $options Supported options are: |
573 | * - 'minimumWatchers': filter for pages that have at least a minimum number of watchers |
574 | * @return array |
575 | */ |
576 | public function countWatchersMultiple( array $targets, array $options = [] ): array { |
577 | $linkTargets = array_map( static function ( $target ) { |
578 | if ( !$target instanceof LinkTarget ) { |
579 | return new TitleValue( $target->getNamespace(), $target->getDBkey() ); |
580 | } |
581 | return $target; |