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