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