wiki.techinc.nl/includes/watchlist/WatchedItemStore.php
thiemowmde dca4931b42 Make use of the ??= and ?? operators where it makes sense
This touches various production classes and maintenance scripts.
The code should do the exact same as before. The main benefit is that
the syntax avoids any repetition.

Change-Id: I5c552125469f4d7fb5b0fe494d198951b05eb35f
2024-08-26 09:26:36 +02:00

1863 lines
54 KiB
PHP

<?php
namespace MediaWiki\Watchlist;
use DateInterval;
use JobQueueGroup;
use LogicException;
use MapCacheLRU;
use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Revision\RevisionLookup;
use MediaWiki\Title\NamespaceInfo;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\UserIdentity;
use MediaWiki\Utils\MWTimestamp;
use stdClass;
use Wikimedia\Assert\Assert;
use Wikimedia\ObjectCache\BagOStuff;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\ParamValidator\TypeDef\ExpiryDef;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\ILBFactory;
use Wikimedia\Rdbms\IReadableDatabase;
use Wikimedia\Rdbms\IResultWrapper;
use Wikimedia\Rdbms\ReadOnlyMode;
use Wikimedia\Rdbms\SelectQueryBuilder;
use Wikimedia\ScopedCallback;
use Wikimedia\Stats\StatsFactory;
/**
* Storage layer class for WatchedItems.
* Database interaction & caching
* TODO caching should be factored out into a CachingWatchedItemStore class
*
* @author Addshore
* @since 1.27
*/
class WatchedItemStore implements WatchedItemStoreInterface {
/**
* @internal For use by ServiceWiring
*/
public const CONSTRUCTOR_OPTIONS = [
MainConfigNames::UpdateRowsPerQuery,
MainConfigNames::WatchlistExpiry,
MainConfigNames::WatchlistExpiryMaxDuration,
MainConfigNames::WatchlistPurgeRate,
];
/**
* @var ILBFactory
*/
private $lbFactory;
/**
* @var JobQueueGroup
*/
private $queueGroup;
/**
* @var BagOStuff
*/
private $stash;
/**
* @var ReadOnlyMode
*/
private $readOnlyMode;
/**
* @var HashBagOStuff
*/
private $cache;
/**
* @var HashBagOStuff
*/
private $latestUpdateCache;
/**
* @var array[][] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key'
* The index is needed so that on mass changes all relevant items can be un-cached.
* For example: Clearing a users watchlist of all items or updating notification timestamps
* for all users watching a single target.
* @phan-var array<int,array<string,array<int,string>>>
*/
private $cacheIndex = [];
/**
* @var callable|null
*/
private $deferredUpdatesAddCallableUpdateCallback;
/**
* @var int
*/
private $updateRowsPerQuery;
/**
* @var NamespaceInfo
*/
private $nsInfo;
/**
* @var RevisionLookup
*/
private $revisionLookup;
/**
* @var bool Correlates to $wgWatchlistExpiry feature flag.
*/
private $expiryEnabled;
/**
* @var LinkBatchFactory
*/
private $linkBatchFactory;
/** @var StatsFactory */
private $statsFactory;
/**
* @var string|null Maximum configured relative expiry.
*/
private $maxExpiryDuration;
/** @var float corresponds to $wgWatchlistPurgeRate value */
private $watchlistPurgeRate;
/**
* @param ServiceOptions $options
* @param ILBFactory $lbFactory
* @param JobQueueGroup $queueGroup
* @param BagOStuff $stash
* @param HashBagOStuff $cache
* @param ReadOnlyMode $readOnlyMode
* @param NamespaceInfo $nsInfo
* @param RevisionLookup $revisionLookup
* @param LinkBatchFactory $linkBatchFactory
* @param StatsFactory $statsFactory
*/
public function __construct(
ServiceOptions $options,
ILBFactory $lbFactory,
JobQueueGroup $queueGroup,
BagOStuff $stash,
HashBagOStuff $cache,
ReadOnlyMode $readOnlyMode,
NamespaceInfo $nsInfo,
RevisionLookup $revisionLookup,
LinkBatchFactory $linkBatchFactory,
StatsFactory $statsFactory
) {
$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
$this->updateRowsPerQuery = $options->get( MainConfigNames::UpdateRowsPerQuery );
$this->expiryEnabled = $options->get( MainConfigNames::WatchlistExpiry );
$this->maxExpiryDuration = $options->get( MainConfigNames::WatchlistExpiryMaxDuration );
$this->watchlistPurgeRate = $options->get( MainConfigNames::WatchlistPurgeRate );
$this->lbFactory = $lbFactory;
$this->queueGroup = $queueGroup;
$this->stash = $stash;
$this->cache = $cache;
$this->readOnlyMode = $readOnlyMode;
$this->deferredUpdatesAddCallableUpdateCallback =
[ DeferredUpdates::class, 'addCallableUpdate' ];
$this->nsInfo = $nsInfo;
$this->revisionLookup = $revisionLookup;
$this->linkBatchFactory = $linkBatchFactory;
$this->statsFactory = $statsFactory;
$this->latestUpdateCache = new HashBagOStuff( [ 'maxKeys' => 3 ] );
}
/**
* Overrides the DeferredUpdates::addCallableUpdate callback
* This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
*
* @param callable $callback
*
* @see DeferredUpdates::addCallableUpdate for callback signiture
*
* @return ScopedCallback to reset the overridden value
*/
public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) {
if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
throw new LogicException(
'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
);
}
$previousValue = $this->deferredUpdatesAddCallableUpdateCallback;
$this->deferredUpdatesAddCallableUpdateCallback = $callback;
return new ScopedCallback( function () use ( $previousValue ) {
$this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
} );
}
/**
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $target
* @return string
*/
private function getCacheKey( UserIdentity $user, $target ): string {
return $this->cache->makeKey(
(string)$target->getNamespace(),
$target->getDBkey(),
(string)$user->getId()
);
}
/**
* @param WatchedItem $item
*/
private function cache( WatchedItem $item ) {
$user = $item->getUserIdentity();
$target = $item->getTarget();
$key = $this->getCacheKey( $user, $target );
$this->cache->set( $key, $item );
$this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
$this->statsFactory->getCounter( 'WatchedItemStore_cache_total' )
->copyToStatsdAt( 'WatchedItemStore.cache' )
->increment();
}
/**
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $target
*/
private function uncache( UserIdentity $user, $target ) {
$this->cache->delete( $this->getCacheKey( $user, $target ) );
unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
$this->statsFactory->getCounter( 'WatchedItemStore_uncache_total' )
->copyToStatsdAt( 'WatchedItemStore.uncache' )
->increment();
}
/**
* @param LinkTarget|PageIdentity $target
*/
private function uncacheLinkTarget( $target ) {
$this->statsFactory->getCounter( 'WatchedItemStore_uncacheLinkTarget_total' )
->copyToStatsdAt( 'WatchedItemStore.uncacheLinkTarget' )
->increment();
if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
return;
}
$uncacheLinkTargetItemsTotal = $this->statsFactory
->getCounter( 'WatchedItemStore_uncacheLinkTarget_items_total' )
->copyToStatsdAt( 'WatchedItemStore.uncacheLinkTarget.items' );
foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
$uncacheLinkTargetItemsTotal->increment();
$this->cache->delete( $key );
}
}
/**
* @param UserIdentity $user
*/
private function uncacheUser( UserIdentity $user ) {
$this->statsFactory->getCounter( 'WatchedItemStore_uncacheUser_total' )
->copyToStatsdAt( 'WatchedItemStore.uncacheUser' )
->increment();
$uncacheUserItemsTotal = $this->statsFactory->getCounter( 'WatchedItemStore_uncacheUser_items_total' )
->copyToStatsdAt( 'WatchedItemStore.uncacheUser.items' );
foreach ( $this->cacheIndex as $dbKeyArray ) {
foreach ( $dbKeyArray as $userArray ) {
if ( isset( $userArray[$user->getId()] ) ) {
$uncacheUserItemsTotal->increment();
$this->cache->delete( $userArray[$user->getId()] );
}
}
}
$pageSeenKey = $this->getPageSeenTimestampsKey( $user );
$this->latestUpdateCache->delete( $pageSeenKey );
$this->stash->delete( $pageSeenKey );
}
/**
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $target
*
* @return WatchedItem|false
*/
private function getCached( UserIdentity $user, $target ) {
return $this->cache->get( $this->getCacheKey( $user, $target ) );
}
/**
* Helper method to deduplicate logic around queries that need to be modified
* if watchlist expiration is enabled
*
* @param SelectQueryBuilder $queryBuilder
* @param IReadableDatabase $db
*/
private function modifyQueryBuilderForExpiry(
SelectQueryBuilder $queryBuilder,
IReadableDatabase $db
) {
if ( $this->expiryEnabled ) {
$queryBuilder->where( $db->expr( 'we_expiry', '=', null )->or( 'we_expiry', '>', $db->timestamp() ) );
$queryBuilder->leftJoin( 'watchlist_expiry', null, 'wl_id = we_item' );
}
}
/**
* Deletes ALL watched items for the given user when under
* $updateRowsPerQuery entries exist.
*
* @since 1.30
*
* @param UserIdentity $user
*
* @return bool true on success, false when too many items are watched
*/
public function clearUserWatchedItems( UserIdentity $user ): bool {
if ( $this->mustClearWatchedItemsUsingJobQueue( $user ) ) {
return false;
}
$dbw = $this->lbFactory->getPrimaryDatabase();
if ( $this->expiryEnabled ) {
$ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
// First fetch the wl_ids.
$wlIds = $dbw->newSelectQueryBuilder()
->select( 'wl_id' )
->from( 'watchlist' )
->where( [ 'wl_user' => $user->getId() ] )
->caller( __METHOD__ )
->fetchFieldValues();
if ( $wlIds ) {
// Delete rows from both the watchlist and watchlist_expiry tables.
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'watchlist' )
->where( [ 'wl_id' => $wlIds ] )
->caller( __METHOD__ )->execute();
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'watchlist_expiry' )
->where( [ 'we_item' => $wlIds ] )
->caller( __METHOD__ )->execute();
}
$this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
} else {
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'watchlist' )
->where( [ 'wl_user' => $user->getId() ] )
->caller( __METHOD__ )->execute();
}
$this->uncacheAllItemsForUser( $user );
return true;
}
/**
* @param UserIdentity $user
* @return bool
*/
public function mustClearWatchedItemsUsingJobQueue( UserIdentity $user ): bool {
return $this->countWatchedItems( $user ) > $this->updateRowsPerQuery;
}
/**
* @param UserIdentity $user
*/
private function uncacheAllItemsForUser( UserIdentity $user ) {
$userId = $user->getId();
foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
if ( array_key_exists( $userId, $userIndex ) ) {
$this->cache->delete( $userIndex[$userId] );
unset( $this->cacheIndex[$ns][$dbKey][$userId] );
}
}
}
// Cleanup empty cache keys
foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
if ( empty( $this->cacheIndex[$ns][$dbKey] ) ) {
unset( $this->cacheIndex[$ns][$dbKey] );
}
}
if ( empty( $this->cacheIndex[$ns] ) ) {
unset( $this->cacheIndex[$ns] );
}
}
}
/**
* Queues a job that will clear the users watchlist using the Job Queue.
*
* @since 1.31
*
* @param UserIdentity $user
*/
public function clearUserWatchedItemsUsingJobQueue( UserIdentity $user ) {
$job = ClearUserWatchlistJob::newForUser( $user, $this->getMaxId() );
$this->queueGroup->push( $job );
}
/**
* @inheritDoc
*/
public function maybeEnqueueWatchlistExpiryJob(): void {
if ( !$this->expiryEnabled ) {
// No need to purge expired entries if there are none
return;
}
$max = mt_getrandmax();
if ( mt_rand( 0, $max ) < $max * $this->watchlistPurgeRate ) {
// The higher the watchlist purge rate, the more likely we are to enqueue a job.
$this->queueGroup->lazyPush( new WatchlistExpiryJob() );
}
}
/**
* @since 1.31
* @return int The maximum current wl_id
*/
public function getMaxId(): int {
return (int)$this->lbFactory->getReplicaDatabase()->newSelectQueryBuilder()
->select( 'MAX(wl_id)' )
->from( 'watchlist' )
->caller( __METHOD__ )
->fetchField();
}
/**
* @since 1.31
* @param UserIdentity $user
* @return int
*/
public function countWatchedItems( UserIdentity $user ): int {
$dbr = $this->lbFactory->getReplicaDatabase();
$queryBuilder = $dbr->newSelectQueryBuilder()
->select( 'COUNT(*)' )
->from( 'watchlist' )
->where( [ 'wl_user' => $user->getId() ] )
->caller( __METHOD__ );
$this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
return (int)$queryBuilder->fetchField();
}
/**
* @since 1.27
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
* @return int
*/
public function countWatchers( $target ): int {
$dbr = $this->lbFactory->getReplicaDatabase();
$queryBuilder = $dbr->newSelectQueryBuilder()
->select( 'COUNT(*)' )
->from( 'watchlist' )
->where( [
'wl_namespace' => $target->getNamespace(),
'wl_title' => $target->getDBkey()
] )
->caller( __METHOD__ );
$this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
return (int)$queryBuilder->fetchField();
}
/**
* @since 1.27
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
* @param string|int $threshold
* @return int
*/
public function countVisitingWatchers( $target, $threshold ): int {
$dbr = $this->lbFactory->getReplicaDatabase();
$queryBuilder = $dbr->newSelectQueryBuilder()
->select( 'COUNT(*)' )
->from( 'watchlist' )
->where( [
'wl_namespace' => $target->getNamespace(),
'wl_title' => $target->getDBkey(),
$dbr->expr( 'wl_notificationtimestamp', '>=', $dbr->timestamp( $threshold ) )
->or( 'wl_notificationtimestamp', '=', null )
] )
->caller( __METHOD__ );
$this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
return (int)$queryBuilder->fetchField();
}
/**
* @param UserIdentity $user
* @param LinkTarget[]|PageIdentity[] $titles deprecated passing LinkTarget[] since 1.36
* @return bool
*/
public function removeWatchBatchForUser( UserIdentity $user, array $titles ): bool {
if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) {
return false;
}
if ( !$titles ) {
return true;
}
$rows = $this->getTitleDbKeysGroupedByNamespace( $titles );
$this->uncacheTitlesForUser( $user, $titles );
$dbw = $this->lbFactory->getPrimaryDatabase();
$ticket = count( $titles ) > $this->updateRowsPerQuery ?
$this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
$affectedRows = 0;
// Batch delete items per namespace.
foreach ( $rows as $namespace => $namespaceTitles ) {
$rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
foreach ( $rowBatches as $toDelete ) {
// First fetch the wl_ids.
$wlIds = $dbw->newSelectQueryBuilder()
->select( 'wl_id' )
->from( 'watchlist' )
->where(
[
'wl_user' => $user->getId(),
'wl_namespace' => $namespace,
'wl_title' => $toDelete
]
)
->caller( __METHOD__ )
->fetchFieldValues();
if ( $wlIds ) {
// Delete rows from both the watchlist and watchlist_expiry tables.
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'watchlist' )
->where( [ 'wl_id' => $wlIds ] )
->caller( __METHOD__ )->execute();
$affectedRows += $dbw->affectedRows();
if ( $this->expiryEnabled ) {
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'watchlist_expiry' )
->where( [ 'we_item' => $wlIds ] )
->caller( __METHOD__ )->execute();
$affectedRows += $dbw->affectedRows();
}
}
if ( $ticket ) {
$this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
}
}
}
return (bool)$affectedRows;
}
/**
* @since 1.27
* @param LinkTarget[]|PageIdentity[] $targets deprecated passing LinkTarget[] since 1.36
* @param array $options Supported options are:
* - 'minimumWatchers': filter for pages that have at least a minimum number of watchers
* @return array
*/
public function countWatchersMultiple( array $targets, array $options = [] ): array {
$linkTargets = array_map( static function ( $target ) {
if ( !$target instanceof LinkTarget ) {
return new TitleValue( $target->getNamespace(), $target->getDBkey() );
}
return $target;
}, $targets );
$lb = $this->linkBatchFactory->newLinkBatch( $linkTargets );
$dbr = $this->lbFactory->getReplicaDatabase();
$queryBuilder = $dbr->newSelectQueryBuilder();
$queryBuilder
->select( [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ] )
->from( 'watchlist' )
->where( [ $lb->constructSet( 'wl', $dbr ) ] )
->groupBy( [ 'wl_namespace', 'wl_title' ] )
->caller( __METHOD__ );
if ( array_key_exists( 'minimumWatchers', $options ) ) {
$queryBuilder->having( 'COUNT(*) >= ' . (int)$options['minimumWatchers'] );
}
$this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
$res = $queryBuilder->fetchResultSet();
$watchCounts = [];
foreach ( $targets as $linkTarget ) {
$watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
}
foreach ( $res as $row ) {
$watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
}
return $watchCounts;
}
/**
* @since 1.27
* @param array $targetsWithVisitThresholds array of LinkTarget[]|PageIdentity[] (not type
* hinted since it annoys phan) - deprecated passing LinkTarget[] since 1.36
* @param int|null $minimumWatchers
* @return int[][] two dimensional array, first is namespace, second is database key,
* value is the number of watchers
*/
public function countVisitingWatchersMultiple(
array $targetsWithVisitThresholds,
$minimumWatchers = null
): array {
if ( $targetsWithVisitThresholds === [] ) {
// No titles requested => no results returned
return [];
}
$dbr = $this->lbFactory->getReplicaDatabase();
$queryBuilder = $dbr->newSelectQueryBuilder()
->select( [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ] )
->from( 'watchlist' )
->where( [ $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds ) ] )
->groupBy( [ 'wl_namespace', 'wl_title' ] )
->caller( __METHOD__ );
if ( $minimumWatchers !== null ) {
$queryBuilder->having( 'COUNT(*) >= ' . (int)$minimumWatchers );
}
$this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
$res = $queryBuilder->fetchResultSet();
$watcherCounts = [];
foreach ( $targetsWithVisitThresholds as [ $target ] ) {
/** @var LinkTarget|PageIdentity $target */
$watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
}
foreach ( $res as $row ) {
$watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
}
return $watcherCounts;
}
/**
* Generates condition for the query used in a batch count visiting watchers.
*
* @param IReadableDatabase $db
* @param array $targetsWithVisitThresholds array of pairs (LinkTarget|PageIdentity,
* last visit threshold) - deprecated passing LinkTarget since 1.36
* @return string
*/
private function getVisitingWatchersCondition(
IReadableDatabase $db,
array $targetsWithVisitThresholds
): string {
$missingTargets = [];
$namespaceConds = [];
foreach ( $targetsWithVisitThresholds as [ $target, $threshold ] ) {
if ( $threshold === null ) {
$missingTargets[] = $target;
continue;
}
/** @var LinkTarget|PageIdentity $target */
$namespaceConds[$target->getNamespace()][] = $db->expr( 'wl_title', '=', $target->getDBkey() )
->andExpr(
$db->expr( 'wl_notificationtimestamp', '>=', $db->timestamp( $threshold ) )
->or( 'wl_notificationtimestamp', '=', null )
);
}
$conds = [];
foreach ( $namespaceConds as $namespace => $pageConds ) {
$conds[] = $db->makeList( [
'wl_namespace = ' . $namespace,
'(' . $db->makeList( $pageConds, LIST_OR ) . ')'
], LIST_AND );
}
if ( $missingTargets ) {
$lb = $this->linkBatchFactory->newLinkBatch( $missingTargets );
$conds[] = $lb->constructSet( 'wl', $db );
}
return $db->makeList( $conds, LIST_OR );
}
/**
* @since 1.27
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
* @return WatchedItem|false
*/
public function getWatchedItem( UserIdentity $user, $target ) {
if ( !$user->isRegistered() ) {
return false;
}
$cached = $this->getCached( $user, $target );
if ( $cached && !$cached->isExpired() ) {
$this->statsFactory->getCounter( 'WatchedItemStore_getWatchedItem_accesses_total' )
->setLabel( 'status', 'hit' )
->copyToStatsdAt( 'WatchedItemStore.getWatchedItem.cached' )
->increment();
return $cached;
}
$this->statsFactory->getCounter( 'WatchedItemStore_getWatchedItem_accesses_total' )
->setLabel( 'status', 'miss' )
->copyToStatsdAt( 'WatchedItemStore.getWatchedItem.load' )
->increment();
return $this->loadWatchedItem( $user, $target );
}
/**
* @since 1.27
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
* @return WatchedItem|false
*/
public function loadWatchedItem( UserIdentity $user, $target ) {
$item = $this->loadWatchedItemsBatch( $user, [ $target ] );
return $item ? $item[0] : false;
}
/**
* @since 1.36
* @param UserIdentity $user
* @param LinkTarget[]|PageIdentity[] $targets deprecated passing LinkTarget[] since 1.36
* @return WatchedItem[]|false
*/
public function loadWatchedItemsBatch( UserIdentity $user, array $targets ) {
// Only registered user can have a watchlist
if ( !$user->isRegistered() ) {
return false;
}
$dbr = $this->lbFactory->getReplicaDatabase();
$rows = $this->fetchWatchedItems(
$dbr,
$user,
[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
[],
$targets
);
if ( !$rows ) {
return false;
}
$items = [];
foreach ( $rows as $row ) {
// TODO: convert to PageIdentity
$target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
$item = $this->getWatchedItemFromRow( $user, $target, $row );
$this->cache( $item );
$items[] = $item;
}
return $items;
}
/**
* @since 1.27
* @param UserIdentity $user
* @param array $options Supported options are:
* - 'forWrite': whether to use the primary database instead of a replica
* - 'sort': how to sort the titles, either SORT_ASC or SORT_DESC
* - 'sortByExpiry': whether to also sort results by expiration, with temporarily watched titles
* above titles watched indefinitely and titles expiring soonest at the top
* @return WatchedItem[]
*/
public function getWatchedItemsForUser( UserIdentity $user, array $options = [] ): array {
$options += [ 'forWrite' => false ];
$vars = [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ];
$orderBy = [];
if ( $options['forWrite'] ) {
$db = $this->lbFactory->getPrimaryDatabase();
} else {
$db = $this->lbFactory->getReplicaDatabase();
}
if ( array_key_exists( 'sort', $options ) ) {
Assert::parameter(
( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
'$options[\'sort\']',
'must be SORT_ASC or SORT_DESC'
);
$orderBy[] = "wl_namespace {$options['sort']}";
if ( $this->expiryEnabled
&& array_key_exists( 'sortByExpiry', $options )
&& $options['sortByExpiry']
) {
// Add `wl_has_expiry` column to allow sorting by watched titles that have an expiration date first.
$vars['wl_has_expiry'] = $db->conditional( 'we_expiry IS NULL', '0', '1' );
// Display temporarily watched titles first.
// Order by expiration date, with the titles that will expire soonest at the top.
$orderBy[] = "wl_has_expiry DESC";
$orderBy[] = "we_expiry ASC";
}
$orderBy[] = "wl_title {$options['sort']}";
}
$res = $this->fetchWatchedItems(
$db,
$user,
$vars,
$orderBy
);
$watchedItems = [];
foreach ( $res as $row ) {
// TODO: convert to PageIdentity
$target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
// @todo: Should we add these to the process cache?
$watchedItems[] = $this->getWatchedItemFromRow( $user, $target, $row );
}
return $watchedItems;
}
/**
* Construct a new WatchedItem given a row from watchlist/watchlist_expiry.
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
* @param \stdClass $row
* @return WatchedItem
*/
private function getWatchedItemFromRow(
UserIdentity $user,
$target,
stdClass $row
): WatchedItem {
return new WatchedItem(
$user,
$target,
$this->getLatestNotificationTimestamp(
$row->wl_notificationtimestamp, $user, $target ),
wfTimestampOrNull( TS_ISO_8601, $row->we_expiry ?? null )
);
}
/**
* Fetches either a single or all watched items for the given user, or a specific set of items.
* If a $target is given, IDatabase::selectRow() is called, otherwise select().
* If $wgWatchlistExpiry is enabled, expired items are not returned.
*
* @param IReadableDatabase $db
* @param UserIdentity $user
* @param array $vars we_expiry is added when $wgWatchlistExpiry is enabled.
* @param array $orderBy array of columns
* @param LinkTarget|LinkTarget[]|PageIdentity|PageIdentity[]|null $target null if selecting all
* watched items - deprecated passing LinkTarget or LinkTarget[] since 1.36
* @return IResultWrapper|\stdClass|false
*/
private function fetchWatchedItems(
IReadableDatabase $db,
UserIdentity $user,
array $vars,
array $orderBy = [],
$target = null
) {
$dbMethod = 'select';
$queryBuilder = $db->newSelectQueryBuilder()
->select( $vars )
->from( 'watchlist' )
->where( [ 'wl_user' => $user->getId() ] )
->caller( __METHOD__ );
if ( $target ) {
if ( $target instanceof LinkTarget || $target instanceof PageIdentity ) {
$queryBuilder->where( [
'wl_namespace' => $target->getNamespace(),
'wl_title' => $target->getDBkey(),
] );
$dbMethod = 'selectRow';
} else {
$titleConds = [];
foreach ( $target as $linkTarget ) {
$titleConds[] = $db->makeList(
[
'wl_namespace' => $linkTarget->getNamespace(),
'wl_title' => $linkTarget->getDBkey(),
],
$db::LIST_AND
);
}
$queryBuilder->where( $db->makeList( $titleConds, $db::LIST_OR ) );
}
}
$this->modifyQueryBuilderForExpiry( $queryBuilder, $db );
if ( $this->expiryEnabled ) {
$queryBuilder->field( 'we_expiry' );
}
if ( $orderBy ) {
$queryBuilder->orderBy( $orderBy );
}
if ( $dbMethod == 'selectRow' ) {
return $queryBuilder->fetchRow();
}
return $queryBuilder->fetchResultSet();
}
/**
* @since 1.27
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
* @return bool
*/
public function isWatched( UserIdentity $user, $target ): bool {
return (bool)$this->getWatchedItem( $user, $target );
}
/**
* Check if the user is temporarily watching the page.
* @since 1.35
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
* @return bool
*/
public function isTempWatched( UserIdentity $user, $target ): bool {
$item = $this->getWatchedItem( $user, $target );
return $item && $item->getExpiry();
}
/**
* @since 1.27
* @param UserIdentity $user
* @param LinkTarget[] $targets
* @return (string|null|false)[][] two dimensional array, first is namespace, second is database key,
* value is the notification timestamp or null, or false if not available
*/
public function getNotificationTimestampsBatch( UserIdentity $user, array $targets ): array {
$timestamps = [];
foreach ( $targets as $target ) {
$timestamps[$target->getNamespace()][$target->getDBkey()] = false;
}
if ( !$user->isRegistered() ) {
return $timestamps;
}
$targetsToLoad = [];
foreach ( $targets as $target ) {
$cachedItem = $this->getCached( $user, $target );
if ( $cachedItem ) {
$timestamps[$target->getNamespace()][$target->getDBkey()] =
$cachedItem->getNotificationTimestamp();
} else {
$targetsToLoad[] = $target;
}
}
if ( !$targetsToLoad ) {
return $timestamps;
}
$dbr = $this->lbFactory->getReplicaDatabase();
$lb = $this->linkBatchFactory->newLinkBatch( $targetsToLoad );
$res = $dbr->newSelectQueryBuilder()
->select( [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ] )
->from( 'watchlist' )
->where( [
$lb->constructSet( 'wl', $dbr ),
'wl_user' => $user->getId(),
] )
->caller( __METHOD__ )
->fetchResultSet();
foreach ( $res as $row ) {
// TODO: convert to PageIdentity
$target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
$timestamps[$row->wl_namespace][$row->wl_title] =
$this->getLatestNotificationTimestamp(
$row->wl_notificationtimestamp, $user, $target );
}
return $timestamps;
}
/**
* @since 1.27 Method added.
* @since 1.35 Accepts $expiry parameter.
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
* @param string|null $expiry Optional expiry in any format acceptable to wfTimestamp().
* null will not create an expiry, or leave it unchanged should one already exist.
*/
public function addWatch( UserIdentity $user, $target, ?string $expiry = null ) {
$this->addWatchBatchForUser( $user, [ $target ], $expiry );
if ( $this->expiryEnabled && !$expiry ) {
// When re-watching a page with a null $expiry, any existing expiry is left unchanged.
// However we must re-fetch the preexisting expiry or else the cached WatchedItem will
// incorrectly have a null expiry. Note that loadWatchedItem() does the caching.
// See T259379
$this->loadWatchedItem( $user, $target );
} else {
// Create a new WatchedItem and add it to the process cache.
// In this case we don't need to re-fetch the expiry.
$expiry = ExpiryDef::normalizeUsingMaxExpiry( $expiry, $this->maxExpiryDuration, TS_ISO_8601 );
$item = new WatchedItem(
$user,
$target,
null,
$expiry
);
$this->cache( $item );
}
}
/**
* Add multiple items to the user's watchlist.
* If you know you're adding a single page (and/or its talk page) use self::addWatch(),
* since it will add the WatchedItem to the process cache.
*
* @since 1.27 Method added.
* @since 1.35 Accepts $expiry parameter.
* @param UserIdentity $user
* @param LinkTarget[] $targets
* @param string|null $expiry Optional expiry in a format acceptable to wfTimestamp(),
* null will not create expiries, or leave them unchanged should they already exist.
* @return bool Whether database transactions were performed.
*/
public function addWatchBatchForUser(
UserIdentity $user,
array $targets,
?string $expiry = null
): bool {
// Only registered user can have a watchlist
if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) {
return false;
}
if ( !$targets ) {
return true;
}
$expiry = ExpiryDef::normalizeUsingMaxExpiry( $expiry, $this->maxExpiryDuration, TS_ISO_8601 );
$rows = [];
foreach ( $targets as $target ) {
$rows[] = [
'wl_user' => $user->getId(),
'wl_namespace' => $target->getNamespace(),
'wl_title' => $target->getDBkey(),
'wl_notificationtimestamp' => null,
];
$this->uncache( $user, $target );
}
$dbw = $this->lbFactory->getPrimaryDatabase();
$ticket = count( $targets ) > $this->updateRowsPerQuery ?
$this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
$affectedRows = 0;
$rowBatches = array_chunk( $rows, $this->updateRowsPerQuery );
foreach ( $rowBatches as $toInsert ) {
// Use INSERT IGNORE to avoid overwriting the notification timestamp
// if there's already an entry for this page
$dbw->newInsertQueryBuilder()
->insertInto( 'watchlist' )
->ignore()
->rows( $toInsert )
->caller( __METHOD__ )->execute();
$affectedRows += $dbw->affectedRows();
if ( $this->expiryEnabled ) {
$affectedRows += $this->updateOrDeleteExpiries( $dbw, $user->getId(), $toInsert, $expiry );
}
if ( $ticket ) {
$this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
}
}
return (bool)$affectedRows;
}
/**
* Insert/update expiries, or delete them if the expiry is 'infinity'.
*
* @param IDatabase $dbw
* @param int $userId
* @param array $rows
* @param string|null $expiry
* @return int Number of affected rows.
*/
private function updateOrDeleteExpiries(
IDatabase $dbw,
int $userId,
array $rows,
?string $expiry = null
): int {
if ( !$expiry ) {
// if expiry is null (shouldn't change), 0 rows affected.
return 0;
}
// Build the giant `(...) OR (...)` part to be used with WHERE.
$conds = [];
foreach ( $rows as $row ) {
$conds[] = $dbw->makeList(
[
'wl_user' => $userId,
'wl_namespace' => $row['wl_namespace'],
'wl_title' => $row['wl_title']
],
$dbw::LIST_AND
);
}
$cond = $dbw->makeList( $conds, $dbw::LIST_OR );
if ( wfIsInfinity( $expiry ) ) {
// Rows should be deleted rather than updated.
$dbw->deleteJoin(
'watchlist_expiry',
'watchlist',
'we_item',
'wl_id',
[ $cond ],
__METHOD__
);
return $dbw->affectedRows();
}
return $this->updateExpiries( $dbw, $expiry, $cond );
}
/**
* Update the expiries for items found with the given $cond.
* @param IDatabase $dbw
* @param string $expiry
* @param string $cond
* @return int Number of affected rows.
*/
private function updateExpiries( IDatabase $dbw, string $expiry, string $cond ): int {
// First fetch the wl_ids from the watchlist table.
// We'd prefer to do a INSERT/SELECT in the same query with IDatabase::insertSelect(),
// but it doesn't allow us to use the "ON DUPLICATE KEY UPDATE" clause.
$wlIds = $dbw->newSelectQueryBuilder()
->select( 'wl_id' )
->from( 'watchlist' )
->where( $cond )
->caller( __METHOD__ )
->fetchFieldValues();
if ( !$wlIds ) {
return 0;
}
$expiry = $dbw->timestamp( $expiry );
$weRows = [];
foreach ( $wlIds as $wlId ) {
$weRows[] = [
'we_item' => $wlId,
'we_expiry' => $expiry
];
}
// Insert into watchlist_expiry, updating the expiry for duplicate rows.
$dbw->newInsertQueryBuilder()
->insertInto( 'watchlist_expiry' )
->rows( $weRows )
->onDuplicateKeyUpdate()
->uniqueIndexFields( [ 'we_item' ] )
->set( [ 'we_expiry' => $expiry ] )
->caller( __METHOD__ )->execute();
return $dbw->affectedRows();
}
/**
* @since 1.27
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
* @return bool
*/
public function removeWatch( UserIdentity $user, $target ): bool {
return $this->removeWatchBatchForUser( $user, [ $target ] );
}
/**
* Set the "last viewed" timestamps for certain titles on a user's watchlist.
*
* If the $targets parameter is omitted or set to [], this method simply wraps
* resetAllNotificationTimestampsForUser(), and in that case you should instead call that method
* directly; support for omitting $targets is for backwards compatibility.
*
* If $targets is omitted or set to [], timestamps will be updated for every title on the user's
* watchlist, and this will be done through a DeferredUpdate. If $targets is a non-empty array,
* only the specified titles will be updated, and this will be done immediately (not deferred).
*
* @since 1.27
* @param UserIdentity $user
* @param string|int $timestamp Value to set the "last viewed" timestamp to (null to clear)
* @param LinkTarget[] $targets Titles to set the timestamp for; [] means the entire watchlist
* @return bool
*/
public function setNotificationTimestampsForUser(
UserIdentity $user,
$timestamp,
array $targets = []
): bool {
// Only registered user can have a watchlist
if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) {
return false;
}
if ( !$targets ) {
// Backwards compatibility
$this->resetAllNotificationTimestampsForUser( $user, $timestamp );
return true;
}
$rows = $this->getTitleDbKeysGroupedByNamespace( $targets );
$dbw = $this->lbFactory->getPrimaryDatabase();
if ( $timestamp !== null ) {
$timestamp = $dbw->timestamp( $timestamp );
}
$ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
$affectedSinceWait = 0;
// Batch update items per namespace
foreach ( $rows as $namespace => $namespaceTitles ) {
$rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
foreach ( $rowBatches as $toUpdate ) {
// First fetch the wl_ids.
$wlIds = $dbw->newSelectQueryBuilder()
->select( 'wl_id' )
->from( 'watchlist' )
->where( [
'wl_user' => $user->getId(),
'wl_namespace' => $namespace,
'wl_title' => $toUpdate
] )
->caller( __METHOD__ )
->fetchFieldValues();
if ( $wlIds ) {
$wlIds = array_map( 'intval', $wlIds );
$dbw->newUpdateQueryBuilder()
->update( 'watchlist' )
->set( [ 'wl_notificationtimestamp' => $timestamp ] )
->where( [ 'wl_id' => $wlIds ] )
->caller( __METHOD__ )->execute();
$affectedSinceWait += $dbw->affectedRows();
// Wait for replication every time we've touched updateRowsPerQuery rows
if ( $affectedSinceWait >= $this->updateRowsPerQuery ) {
$this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
$affectedSinceWait = 0;
}
}
}
}
$this->uncacheUser( $user );
return true;
}
/**
* @param string|null $timestamp
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
* @return bool|string|null
*/
public function getLatestNotificationTimestamp(
$timestamp,
UserIdentity $user,
$target
) {
$timestamp = wfTimestampOrNull( TS_MW, $timestamp );
if ( $timestamp === null ) {
return null; // no notification
}
$seenTimestamps = $this->getPageSeenTimestamps( $user );
if ( $seenTimestamps ) {
$seenKey = $this->getPageSeenKey( $target );
if ( isset( $seenTimestamps[$seenKey] ) && $seenTimestamps[$seenKey] >= $timestamp ) {
// If a reset job did not yet run, then the "seen" timestamp will be higher
return null;
}
}
return $timestamp;
}
/**
* Schedule a DeferredUpdate that sets all of the "last viewed" timestamps for a given user
* to the same value.
* @param UserIdentity $user
* @param string|int|null $timestamp Value to set all timestamps to, null to clear them
*/
public function resetAllNotificationTimestampsForUser( UserIdentity $user, $timestamp = null ) {
// Only registered user can have a watchlist
if ( !$user->isRegistered() ) {
return;
}
// If the page is watched by the user (or may be watched), update the timestamp
$job = new ClearWatchlistNotificationsJob( [
'userId' => $user->getId(), 'timestamp' => $timestamp, 'casTime' => time()
] );
// Try to run this post-send
// Calls DeferredUpdates::addCallableUpdate in normal operation
call_user_func(
$this->deferredUpdatesAddCallableUpdateCallback,
static function () use ( $job ) {
$job->run();
}
);
}
/**
* Update wl_notificationtimestamp for all watching users except the editor
* @since 1.27
* @param UserIdentity $editor
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
* @param string|int $timestamp
* @return int[]
*/
public function updateNotificationTimestamp(
UserIdentity $editor,
$target,
$timestamp
): array {
$dbw = $this->lbFactory->getPrimaryDatabase();
$queryBuilder = $dbw->newSelectQueryBuilder()
->select( [ 'wl_id', 'wl_user' ] )
->from( 'watchlist' )
->where(
[
'wl_user != ' . $editor->getId(),
'wl_namespace' => $target->getNamespace(),
'wl_title' => $target->getDBkey(),
'wl_notificationtimestamp' => null,
]
)
->caller( __METHOD__ );
$this->modifyQueryBuilderForExpiry( $queryBuilder, $dbw );
$res = $queryBuilder->fetchResultSet();
$watchers = [];
$wlIds = [];
foreach ( $res as $row ) {
$watchers[] = (int)$row->wl_user;
$wlIds[] = (int)$row->wl_id;
}
if ( $wlIds ) {
$fname = __METHOD__;
// Try to run this post-send
// Calls DeferredUpdates::addCallableUpdate in normal operation
call_user_func(
$this->deferredUpdatesAddCallableUpdateCallback,
function () use ( $timestamp, $wlIds, $target, $fname ) {
$dbw = $this->lbFactory->getPrimaryDatabase();
$ticket = $this->lbFactory->getEmptyTransactionTicket( $fname );
$wlIdsChunks = array_chunk( $wlIds, $this->updateRowsPerQuery );
foreach ( $wlIdsChunks as $wlIdsChunk ) {
$dbw->newUpdateQueryBuilder()
->update( 'watchlist' )
->set( [ 'wl_notificationtimestamp' => $dbw->timestamp( $timestamp ) ] )
->where( [ 'wl_id' => $wlIdsChunk ] )
->caller( $fname )->execute();
if ( count( $wlIdsChunks ) > 1 ) {
$this->lbFactory->commitAndWaitForReplication( $fname, $ticket );
}
}
$this->uncacheLinkTarget( $target );
},
DeferredUpdates::POSTSEND,
$dbw
);
}
return $watchers;
}
/**
* @since 1.27
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $title deprecated passing LinkTarget since 1.36
* @param string $force
* @param int $oldid
* @return bool
*/
public function resetNotificationTimestamp(
UserIdentity $user,
$title,
$force = '',
$oldid = 0
): bool {
$time = time();
// Only registered user can have a watchlist
if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) {
return false;
}
$item = null;
if ( $force != 'force' ) {
$item = $this->getWatchedItem( $user, $title );
if ( !$item || $item->getNotificationTimestamp() === null ) {
return false;
}
}
// Get the timestamp (TS_MW) of this revision to track the latest one seen
$id = $oldid;
$seenTime = null;
if ( !$id ) {
$latestRev = $this->revisionLookup->getRevisionByTitle( $title );
if ( $latestRev ) {
$id = $latestRev->getId();
// Save a DB query
$seenTime = $latestRev->getTimestamp();
}
}
if ( $seenTime === null ) {
// @phan-suppress-next-line PhanTypeMismatchArgumentNullable getId does not return null here
$seenTime = $this->revisionLookup->getTimestampFromId( $id );
}
// Mark the item as read immediately in lightweight storage
$this->stash->merge(
$this->getPageSeenTimestampsKey( $user ),
function ( $cache, $key, $current ) use ( $title, $seenTime ) {
if ( !$current ) {
$value = new MapCacheLRU( 300 );
} elseif ( is_array( $current ) ) {
$value = MapCacheLRU::newFromArray( $current, 300 );
} else {
// Backwards compatibility for T282105
$value = $current;
}
$subKey = $this->getPageSeenKey( $title );
if ( $seenTime > $value->get( $subKey ) ) {
// Revision is newer than the last one seen
$value->set( $subKey, $seenTime );
$this->latestUpdateCache->set( $key, $value->toArray(), BagOStuff::TTL_PROC_LONG );
} elseif ( $seenTime === false ) {
// Revision does not exist
$value->set( $subKey, wfTimestamp( TS_MW ) );
$this->latestUpdateCache->set( $key,
$value->toArray(),
BagOStuff::TTL_PROC_LONG );
} else {
return false; // nothing to update
}
return $value->toArray();
},
BagOStuff::TTL_HOUR
);
// If the page is watched by the user (or may be watched), update the timestamp
// ActivityUpdateJob accepts both LinkTarget and PageReference
$job = new ActivityUpdateJob(
$title,
[
'type' => 'updateWatchlistNotification',
'userid' => $user->getId(),
'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
'curTime' => $time
]
);
// Try to enqueue this post-send
$this->queueGroup->lazyPush( $job );
$this->uncache( $user, $title );
return true;
}
/**
* @param UserIdentity $user
* @return array|null The map contains prefixed title keys and TS_MW values
*/
private function getPageSeenTimestamps( UserIdentity $user ) {
$key = $this->getPageSeenTimestampsKey( $user );
$cache = $this->latestUpdateCache->getWithSetCallback(
$key,
BagOStuff::TTL_PROC_LONG,
function () use ( $key ) {
return $this->stash->get( $key ) ?: null;
}
);
// Backwards compatibility for T282105
if ( $cache instanceof MapCacheLRU ) {
$cache = $cache->toArray();
}
return $cache;
}
/**
* @param UserIdentity $user
* @return string
*/
private function getPageSeenTimestampsKey( UserIdentity $user ): string {
return $this->stash->makeGlobalKey(
'watchlist-recent-updates',
$this->lbFactory->getLocalDomainID(),
$user->getId()
);
}
/**
* @param LinkTarget|PageIdentity $target
* @return string
*/
private function getPageSeenKey( $target ): string {
return "{$target->getNamespace()}:{$target->getDBkey()}";
}
/**
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $title deprecated passing LinkTarget since 1.36
* @param WatchedItem|null $item
* @param string $force
* @param int|false $oldid The ID of the last revision that the user viewed
* @return string|null|false
*/
private function getNotificationTimestamp(
UserIdentity $user,
$title,
$item,
$force,
$oldid
) {
if ( !$oldid ) {
// No oldid given, assuming latest revision; clear the timestamp.
return null;
}
$oldRev = $this->revisionLookup->getRevisionById( $oldid );
if ( !$oldRev ) {
// Oldid given but does not exist (probably deleted)
return false;
}
$nextRev = $this->revisionLookup->getNextRevision( $oldRev );
if ( !$nextRev ) {
// Oldid given and is the latest revision for this title; clear the timestamp.
return null;
}
$item ??= $this->loadWatchedItem( $user, $title );
if ( !$item ) {
// This can only happen if $force is enabled.
return null;
}
// Oldid given and isn't the latest; update the timestamp.
// This will result in no further notification emails being sent!
$notificationTimestamp = $this->revisionLookup->getTimestampFromId( $oldid );
// @FIXME: this should use getTimestamp() for consistency with updates on new edits
// $notificationTimestamp = $nextRev->getTimestamp(); // first unseen revision timestamp
// We need to go one second to the future because of various strict comparisons
// throughout the codebase
$ts = new MWTimestamp( $notificationTimestamp );
$ts->timestamp->add( new DateInterval( 'PT1S' ) );
$notificationTimestamp = $ts->getTimestamp( TS_MW );
if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
if ( $force != 'force' ) {
return false;
} else {
// This is a little silly…
return $item->getNotificationTimestamp();
}
}
return $notificationTimestamp;
}
/**
* @since 1.27
* @param UserIdentity $user
* @param int|null $unreadLimit
* @return int|bool
*/
public function countUnreadNotifications( UserIdentity $user, $unreadLimit = null ) {
$queryBuilder = $this->lbFactory->getReplicaDatabase()->newSelectQueryBuilder()
->select( '1' )
->from( 'watchlist' )
->where( [
'wl_user' => $user->getId(),
'wl_notificationtimestamp IS NOT NULL'
] )
->caller( __METHOD__ );
if ( $unreadLimit !== null ) {
$unreadLimit = (int)$unreadLimit;
$queryBuilder->limit( $unreadLimit );
}
$rowCount = $queryBuilder->fetchRowCount();
if ( $unreadLimit === null ) {
return $rowCount;
}
if ( $rowCount >= $unreadLimit ) {
return true;
}
return $rowCount;
}
/**
* @since 1.27
* @param LinkTarget|PageIdentity $oldTarget deprecated passing LinkTarget since 1.36
* @param LinkTarget|PageIdentity $newTarget deprecated passing LinkTarget since 1.36
*/
public function duplicateAllAssociatedEntries( $oldTarget, $newTarget ) {
// Duplicate first the subject page, then the talk page
// TODO: convert to PageIdentity
$this->duplicateEntry(
new TitleValue( $this->nsInfo->getSubject( $oldTarget->getNamespace() ), $oldTarget->getDBkey() ),
new TitleValue( $this->nsInfo->getSubject( $newTarget->getNamespace() ), $newTarget->getDBkey() )
);
$this->duplicateEntry(
new TitleValue( $this->nsInfo->getTalk( $oldTarget->getNamespace() ), $oldTarget->getDBkey() ),
new TitleValue( $this->nsInfo->getTalk( $newTarget->getNamespace() ), $newTarget->getDBkey() )
);
}
/**
* @since 1.27
* @param LinkTarget|PageIdentity $oldTarget deprecated passing LinkTarget since 1.36
* @param LinkTarget|PageIdentity $newTarget deprecated passing LinkTarget since 1.36
*/
public function duplicateEntry( $oldTarget, $newTarget ) {
$dbw = $this->lbFactory->getPrimaryDatabase();
$result = $this->fetchWatchedItemsForPage( $dbw, $oldTarget );
$newNamespace = $newTarget->getNamespace();
$newDBkey = $newTarget->getDBkey();
# Construct array to replace into the watchlist
$values = [];
$expiries = [];
foreach ( $result as $row ) {
$values[] = [
'wl_user' => $row->wl_user,
'wl_namespace' => $newNamespace,
'wl_title' => $newDBkey,
'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
];
if ( $this->expiryEnabled && $row->we_expiry ) {
$expiries[$row->wl_user] = $row->we_expiry;
}
}
if ( !$values ) {
return;
}
// Perform a replace on the watchlist table rows.
// Note that multi-row replace is very efficient for MySQL but may be inefficient for
// some other DBMSes, mostly due to poor simulation by us.
$dbw->newReplaceQueryBuilder()
->replaceInto( 'watchlist' )
->uniqueIndexFields( [ 'wl_user', 'wl_namespace', 'wl_title' ] )
->rows( $values )
->caller( __METHOD__ )->execute();
if ( $this->expiryEnabled ) {
$this->updateExpiriesAfterMove( $dbw, $expiries, $newNamespace, $newDBkey );
}
}
/**
* @param IReadableDatabase $dbr
* @param LinkTarget|PageIdentity $target
* @return IResultWrapper
*/
private function fetchWatchedItemsForPage(
IReadableDatabase $dbr,
$target
): IResultWrapper {
$queryBuilder = $dbr->newSelectQueryBuilder()
->select( [ 'wl_user', 'wl_notificationtimestamp' ] )
->from( 'watchlist' )
->where( [
'wl_namespace' => $target->getNamespace(),
'wl_title' => $target->getDBkey(),
] )
->caller( __METHOD__ )
->forUpdate();
if ( $this->expiryEnabled ) {
$queryBuilder->leftJoin( 'watchlist_expiry', null, [ 'wl_id = we_item' ] )
->field( 'we_expiry' );
}
return $queryBuilder->fetchResultSet();
}
/**
* @param IDatabase $dbw
* @param array $expiries
* @param int $namespace
* @param string $dbKey
*/
private function updateExpiriesAfterMove(
IDatabase $dbw,
array $expiries,
int $namespace,
string $dbKey
): void {
DeferredUpdates::addCallableUpdate(
function ( $fname ) use ( $dbw, $expiries, $namespace, $dbKey ) {
// First fetch new wl_ids.
$res = $dbw->newSelectQueryBuilder()
->select( [ 'wl_user', 'wl_id' ] )
->from( 'watchlist' )
->where( [
'wl_namespace' => $namespace,
'wl_title' => $dbKey,
] )
->caller( $fname )
->fetchResultSet();
// Build new array to INSERT into multiple rows at once.
$expiryData = [];
foreach ( $res as $row ) {
if ( !empty( $expiries[$row->wl_user] ) ) {
$expiryData[] = [
'we_item' => $row->wl_id,
'we_expiry' => $expiries[$row->wl_user],
];
}
}
// Batch the insertions.
$batches = array_chunk( $expiryData, $this->updateRowsPerQuery );
foreach ( $batches as $toInsert ) {
$dbw->newReplaceQueryBuilder()
->replaceInto( 'watchlist_expiry' )
->uniqueIndexFields( [ 'we_item' ] )
->rows( $toInsert )
->caller( $fname )
->execute();
}
},
DeferredUpdates::POSTSEND,
$dbw
);
}
/**
* @param LinkTarget[]|PageIdentity[] $titles
* @return array
*/
private function getTitleDbKeysGroupedByNamespace( array $titles ) {
$rows = [];
foreach ( $titles as $title ) {
// Group titles by namespace.
$rows[ $title->getNamespace() ][] = $title->getDBkey();
}
return $rows;
}
/**
* @param UserIdentity $user
* @param LinkTarget[]|PageIdentity[] $titles
*/
private function uncacheTitlesForUser( UserIdentity $user, array $titles ) {
foreach ( $titles as $title ) {
$this->uncache( $user, $title );
}
}
/**
* @inheritDoc
*/
public function countExpired(): int {
$dbr = $this->lbFactory->getReplicaDatabase();
return $dbr->newSelectQueryBuilder()
->select( '*' )
->from( 'watchlist_expiry' )
->where( $dbr->expr( 'we_expiry', '<=', $dbr->timestamp() ) )
->caller( __METHOD__ )
->fetchRowCount();
}
/**
* @inheritDoc
*/
public function removeExpired( int $limit, bool $deleteOrphans = false ): void {
$dbr = $this->lbFactory->getReplicaDatabase();
$dbw = $this->lbFactory->getPrimaryDatabase();
$ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
// Get a batch of watchlist IDs to delete.
$toDelete = $dbr->newSelectQueryBuilder()
->select( 'we_item' )
->from( 'watchlist_expiry' )
->where( $dbr->expr( 'we_expiry', '<=', $dbr->timestamp() ) )
->limit( $limit )
->caller( __METHOD__ )
->fetchFieldValues();
if ( count( $toDelete ) > 0 ) {
// Delete them from the watchlist and watchlist_expiry table.
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'watchlist' )
->where( [ 'wl_id' => $toDelete ] )
->caller( __METHOD__ )->execute();
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'watchlist_expiry' )
->where( [ 'we_item' => $toDelete ] )
->caller( __METHOD__ )->execute();
}
// Also delete any orphaned or null-expiry watchlist_expiry rows
// (they should not exist, but might because not everywhere knows about the expiry table yet).
if ( $deleteOrphans ) {
$expiryToDelete = $dbr->newSelectQueryBuilder()
->select( 'we_item' )
->from( 'watchlist_expiry' )
->leftJoin( 'watchlist', null, 'wl_id = we_item' )
->where( $dbr->makeList(
[ 'wl_id' => null, 'we_expiry' => null ],
$dbr::LIST_OR
) )
->caller( __METHOD__ )
->fetchFieldValues();
if ( count( $expiryToDelete ) > 0 ) {
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'watchlist_expiry' )
->where( [ 'we_item' => $expiryToDelete ] )
->caller( __METHOD__ )->execute();
}
}
$this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
}
}
/** @deprecated class alias since 1.43 */
class_alias( WatchedItemStore::class, 'WatchedItemStore' );