Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
95.33% |
776 / 814 |
|
76.36% |
42 / 55 |
CRAP | |
0.00% |
0 / 1 |
WatchedItemStore | |
95.33% |
776 / 814 |
|
76.36% |
42 / 55 |
190 | |
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% |
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% |
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.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\ReadOnlyMode; |
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 JobQueueGroup |
47 | */ |
48 | private $queueGroup; |
49 | |
50 | /** |
51 | * @var BagOStuff |
52 | */ |
53 | private $stash; |
54 | |
55 | /** |
56 | * @var ReadOnlyMode |
57 | */ |
58 | private $readOnlyMode; |
59 | |
60 | /** |
61 | * @var HashBagOStuff |
62 | */ |
63 | private $cache; |
64 | |
65 | /** |
66 | * @var HashBagOStuff |
67 | */ |
68 | private $latestUpdateCache; |
69 | |
70 | /** |
71 | * @var array[][] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key' |
72 | * The index is needed so that on mass changes all relevant items can be un-cached. |
73 | * For example: Clearing a users watchlist of all items or updating notification timestamps |
74 | * for all users watching a single target. |
75 | * @phan-var array<int,array<string,array<int,string>>> |
76 | */ |
77 | private $cacheIndex = []; |
78 | |
79 | /** |
80 | * @var callable|null |
81 | */ |
82 | private $deferredUpdatesAddCallableUpdateCallback; |
83 | |
84 | /** |
85 | * @var int |
86 | */ |
87 | private $updateRowsPerQuery; |
88 | |
89 | /** |
90 | * @var NamespaceInfo |
91 | */ |
92 | private $nsInfo; |
93 | |
94 | /** |
95 | * @var RevisionLookup |
96 | */ |
97 | private $revisionLookup; |
98 | |
99 | /** |
100 | * @var StatsdDataFactoryInterface |
101 | */ |
102 | private $stats; |
103 | |
104 | /** |
105 | * @var bool Correlates to $wgWatchlistExpiry feature flag. |
106 | */ |
107 | private $expiryEnabled; |
108 | |
109 | /** |
110 | * @var LinkBatchFactory |
111 | */ |
112 | private $linkBatchFactory; |
113 | |
114 | /** |
115 | * @var string|null Maximum configured relative expiry. |
116 | */ |
117 | private $maxExpiryDuration; |
118 | |
119 | /** @var float corresponds to $wgWatchlistPurgeRate value */ |
120 | private $watchlistPurgeRate; |
121 | |
122 | /** |
123 | * @param ServiceOptions $options |
124 | * @param ILBFactory $lbFactory |
125 | * @param JobQueueGroup $queueGroup |
126 | * @param BagOStuff $stash |
127 | * @param HashBagOStuff $cache |
128 | * @param ReadOnlyMode $readOnlyMode |
129 | * @param NamespaceInfo $nsInfo |
130 | * @param RevisionLookup $revisionLookup |
131 | * @param LinkBatchFactory $linkBatchFactory |
132 | */ |
133 | public function __construct( |
134 | ServiceOptions $options, |
135 | ILBFactory $lbFactory, |
136 | JobQueueGroup $queueGroup, |
137 | BagOStuff $stash, |
138 | HashBagOStuff $cache, |
139 | ReadOnlyMode $readOnlyMode, |
140 | NamespaceInfo $nsInfo, |
141 | RevisionLookup $revisionLookup, |
142 | LinkBatchFactory $linkBatchFactory |
143 | ) { |
144 | $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); |
145 | $this->updateRowsPerQuery = $options->get( MainConfigNames::UpdateRowsPerQuery ); |
146 | $this->expiryEnabled = $options->get( MainConfigNames::WatchlistExpiry ); |
147 | $this->maxExpiryDuration = $options->get( MainConfigNames::WatchlistExpiryMaxDuration ); |
148 | $this->watchlistPurgeRate = $options->get( MainConfigNames::WatchlistPurgeRate ); |
149 | |
150 | $this->lbFactory = $lbFactory; |
151 | $this->queueGroup = $queueGroup; |
152 | $this->stash = $stash; |
153 | $this->cache = $cache; |
154 | $this->readOnlyMode = $readOnlyMode; |
155 | $this->stats = new NullStatsdDataFactory(); |
156 | $this->deferredUpdatesAddCallableUpdateCallback = |
157 | [ DeferredUpdates::class, 'addCallableUpdate' ]; |
158 | $this->nsInfo = $nsInfo; |
159 | $this->revisionLookup = $revisionLookup; |
160 | $this->linkBatchFactory = $linkBatchFactory; |
161 | |
162 | $this->latestUpdateCache = new HashBagOStuff( [ 'maxKeys' => 3 ] ); |
163 | } |
164 | |
165 | /** |
166 | * @param StatsdDataFactoryInterface $stats |
167 | */ |
168 | public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) { |
169 | $this->stats = $stats; |
170 | } |
171 | |
172 | /** |
173 | * Overrides the DeferredUpdates::addCallableUpdate callback |
174 | * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined. |
175 | * |
176 | * @param callable $callback |
177 | * |
178 | * @see DeferredUpdates::addCallableUpdate for callback signiture |
179 | * |
180 | * @return ScopedCallback to reset the overridden value |
181 | * @throws MWException |
182 | */ |
183 | public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) { |
184 | if ( !defined( 'MW_PHPUNIT_TEST' ) ) { |
185 | throw new MWException( |
186 | 'Cannot override DeferredUpdates::addCallableUpdate callback in operation.' |
187 | ); |
188 | } |
189 | $previousValue = $this->deferredUpdatesAddCallableUpdateCallback; |
190 | $this->deferredUpdatesAddCallableUpdateCallback = $callback; |
191 | return new ScopedCallback( function () use ( $previousValue ) { |
192 | $this->deferredUpdatesAddCallableUpdateCallback = $previousValue; |
193 | } ); |
194 | } |
195 | |
196 | /** |
197 | * @param UserIdentity $user |
198 | * @param LinkTarget|PageIdentity $target |
199 | * @return string |
200 | */ |
201 | private function getCacheKey( UserIdentity $user, $target ): string { |
202 | return $this->cache->makeKey( |
203 | (string)$target->getNamespace(), |
204 | $target->getDBkey(), |
205 | (string)$user->getId() |
206 | ); |
207 | } |
208 | |
209 | /** |
210 | * @param WatchedItem $item |
211 | */ |
212 | private function cache( WatchedItem $item ) { |
213 | $user = $item->getUserIdentity(); |
214 | $target = $item->getTarget(); |
215 | $key = $this->getCacheKey( $user, $target ); |
216 | $this->cache->set( $key, $item ); |
217 | $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key; |
218 | $this->stats->increment( 'WatchedItemStore.cache' ); |
219 | } |
220 | |
221 | /** |
222 | * @param UserIdentity $user |
223 | * @param LinkTarget|PageIdentity $target |
224 | */ |
225 | private function uncache( UserIdentity $user, $target ) { |
226 | $this->cache->delete( $this->getCacheKey( $user, $target ) ); |
227 | unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] ); |
228 | $this->stats->increment( 'WatchedItemStore.uncache' ); |
229 | } |
230 | |
231 | /** |
232 | * @param LinkTarget|PageIdentity $target |
233 | */ |
234 | private function uncacheLinkTarget( $target ) { |
235 | $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' ); |
236 | if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) { |
237 | return; |
238 | } |
239 | foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) { |
240 | $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' ); |
241 | $this->cache->delete( $key ); |
242 | } |
243 | } |
244 | |
245 | /** |
246 | * @param UserIdentity $user |
247 | */ |
248 | private function uncacheUser( UserIdentity $user ) { |
249 | $this->stats->increment( 'WatchedItemStore.uncacheUser' ); |
250 | foreach ( $this->cacheIndex as $dbKeyArray ) { |
251 | foreach ( $dbKeyArray as $userArray ) { |
252 | if ( isset( $userArray[$user->getId()] ) ) { |
253 | $this->stats->increment( 'WatchedItemStore.uncacheUser.items' ); |
254 | $this->cache->delete( $userArray[$user->getId()] ); |
255 | } |
256 | } |
257 | } |
258 | |
259 | $pageSeenKey = $this->getPageSeenTimestampsKey( $user ); |
260 | $this->latestUpdateCache->delete( $pageSeenKey ); |
261 | $this->stash->delete( $pageSeenKey ); |
262 | } |
263 | |
264 | /** |
265 | * @param UserIdentity $user |
266 | * @param LinkTarget|PageIdentity $target |
267 | * |
268 | * @return WatchedItem|false |
269 | */ |
270 | private function getCached( UserIdentity $user, $target ) { |
271 | return $this->cache->get( $this->getCacheKey( $user, $target ) ); |
272 | } |
273 | |
274 | /** |
275 | * Helper method to deduplicate logic around queries that need to be modified |
276 | * if watchlist expiration is enabled |
277 | * |
278 | * @param SelectQueryBuilder $queryBuilder |
279 | * @param IDatabase $db |
280 | */ |
281 | private function modifyQueryBuilderForExpiry( |
282 | SelectQueryBuilder $queryBuilder, |
283 | IDatabase $db |
284 | ) { |
285 | if ( $this->expiryEnabled ) { |
286 | $queryBuilder->where( 'we_expiry IS NULL OR we_expiry > ' . $db->addQuotes( $db->timestamp() ) ); |
287 | $queryBuilder->leftJoin( 'watchlist_expiry', null, 'wl_id = we_item' ); |
288 | } |
289 | } |
290 | |
291 | /** |
292 | * Deletes ALL watched items for the given user when under |
293 | * $updateRowsPerQuery entries exist. |
294 | * |
295 | * @since 1.30 |
296 | * |
297 | * @param UserIdentity $user |
298 | * |
299 | * @return bool true on success, false when too many items are watched |
300 | */ |
301 | public function clearUserWatchedItems( UserIdentity $user ): bool { |
302 | if ( $this->mustClearWatchedItemsUsingJobQueue( $user ) ) { |
303 | return false; |
304 | } |
305 | |
306 | $dbw = $this->lbFactory->getPrimaryDatabase(); |
307 | |
308 | if ( $this->expiryEnabled ) { |
309 | $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ); |
310 | // First fetch the wl_ids. |
311 | $wlIds = $dbw->newSelectQueryBuilder() |
312 | ->select( 'wl_id' ) |
313 | ->from( 'watchlist' ) |
314 | ->where( [ 'wl_user' => $user->getId() ] ) |
315 | ->caller( __METHOD__ ) |
316 | ->fetchFieldValues(); |
317 | if ( $wlIds ) { |
318 | // Delete rows from both the watchlist and watchlist_expiry tables. |
319 | $dbw->delete( |
320 | 'watchlist', |
321 | [ 'wl_id' => $wlIds ], |
322 | __METHOD__ |
323 | ); |
324 | |
325 | $dbw->delete( |
326 | 'watchlist_expiry', |
327 | [ 'we_item' => $wlIds ], |
328 | __METHOD__ |
329 | ); |
330 | } |
331 | $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket ); |
332 | } else { |
333 | $dbw->delete( |
334 | 'watchlist', |
335 | [ 'wl_user' => $user->getId() ], |
336 | __METHOD__ |
337 | ); |
338 | } |
339 | |
340 | $this->uncacheAllItemsForUser( $user ); |
341 | |
342 | return true; |
343 | } |
344 | |
345 | /** |
346 | * @param UserIdentity $user |
347 | * @return bool |
348 | */ |
349 | public function mustClearWatchedItemsUsingJobQueue( UserIdentity $user ): bool { |
350 | return $this->countWatchedItems( $user ) > $this->updateRowsPerQuery; |
351 | } |
352 | |
353 | /** |
354 | * @param UserIdentity $user |
355 | */ |
356 | private function uncacheAllItemsForUser( UserIdentity $user ) { |
357 | $userId = $user->getId(); |
358 | foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) { |
359 | foreach ( $dbKeyIndex as $dbKey => $userIndex ) { |
360 | if ( array_key_exists( $userId, $userIndex ) ) { |
361 | $this->cache->delete( $userIndex[$userId] ); |
362 | unset( $this->cacheIndex[$ns][$dbKey][$userId] ); |
363 | } |
364 | } |
365 | } |
366 | |
367 | // Cleanup empty cache keys |
368 | foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) { |
369 | foreach ( $dbKeyIndex as $dbKey => $userIndex ) { |
370 | if ( empty( $this->cacheIndex[$ns][$dbKey] ) ) { |
371 | unset( $this->cacheIndex[$ns][$dbKey] ); |
372 | } |
373 | } |
374 | if ( empty( $this->cacheIndex[$ns] ) ) { |
375 | unset( $this->cacheIndex[$ns] ); |
376 | } |
377 | } |
378 | } |
379 | |
380 | /** |
381 | * Queues a job that will clear the users watchlist using the Job Queue. |
382 | * |
383 | * @since 1.31 |
384 | * |
385 | * @param UserIdentity $user |
386 | */ |
387 | public function clearUserWatchedItemsUsingJobQueue( UserIdentity $user ) { |
388 | $job = ClearUserWatchlistJob::newForUser( $user, $this->getMaxId() ); |
389 | $this->queueGroup->push( $job ); |
390 | } |
391 | |
392 | /** |
393 | * @inheritDoc |
394 | */ |
395 | public function maybeEnqueueWatchlistExpiryJob(): void { |
396 | if ( !$this->expiryEnabled ) { |
397 | // No need to purge expired entries if there are none |
398 | return; |
399 | } |
400 | |
401 | $max = mt_getrandmax(); |
402 | if ( mt_rand( 0, $max ) < $max * $this->watchlistPurgeRate ) { |
403 | // The higher the watchlist purge rate, the more likely we are to enqueue a job. |
404 | $this->queueGroup->lazyPush( new WatchlistExpiryJob() ); |
405 | } |
406 | } |
407 | |
408 | /** |
409 | * @since 1.31 |
410 | * @return int The maximum current wl_id |
411 | */ |
412 | public function getMaxId(): int { |
413 | return (int)$this->lbFactory->getReplicaDatabase()->newSelectQueryBuilder() |
414 | ->select( 'MAX(wl_id)' ) |
415 | ->from( 'watchlist' ) |
416 | ->caller( __METHOD__ ) |
417 | ->fetchField(); |
418 | } |
419 | |
420 | /** |
421 | * @since 1.31 |
422 | * @param UserIdentity $user |
423 | * @return int |
424 | */ |
425 | public function countWatchedItems( UserIdentity $user ): int { |
426 | $dbr = $this->lbFactory->getReplicaDatabase(); |
427 | $queryBuilder = $this->lbFactory->getReplicaDatabase()->newSelectQueryBuilder() |
428 | ->select( 'COUNT(*)' ) |
429 | ->from( 'watchlist' ) |
430 | ->where( [ 'wl_user' => $user->getId() ] ) |
431 | ->caller( __METHOD__ ); |
432 | |
433 | $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr ); |
434 | |
435 | return (int)$queryBuilder->fetchField(); |
436 | } |
437 | |
438 | /** |
439 | * @since 1.27 |
440 | * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36 |
441 | * @return int |
442 | */ |
443 | public function countWatchers( $target ): int { |
444 | $dbr = $this->lbFactory->getReplicaDatabase(); |
445 | $queryBuilder = $dbr->newSelectQueryBuilder() |
446 | ->select( 'COUNT(*)' ) |
447 | ->from( 'watchlist' ) |
448 | ->where( [ |
449 | 'wl_namespace' => $target->getNamespace(), |
450 | 'wl_title' => $target->getDBkey() |
451 | ] ) |
452 | ->caller( __METHOD__ ); |
453 | |
454 | $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr ); |
455 | |
456 | return (int)$queryBuilder->fetchField(); |
457 | } |
458 | |
459 | /** |
460 | * @since 1.27 |
461 | * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36 |
462 | * @param string|int $threshold |
463 | * @return int |
464 | */ |
465 | public function countVisitingWatchers( $target, $threshold ): int { |
466 | $dbr = $this->lbFactory->getReplicaDatabase(); |
467 | $queryBuilder = $dbr->newSelectQueryBuilder() |
468 | ->select( 'COUNT(*)' ) |
469 | ->from( 'watchlist' ) |
470 | ->where( [ |
471 | 'wl_namespace' => $target->getNamespace(), |
472 | 'wl_title' => $target->getDBkey(), |
473 | 'wl_notificationtimestamp >= ' . |
474 | $dbr->addQuotes( $dbr->timestamp( $threshold ) ) . |
475 | ' OR wl_notificationtimestamp IS NULL' |
476 | ] ) |
477 | ->caller( __METHOD__ ); |
478 | |
479 | $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr ); |
480 | |
481 | return (int)$queryBuilder->fetchField(); |
482 | } |
483 | |
484 | /** |
485 | * @param UserIdentity $user |
486 | * @param LinkTarget[]|PageIdentity[] $titles deprecated passing LinkTarget[] since 1.36 |
487 | * @return bool |
488 | */ |
489 | public function removeWatchBatchForUser( UserIdentity $user, array $titles ): bool { |
490 | if ( $this->readOnlyMode->isReadOnly() ) { |
491 | return false; |
492 | } |
493 | if ( !$user->isRegistered() ) { |
494 | return false; |
495 | } |
496 | if ( !$titles ) { |
497 | return true; |
498 | } |
499 | |
500 | $rows = $this->getTitleDbKeysGroupedByNamespace( $titles ); |
501 | $this->uncacheTitlesForUser( $user, $titles ); |
502 | |
503 | $dbw = $this->lbFactory->getPrimaryDatabase(); |
504 | $ticket = count( $titles ) > $this->updateRowsPerQuery ? |
505 | $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null; |
506 | $affectedRows = 0; |
507 | |
508 | // Batch delete items per namespace. |
509 | foreach ( $rows as $namespace => $namespaceTitles ) { |
510 | $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery ); |
511 | foreach ( $rowBatches as $toDelete ) { |
512 | // First fetch the wl_ids. |
513 | $wlIds = $dbw->newSelectQueryBuilder() |
514 | ->select( 'wl_id' ) |
515 | ->from( 'watchlist' ) |
516 | ->where( |
517 | [ |
518 | 'wl_user' => $user->getId(), |
519 | 'wl_namespace' => $namespace, |
520 | 'wl_title' => $toDelete |
521 | ] |
522 | ) |
523 | ->caller( __METHOD__ ) |
524 | ->fetchFieldValues(); |
525 | |
526 | if ( $wlIds ) { |
527 | // Delete rows from both the watchlist and watchlist_expiry tables. |
528 | $dbw->delete( |
529 | 'watchlist', |
530 | [ 'wl_id' => $wlIds ], |
531 | __METHOD__ |
532 | ); |
533 | $affectedRows += $dbw->affectedRows(); |
534 | |
535 | if ( $this->expiryEnabled ) { |
536 | $dbw->delete( |
537 | 'watchlist_expiry', |
538 | [ 'we_item' => $wlIds ], |
539 | __METHOD__ |
540 | ); |
541 | $affectedRows += $dbw->affectedRows(); |
542 | } |
543 | } |
544 | |
545 | if ( $ticket ) { |
546 | $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket ); |
547 | } |
548 | } |
549 | } |
550 | |
551 | return (bool)$affectedRows; |
552 | } |
553 | |
554 | /** |
555 | * @since 1.27 |
556 | * @param LinkTarget[]|PageIdentity[] $targets deprecated passing LinkTarget[] since 1.36 |
557 | * @param array $options Supported options are: |
558 | * - 'minimumWatchers': filter for pages that have at least a minimum number of watchers |
559 | * @return array |
560 | */ |
561 | public function countWatchersMultiple( array $targets, array $options = [] ): array { |
562 | $linkTargets = array_map( static function ( $target ) { |
563 | if ( !$target instanceof LinkTarget ) { |
564 | return new TitleValue( $target->getNamespace(), $target->getDBkey() ); |
565 | } |
566 | return $target; |
567 | }, $targets ); |
568 | $lb = $this->linkBatchFactory->newLinkBatch( $linkTargets ); |
569 | $dbr = $this->lbFactory->getReplicaDatabase(); |
570 | $queryBuilder = $dbr->newSelectQueryBuilder(); |
571 | $queryBuilder |
572 | ->select( [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ] ) |
573 | ->from( 'watchlist' ) |
574 | ->where( [ $lb->constructSet( 'wl', $dbr ) ] ) |
575 | ->groupBy( [ 'wl_namespace', 'wl_title' ] ) |
576 | ->caller( __METHOD__ ); |
577 | |
578 | if ( array_key_exists( 'minimumWatchers', $options ) ) { |
579 | $queryBuilder->having( 'COUNT(*) >= ' . (int)$options['minimumWatchers'] ); |
580 | } |
581 | |
582 | $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr ); |
583 | |
584 | $res = $queryBuilder->fetchResultSet(); |
585 | |
586 | $watchCounts = []; |
587 | foreach ( $targets as $linkTarget ) { |
588 | $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0; |
589 | } |
590 | |
591 | foreach ( $res as $row ) { |
592 | $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers; |
593 | } |
594 | |
595 | return $watchCounts; |
596 | } |
597 | |
598 | /** |
599 | * @since 1.27 |
600 | * @param array $targetsWithVisitThresholds array of LinkTarget[]|PageIdentity[] (not type |
601 | * hinted since it annoys phan) - deprecated passing LinkTarget[] since 1.36 |
602 | * @param int|null $minimumWatchers |
603 | * @return int[][] two dimensional array, first is namespace, second is database key, |
604 | * value is the number of watchers |
605 | */ |
606 | public function countVisitingWatchersMultiple( |
607 | array $targetsWithVisitThresholds, |
608 | $minimumWatchers = null |
609 | ): array { |
610 | if ( $targetsWithVisitThresholds === [] ) { |
611 | // No titles requested => no results returned |
612 | return []; |
613 | } |
614 | |