* Add a list of targets to a user's watchlist
*
* @param string[]|LinkTarget[] $targets
+ * @return bool
+ * @throws FatalError
+ * @throws MWException
*/
- private function watchTitles( $targets ) {
+ private function watchTitles( array $targets ) {
+ return MediaWikiServices::getInstance()->getWatchedItemStore()
+ ->addWatchBatchForUser( $this->getUser(), $this->getExpandedTargets( $targets ) )
+ && $this->runWatchUnwatchCompleteHook( 'Watch', $targets );
+ }
+
+ /**
+ * Remove a list of titles from a user's watchlist
+ *
+ * $titles can be an array of strings or Title objects; the former
+ * is preferred, since Titles are very memory-heavy
+ *
+ * @param string[]|LinkTarget[] $targets
+ *
+ * @return bool
+ * @throws FatalError
+ * @throws MWException
+ */
+ private function unwatchTitles( array $targets ) {
+ return MediaWikiServices::getInstance()->getWatchedItemStore()
+ ->removeWatchBatchForUser( $this->getUser(), $this->getExpandedTargets( $targets ) )
+ && $this->runWatchUnwatchCompleteHook( 'Unwatch', $targets );
+ }
+
+ /**
+ * @param string $action
+ * Can be "Watch" or "Unwatch"
+ * @param string[]|LinkTarget[] $targets
+ * @return bool
+ * @throws FatalError
+ * @throws MWException
+ */
+ private function runWatchUnwatchCompleteHook( $action, $targets ) {
+ foreach ( $targets as $target ) {
+ $title = $target instanceof TitleValue ?
+ Title::newFromTitleValue( $target ) :
+ Title::newFromText( $target );
+ $page = WikiPage::factory( $title );
+ Hooks::run( $action . 'ArticleComplete', [ $this->getUser(), &$page ] );
+ }
+ return true;
+ }
+
+ /**
+ * @param string[]|LinkTarget[] $targets
+ * @return TitleValue[]
+ */
+ private function getExpandedTargets( array $targets ) {
$expandedTargets = [];
foreach ( $targets as $target ) {
if ( !$target instanceof LinkTarget ) {
$expandedTargets[] = new TitleValue( MWNamespace::getSubject( $ns ), $dbKey );
$expandedTargets[] = new TitleValue( MWNamespace::getTalk( $ns ), $dbKey );
}
-
- MediaWikiServices::getInstance()->getWatchedItemStore()->addWatchBatchForUser(
- $this->getUser(),
- $expandedTargets
- );
- }
-
- /**
- * Remove a list of titles from a user's watchlist
- *
- * $titles can be an array of strings or Title objects; the former
- * is preferred, since Titles are very memory-heavy
- *
- * @param array $titles Array of strings, or Title objects
- */
- private function unwatchTitles( $titles ) {
- $store = MediaWikiServices::getInstance()->getWatchedItemStore();
-
- foreach ( $titles as $title ) {
- if ( !$title instanceof Title ) {
- $title = Title::newFromText( $title );
- }
-
- if ( $title instanceof Title ) {
- $store->removeWatch( $this->getUser(), $title->getSubjectPage() );
- $store->removeWatch( $this->getUser(), $title->getTalkPage() );
-
- $page = WikiPage::factory( $title );
- Hooks::run( 'UnwatchArticleComplete', [ $this->getUser(), &$page ] );
- }
- }
+ return $expandedTargets;
}
public function submitNormal( $data ) {
public function clearUserWatchedItemsUsingJobQueue( User $user ) {
throw new DBReadOnlyError( null, 'The watchlist is currently readonly.' );
}
+
+ public function removeWatchBatchForUser( User $user, array $titles ) {
+ throw new DBReadOnlyError( null, 'The watchlist is currently readonly.' );
+ }
+
}
use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
use MediaWiki\Linker\LinkTarget;
use Wikimedia\Assert\Assert;
+use Wikimedia\Rdbms\LBFactory;
use Wikimedia\ScopedCallback;
use Wikimedia\Rdbms\ILBFactory;
use Wikimedia\Rdbms\LoadBalancer;
return $visitingWatchers;
}
+ /**
+ * @param User $user
+ * @param TitleValue[] $titles
+ * @return bool
+ * @throws MWException
+ */
+ public function removeWatchBatchForUser( User $user, array $titles ) {
+ if ( $this->readOnlyMode->isReadOnly() ) {
+ return false;
+ }
+ if ( $user->isAnon() ) {
+ return false;
+ }
+ if ( !$titles ) {
+ return true;
+ }
+
+ $rows = $this->getTitleDbKeysGroupedByNamespace( $titles );
+ $this->uncacheTitlesForUser( $user, $titles );
+
+ $dbw = $this->getConnectionRef( DB_MASTER );
+ $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
+ $affectedRows = 0;
+
+ // Batch delete items per namespace.
+ foreach ( $rows as $namespace => $namespaceTitles ) {
+ $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
+ foreach ( $rowBatches as $toDelete ) {
+ $dbw->delete( 'watchlist', [
+ 'wl_user' => $user->getId(),
+ 'wl_namespace' => $namespace,
+ 'wl_title' => $toDelete
+ ], __METHOD__ );
+ $affectedRows += $dbw->affectedRows();
+ $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
+ }
+ }
+
+ return (bool)$affectedRows;
+ }
+
/**
* @since 1.27
* @param LinkTarget[] $targets
* @since 1.27
* @param User $user
* @param LinkTarget $target
+ * @throws MWException
*/
public function addWatch( User $user, LinkTarget $target ) {
$this->addWatchBatchForUser( $user, [ $target ] );
* @param User $user
* @param LinkTarget[] $targets
* @return bool
+ * @throws MWException
*/
public function addWatchBatchForUser( User $user, array $targets ) {
if ( $this->readOnlyMode->isReadOnly() ) {
}
$dbw = $this->getConnectionRef( DB_MASTER );
- foreach ( array_chunk( $rows, 100 ) as $toInsert ) {
+ $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
+ $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->insert( 'watchlist', $toInsert, __METHOD__, 'IGNORE' );
+ $affectedRows += $dbw->affectedRows();
+ $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
}
// Update process cache to ensure skin doesn't claim that the current
// page is unwatched in the response of action=watch itself (T28292).
$this->cache( $item );
}
- return true;
+ return (bool)$affectedRows;
}
/**
* @param User $user
* @param LinkTarget $target
* @return bool
+ * @throws MWException
*/
public function removeWatch( User $user, LinkTarget $target ) {
- // Only logged in user can have a watchlist
- if ( $this->readOnlyMode->isReadOnly() || $user->isAnon() ) {
- return false;
- }
-
- $this->uncache( $user, $target );
-
- $dbw = $this->getConnectionRef( DB_MASTER );
- $dbw->delete( 'watchlist',
- [
- 'wl_user' => $user->getId(),
- 'wl_namespace' => $target->getNamespace(),
- 'wl_title' => $target->getDBkey(),
- ], __METHOD__
- );
- $success = (bool)$dbw->affectedRows();
-
- return $success;
+ return $this->removeWatchBatchForUser( $user, [ $target ] );
}
/**
}
}
+ /**
+ * @param TitleValue[] $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 User $user
+ * @param Title[] $titles
+ */
+ private function uncacheTitlesForUser( User $user, array $titles ) {
+ foreach ( $titles as $title ) {
+ $this->uncache( $user, $title );
+ }
+ }
+
}
*/
public function clearUserWatchedItemsUsingJobQueue( User $user );
+ /**
+ * @since 1.32
+ *
+ * @param User $user
+ * @param LinkTarget[] $targets
+ *
+ * @return bool success
+ */
+ public function removeWatchBatchForUser( User $user, array $targets );
+
}
<?php
use MediaWiki\Linker\LinkTarget;
-use Wikimedia\Rdbms\LoadBalancer;
use Wikimedia\Rdbms\LBFactory;
+use Wikimedia\Rdbms\LoadBalancer;
use Wikimedia\ScopedCallback;
use Wikimedia\TestingAccessWrapper;
]
);
+ $mockDb->expects( $this->once() )
+ ->method( 'affectedRows' )
+ ->willReturn( 2 );
+
$mockCache = $this->getMockCache();
$mockCache->expects( $this->exactly( 2 ) )
->method( 'delete' );
$mockDb = $this->getMockDb();
$mockDb->expects( $this->once() )
->method( 'delete' )
- ->with(
- 'watchlist',
+ ->withConsecutive(
[
- 'wl_user' => 1,
- 'wl_namespace' => 0,
- 'wl_title' => 'SomeDbKey',
+ 'watchlist',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => [ 'SomeDbKey' ],
+ ],
+ ],
+ [
+ 'watchlist',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 1,
+ 'wl_title' => [ 'SomeDbKey' ],
+ ]
]
);
- $mockDb->expects( $this->once() )
+ $mockDb->expects( $this->exactly( 1 ) )
->method( 'affectedRows' )
- ->will( $this->returnValue( 1 ) );
+ ->willReturn( 2 );
$mockCache = $this->getMockCache();
$mockCache->expects( $this->never() )->method( 'get' );
$mockCache->expects( $this->once() )
->method( 'delete' )
- ->with( '0:SomeDbKey:1' );
+ ->withConsecutive(
+ [ '0:SomeDbKey:1' ],
+ [ '1:SomeDbKey:1' ]
+ );
$store = $this->newWatchedItemStore(
$this->getMockLBFactory( $mockDb ),
$this->getMockReadOnlyMode()
);
+ $titleValue = new TitleValue( 0, 'SomeDbKey' );
$this->assertTrue(
$store->removeWatch(
$this->getMockNonAnonUserWithId( 1 ),
- new TitleValue( 0, 'SomeDbKey' )
+ Title::newFromTitleValue( $titleValue )
)
);
}
$mockDb = $this->getMockDb();
$mockDb->expects( $this->once() )
->method( 'delete' )
- ->with(
- 'watchlist',
+ ->withConsecutive(
[
- 'wl_user' => 1,
- 'wl_namespace' => 0,
- 'wl_title' => 'SomeDbKey',
+ 'watchlist',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => [ 'SomeDbKey' ],
+ ]
+ ],
+ [
+ 'watchlist',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 1,
+ 'wl_title' => [ 'SomeDbKey' ],
+ ]
]
);
+
$mockDb->expects( $this->once() )
->method( 'affectedRows' )
- ->will( $this->returnValue( 0 ) );
+ ->willReturn( 0 );
$mockCache = $this->getMockCache();
$mockCache->expects( $this->never() )->method( 'get' );
$mockCache->expects( $this->once() )
->method( 'delete' )
- ->with( '0:SomeDbKey:1' );
+ ->withConsecutive(
+ [ '0:SomeDbKey:1' ],
+ [ '1:SomeDbKey:1' ]
+ );
$store = $this->newWatchedItemStore(
$this->getMockLBFactory( $mockDb ),
$this->getMockReadOnlyMode()
);
+ $titleValue = new TitleValue( 0, 'SomeDbKey' );
$this->assertFalse(
$store->removeWatch(
$this->getMockNonAnonUserWithId( 1 ),
- new TitleValue( 0, 'SomeDbKey' )
+ Title::newFromTitleValue( $titleValue )
)
);
}