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