Файловый менеджер - Редактировать - /var/www/html/mediawiki-1.43.1/includes/watchlist/WatchedItemStore.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' );
| ver. 1.1 | |
.
| PHP 8.4.18 | Ð“ÐµÐ½ÐµÑ€Ð°Ñ†Ð¸Ñ Ñтраницы: 0 |
proxy
|
phpinfo
|
ÐаÑтройка