Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
95.49% |
762 / 798 |
|
74.55% |
41 / 55 |
CRAP | |
0.00% |
0 / 1 |
WatchedItemStore | |
95.49% |
762 / 798 |
|
74.55% |
41 / 55 |
191 | |
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.30% |
26 / 27 |
|
0.00% |
0 / 1 |
9 | |||
updateOrDeleteExpiries | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
4 | |||
updateExpiries | |
95.65% |
22 / 23 |
|
0.00% |
0 / 1 |
2 | |||
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\IResultWrapper; |
20 | use Wikimedia\Rdbms\ReadOnlyMode; |
21 | use Wikimedia\Rdbms\SelectQueryBuilder; |
22 | use Wikimedia\ScopedCallback; |
23 | |
24 | /** |
25 | * Storage layer class for WatchedItems. |
26 | * Database interaction & caching |
27 | * TODO caching should be factored out into a CachingWatchedItemStore class |
28 | * |
29 | * @author Addshore |
30 | * @since 1.27 |
31 | */ |
32 | class WatchedItemStore implements WatchedItemStoreInterface, StatsdAwareInterface { |
33 | |
34 | /** |
35 | * @internal For use by ServiceWiring |
36 | */ |
37 | public const CONSTRUCTOR_OPTIONS = [ |
38 | MainConfigNames::UpdateRowsPerQuery, |
39 | MainConfigNames::WatchlistExpiry, |
40 | MainConfigNames::WatchlistExpiryMaxDuration, |
41 | MainConfigNames::WatchlistPurgeRate, |
42 | ]; |
43 | |
44 | /** |
45 | * @var ILBFactory |
46 | */ |
47 | private $lbFactory; |
48 | |
49 | /** |
50 | * @var JobQueueGroup |
51 | */ |
52 | private $queueGroup; |
53 | |
54 | /** |
55 | * @var BagOStuff |
56 | */ |
57 | private $stash; |
58 | |
59 | /** |
60 | * @var ReadOnlyMode |
61 | */ |
62 | private $readOnlyMode; |
63 | |
64 | /** |
65 | * @var HashBagOStuff |
66 | */ |
67 | private $cache; |
68 | |
69 | /** |
70 | * @var HashBagOStuff |
71 | */ |
72 | private $latestUpdateCache; |
73 | |
74 | /** |
75 | * @var array[][] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key' |
76 | * The index is needed so that on mass changes all relevant items can be un-cached. |
77 | * For example: Clearing a users watchlist of all items or updating notification timestamps |
78 | * for all users watching a single target. |
79 | * @phan-var array<int,array<string,array<int,string>>> |
80 | */ |
81 | private $cacheIndex = []; |
82 | |
83 | /** |
84 | * @var callable|null |
85 | */ |
86 | private $deferredUpdatesAddCallableUpdateCallback; |
87 | |
88 | /** |
89 | * @var int |
90 | */ |
91 | private $updateRowsPerQuery; |
92 | |
93 | /** |
94 | * @var NamespaceInfo |
95 | */ |
96 | private $nsInfo; |
97 | |
98 | /** |
99 | * @var RevisionLookup |
100 | */ |
101 | private $revisionLookup; |
102 | |
103 | /** |
104 | * @var StatsdDataFactoryInterface |
105 | */ |
106 | private $stats; |
107 | |
108 | /** |
109 | * @var bool Correlates to $wgWatchlistExpiry feature flag. |
110 | */ |
111 | private $expiryEnabled; |
112 | |
113 | /** |
114 | * @var LinkBatchFactory |
115 | */ |
116 | private $linkBatchFactory; |
117 | |
118 | /** |
119 | * @var string|null Maximum configured relative expiry. |
120 | */ |
121 | private $maxExpiryDuration; |
122 | |
123 | /** @var float corresponds to $wgWatchlistPurgeRate value */ |
124 | private $watchlistPurgeRate; |
125 | |
126 | /** |
127 | * @param ServiceOptions $options |
128 | * @param ILBFactory $lbFactory |
129 | * @param JobQueueGroup $queueGroup |
130 | * @param BagOStuff $stash |
131 | * @param HashBagOStuff $cache |
132 | * @param ReadOnlyMode $readOnlyMode |
133 | * @param NamespaceInfo $nsInfo |
134 | * @param RevisionLookup $revisionLookup |
135 | * @param LinkBatchFactory $linkBatchFactory |
136 | */ |
137 | public function __construct( |
138 | ServiceOptions $options, |
139 | ILBFactory $lbFactory, |
140 | JobQueueGroup $queueGroup, |
141 | BagOStuff $stash, |
142 | HashBagOStuff $cache, |
143 | ReadOnlyMode $readOnlyMode, |
144 | NamespaceInfo $nsInfo, |
145 | RevisionLookup $revisionLookup, |
146 | LinkBatchFactory $linkBatchFactory |
147 | ) { |
148 | $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); |
149 | $this->updateRowsPerQuery = $options->get( MainConfigNames::UpdateRowsPerQuery ); |
150 | $this->expiryEnabled = $options->get( MainConfigNames::WatchlistExpiry ); |
151 | $this->maxExpiryDuration = $options->get( MainConfigNames::WatchlistExpiryMaxDuration ); |
152 | $this->watchlistPurgeRate = $options->get( MainConfigNames::WatchlistPurgeRate ); |
153 | |
154 | $this->lbFactory = $lbFactory; |
155 | $this->queueGroup = $queueGroup; |
156 | $this->stash = $stash; |
157 | $this->cache = $cache; |
158 | $this->readOnlyMode = $readOnlyMode; |
159 | $this->stats = new NullStatsdDataFactory(); |
160 | $this->deferredUpdatesAddCallableUpdateCallback = |
161 | [ DeferredUpdates::class, 'addCallableUpdate' ]; |
162 | $this->nsInfo = $nsInfo; |
163 | $this->revisionLookup = $revisionLookup; |
164 | $this->linkBatchFactory = $linkBatchFactory; |
165 | |
166 | $this->latestUpdateCache = new HashBagOStuff( [ 'maxKeys' => 3 ] ); |
167 | } |
168 | |
169 | /** |
170 | * @param StatsdDataFactoryInterface $stats |
171 | */ |
172 | public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) { |
173 | $this->stats = $stats; |
174 | } |
175 | |
176 | /** |
177 | * Overrides the DeferredUpdates::addCallableUpdate callback |
178 | * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined. |
179 | * |
180 | * @param callable $callback |
181 | * |
182 | * @see DeferredUpdates::addCallableUpdate for callback signiture |
183 | * |
184 | * @return ScopedCallback to reset the overridden value |
185 | */ |
186 | public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) { |
187 | if ( !defined( 'MW_PHPUNIT_TEST' ) ) { |
188 | throw new LogicException( |
189 | 'Cannot override DeferredUpdates::addCallableUpdate callback in operation.' |
190 | ); |
191 | } |
192 | $previousValue = $this->deferredUpdatesAddCallableUpdateCallback; |
193 | $this->deferredUpdatesAddCallableUpdateCallback = $callback; |
194 | return new ScopedCallback( function () use ( $previousValue ) { |
195 | $this->deferredUpdatesAddCallableUpdateCallback = $previousValue; |
196 | } ); |
197 | } |
198 | |
199 | /** |
200 | * @param UserIdentity $user |
201 | * @param LinkTarget|PageIdentity $target |
202 | * @return string |
203 | */ |
204 | private function getCacheKey( UserIdentity $user, $target ): string { |
205 | return $this->cache->makeKey( |
206 | (string)$target->getNamespace(), |
207 | $target->getDBkey(), |
208 | (string)$user->getId() |
209 | ); |
210 | } |
211 | |
212 | /** |
213 | * @param WatchedItem $item |
214 | */ |
215 | private function cache( WatchedItem $item ) { |
216 | $user = $item->getUserIdentity(); |
217 | $target = $item->getTarget(); |
218 | $key = $this->getCacheKey( $user, $target ); |
219 | $this->cache->set( $key, $item ); |
220 | $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key; |
221 | $this->stats->increment( 'WatchedItemStore.cache' ); |
222 | } |
223 | |
224 | /** |
225 | * @param UserIdentity $user |
226 | * @param LinkTarget|PageIdentity $target |
227 | */ |
228 | private function uncache( UserIdentity $user, $target ) { |
229 | $this->cache->delete( $this->getCacheKey( $user, $target ) ); |
230 | unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] ); |
231 | $this->stats->increment( 'WatchedItemStore.uncache' ); |
232 | } |
233 | |
234 | /** |
235 | * @param LinkTarget|PageIdentity $target |
236 | */ |
237 | private function uncacheLinkTarget( $target ) { |
238 | $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' ); |
239 | if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) { |
240 | return; |
241 | } |
242 | foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) { |
243 | $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' ); |
244 | $this->cache->delete( $key ); |
245 | } |
246 | } |
247 | |
248 | /** |
249 | * @param UserIdentity $user |
250 | */ |
251 | private function uncacheUser( UserIdentity $user ) { |
252 | $this->stats->increment( 'WatchedItemStore.uncacheUser' ); |
253 | foreach ( $this->cacheIndex as $dbKeyArray ) { |
254 | foreach ( $dbKeyArray as $userArray ) { |
255 | if ( isset( $userArray[$user->getId()] ) ) { |
256 | $this->stats->increment( 'WatchedItemStore.uncacheUser.items' ); |
257 | $this->cache->delete( $userArray[$user->getId()] ); |
258 | } |
259 | } |
260 | } |
261 | |
262 | $pageSeenKey = $this->getPageSeenTimestampsKey( $user ); |
263 | $this->latestUpdateCache->delete( $pageSeenKey ); |
264 | $this->stash->delete( $pageSeenKey ); |
265 | } |
266 | |
267 | /** |
268 | * @param UserIdentity $user |
269 | * @param LinkTarget|PageIdentity $target |
270 | * |
271 | * @return WatchedItem|false |
272 | */ |
273 | private function getCached( UserIdentity $user, $target ) { |
274 | return $this->cache->get( $this->getCacheKey( $user, $target ) ); |
275 | } |
276 | |
277 | /** |
278 | * Helper method to deduplicate logic around queries that need to be modified |
279 | * if watchlist expiration is enabled |
280 | * |
281 | * @param SelectQueryBuilder $queryBuilder |
282 | * @param IDatabase $db |
283 | */ |
284 | private function modifyQueryBuilderForExpiry( |
285 | SelectQueryBuilder $queryBuilder, |
286 | IDatabase $db |
287 | ) { |
288 | if ( $this->expiryEnabled ) { |
289 | $queryBuilder->where( 'we_expiry IS NULL OR we_expiry > ' . $db->addQuotes( $db->timestamp() ) ); |
290 | $queryBuilder->leftJoin( 'watchlist_expiry', null, 'wl_id = we_item' ); |
291 | } |
292 | } |
293 | |
294 | /** |
295 | * Deletes ALL watched items for the given user when under |
296 | * $updateRowsPerQuery entries exist. |
297 | * |
298 | * @since 1.30 |
299 | * |
300 | * @param UserIdentity $user |
301 | * |
302 | * @return bool true on success, false when too many items are watched |
303 | */ |
304 | public function clearUserWatchedItems( UserIdentity $user ): bool { |
305 | if ( $this->mustClearWatchedItemsUsingJobQueue( $user ) ) { |
306 | return false; |
307 | } |
308 | |
309 | $dbw = $this->lbFactory->getPrimaryDatabase(); |
310 | |
311 | if ( $this->expiryEnabled ) { |
312 | $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ); |
313 | // First fetch the wl_ids. |
314 | $wlIds = $dbw->newSelectQueryBuilder() |
315 | ->select( 'wl_id' ) |
316 | ->from( 'watchlist' ) |
317 | ->where( [ 'wl_user' => $user->getId() ] ) |
318 | ->caller( __METHOD__ ) |
319 | ->fetchFieldValues(); |
320 | if ( $wlIds ) { |
321 | // Delete rows from both the watchlist and watchlist_expiry tables. |
322 | $dbw->newDeleteQueryBuilder() |
323 | ->deleteFrom( 'watchlist' ) |
324 | ->where( [ 'wl_id' => $wlIds ] ) |
325 | ->caller( __METHOD__ )->execute(); |
326 | |
327 | $dbw->newDeleteQueryBuilder() |
328 | ->deleteFrom( 'watchlist_expiry' ) |
329 | ->where( [ 'we_item' => $wlIds ] ) |
330 | ->caller( __METHOD__ )->execute(); |
331 | } |
332 | $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket ); |
333 | } else { |
334 | $dbw->newDeleteQueryBuilder() |
335 | ->deleteFrom( 'watchlist' ) |
336 | ->where( [ 'wl_user' => $user->getId() ] ) |
337 | ->caller( __METHOD__ )->execute(); |
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 ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) { |
491 | return false; |
492 | } |
493 | if ( !$titles ) { |
494 | return true; |
495 | } |
496 | |
497 | $rows = $this->getTitleDbKeysGroupedByNamespace( $titles ); |
498 | $this->uncacheTitlesForUser( $user, $titles ); |
499 | |
500 | $dbw = $this->lbFactory->getPrimaryDatabase(); |
501 | $ticket = count( $titles ) > $this->updateRowsPerQuery ? |
502 | $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null; |
503 | $affectedRows = 0; |
504 | |
505 | // Batch delete items per namespace. |
506 | foreach ( $rows as $namespace => $namespaceTitles ) { |
507 | $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery ); |
508 | foreach ( $rowBatches as $toDelete ) { |
509 | // First fetch the wl_ids. |
510 | $wlIds = $dbw->newSelectQueryBuilder() |
511 | ->select( 'wl_id' ) |
512 | ->from( 'watchlist' ) |
513 | ->where( |
514 | [ |
515 | 'wl_user' => $user->getId(), |
516 | 'wl_namespace' => $namespace, |
517 | 'wl_title' => $toDelete |
518 | ] |
519 | ) |
520 | ->caller( __METHOD__ ) |
521 | ->fetchFieldValues(); |
522 | |
523 | if ( $wlIds ) { |
524 | // Delete rows from both the watchlist and watchlist_expiry tables. |
525 | $dbw->newDeleteQueryBuilder() |
526 | ->deleteFrom( 'watchlist' ) |
527 | ->where( [ 'wl_id' => $wlIds ] ) |
528 | ->caller( __METHOD__ )->execute(); |
529 | $affectedRows += $dbw->affectedRows(); |
530 | |
531 | if ( $this->expiryEnabled ) { |
532 | $dbw->newDeleteQueryBuilder() |
533 | ->deleteFrom( 'watchlist_expiry' ) |
534 | ->where( [ 'we_item' => $wlIds ] ) |
535 | ->caller( __METHOD__ )->execute(); |
536 | $affectedRows += $dbw->affectedRows(); |
537 | } |
538 | } |
539 | |
540 | if ( $ticket ) { |
541 | $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket ); |
542 | } |
543 | } |
544 | } |
545 | |
546 | return (bool)$affectedRows; |
547 | } |
548 | |
549 | /** |
550 | * @since 1.27 |
551 | * @param LinkTarget[]|PageIdentity[] $targets deprecated passing LinkTarget[] since 1.36 |
552 | * @param array $options Supported options are: |
553 | * - 'minimumWatchers': filter for pages that have at least a minimum number of watchers |
554 | * @return array |
555 | */ |
556 | public function countWatchersMultiple( array $targets, array $options = [] ): array { |
557 | $linkTargets = array_map( static function ( $target ) { |
558 | if ( !$target instanceof LinkTarget ) { |
559 | return new TitleValue( $target->getNamespace(), $target->getDBkey() ); |
560 | } |
561 | return $target; |
562 | }, $targets ); |
563 | $lb = $this->linkBatchFactory->newLinkBatch( $linkTargets ); |
564 | $dbr = $this->lbFactory->getReplicaDatabase(); |
565 | $queryBuilder = $dbr->newSelectQueryBuilder(); |
566 | $queryBuilder |
567 | ->select( [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ] ) |
568 | ->from( 'watchlist' ) |
569 | ->where( [ $lb->constructSet( 'wl', $dbr ) ] ) |
570 | ->groupBy( [ 'wl_namespace', 'wl_title' ] ) |
571 | ->caller( __METHOD__ ); |
572 | |
573 | if ( array_key_exists( 'minimumWatchers', $options ) ) { |
574 | $queryBuilder->having( 'COUNT(*) >= ' . (int)$options['minimumWatchers'] ); |
575 | } |
576 | |
577 | $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr ); |
578 | |
579 | $res = $queryBuilder->fetchResultSet(); |
580 | |
581 | $watchCounts = []; |
582 | foreach ( $targets as $linkTarget ) { |
583 | $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0; |
584 | } |
585 | |
586 | foreach ( $res as $row ) { |
587 | $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers; |
588 | } |
589 | |
590 | return $watchCounts; |
591 | } |
592 | |
593 | /** |
594 | * @since 1.27 |
595 | * @param array $targetsWithVisitThresholds array of LinkTarget[]|PageIdentity[] (not type |
596 | * hinted since it annoys phan) - deprecated passing LinkTarget[] since 1.36 |
597 | * @param int|null $minimumWatchers |
598 | * @return int[][] two dimensional array, first is namespace, second is database key, |
599 | * value is the number of watchers |
600 | */ |
601 | public function countVisitingWatchersMultiple( |
602 | array $targetsWithVisitThresholds, |
603 | $minimumWatchers = null |
604 | ): array { |
605 | if ( $targetsWithVisitThresholds === [] ) { |
606 | // No titles requested => no results returned |
607 | return []; |
608 | } |
609 | |
610 | $dbr |