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