WatchedItemStore.php 26 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045
  1. <?php
  2. use Wikimedia\Rdbms\IDatabase;
  3. use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
  4. use MediaWiki\Linker\LinkTarget;
  5. use MediaWiki\MediaWikiServices;
  6. use Wikimedia\Assert\Assert;
  7. use Wikimedia\ScopedCallback;
  8. use Wikimedia\Rdbms\LoadBalancer;
  9. /**
  10. * Storage layer class for WatchedItems.
  11. * Database interaction & caching
  12. * TODO caching should be factored out into a CachingWatchedItemStore class
  13. *
  14. * @author Addshore
  15. * @since 1.27
  16. */
  17. class WatchedItemStore implements WatchedItemStoreInterface, StatsdAwareInterface {
  18. /**
  19. * @var LoadBalancer
  20. */
  21. private $loadBalancer;
  22. /**
  23. * @var ReadOnlyMode
  24. */
  25. private $readOnlyMode;
  26. /**
  27. * @var HashBagOStuff
  28. */
  29. private $cache;
  30. /**
  31. * @var array[] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key'
  32. * The index is needed so that on mass changes all relevant items can be un-cached.
  33. * For example: Clearing a users watchlist of all items or updating notification timestamps
  34. * for all users watching a single target.
  35. */
  36. private $cacheIndex = [];
  37. /**
  38. * @var callable|null
  39. */
  40. private $deferredUpdatesAddCallableUpdateCallback;
  41. /**
  42. * @var callable|null
  43. */
  44. private $revisionGetTimestampFromIdCallback;
  45. /**
  46. * @var int
  47. */
  48. private $updateRowsPerQuery;
  49. /**
  50. * @var StatsdDataFactoryInterface
  51. */
  52. private $stats;
  53. /**
  54. * @param LoadBalancer $loadBalancer
  55. * @param HashBagOStuff $cache
  56. * @param ReadOnlyMode $readOnlyMode
  57. * @param int $updateRowsPerQuery
  58. */
  59. public function __construct(
  60. LoadBalancer $loadBalancer,
  61. HashBagOStuff $cache,
  62. ReadOnlyMode $readOnlyMode,
  63. $updateRowsPerQuery
  64. ) {
  65. $this->loadBalancer = $loadBalancer;
  66. $this->cache = $cache;
  67. $this->readOnlyMode = $readOnlyMode;
  68. $this->stats = new NullStatsdDataFactory();
  69. $this->deferredUpdatesAddCallableUpdateCallback =
  70. [ DeferredUpdates::class, 'addCallableUpdate' ];
  71. $this->revisionGetTimestampFromIdCallback =
  72. [ Revision::class, 'getTimestampFromId' ];
  73. $this->updateRowsPerQuery = $updateRowsPerQuery;
  74. }
  75. /**
  76. * @param StatsdDataFactoryInterface $stats
  77. */
  78. public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) {
  79. $this->stats = $stats;
  80. }
  81. /**
  82. * Overrides the DeferredUpdates::addCallableUpdate callback
  83. * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
  84. *
  85. * @param callable $callback
  86. *
  87. * @see DeferredUpdates::addCallableUpdate for callback signiture
  88. *
  89. * @return ScopedCallback to reset the overridden value
  90. * @throws MWException
  91. */
  92. public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) {
  93. if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
  94. throw new MWException(
  95. 'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
  96. );
  97. }
  98. $previousValue = $this->deferredUpdatesAddCallableUpdateCallback;
  99. $this->deferredUpdatesAddCallableUpdateCallback = $callback;
  100. return new ScopedCallback( function () use ( $previousValue ) {
  101. $this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
  102. } );
  103. }
  104. /**
  105. * Overrides the Revision::getTimestampFromId callback
  106. * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
  107. *
  108. * @param callable $callback
  109. * @see Revision::getTimestampFromId for callback signiture
  110. *
  111. * @return ScopedCallback to reset the overridden value
  112. * @throws MWException
  113. */
  114. public function overrideRevisionGetTimestampFromIdCallback( callable $callback ) {
  115. if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
  116. throw new MWException(
  117. 'Cannot override Revision::getTimestampFromId callback in operation.'
  118. );
  119. }
  120. $previousValue = $this->revisionGetTimestampFromIdCallback;
  121. $this->revisionGetTimestampFromIdCallback = $callback;
  122. return new ScopedCallback( function () use ( $previousValue ) {
  123. $this->revisionGetTimestampFromIdCallback = $previousValue;
  124. } );
  125. }
  126. private function getCacheKey( User $user, LinkTarget $target ) {
  127. return $this->cache->makeKey(
  128. (string)$target->getNamespace(),
  129. $target->getDBkey(),
  130. (string)$user->getId()
  131. );
  132. }
  133. private function cache( WatchedItem $item ) {
  134. $user = $item->getUser();
  135. $target = $item->getLinkTarget();
  136. $key = $this->getCacheKey( $user, $target );
  137. $this->cache->set( $key, $item );
  138. $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
  139. $this->stats->increment( 'WatchedItemStore.cache' );
  140. }
  141. private function uncache( User $user, LinkTarget $target ) {
  142. $this->cache->delete( $this->getCacheKey( $user, $target ) );
  143. unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
  144. $this->stats->increment( 'WatchedItemStore.uncache' );
  145. }
  146. private function uncacheLinkTarget( LinkTarget $target ) {
  147. $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' );
  148. if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
  149. return;
  150. }
  151. foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
  152. $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
  153. $this->cache->delete( $key );
  154. }
  155. }
  156. private function uncacheUser( User $user ) {
  157. $this->stats->increment( 'WatchedItemStore.uncacheUser' );
  158. foreach ( $this->cacheIndex as $ns => $dbKeyArray ) {
  159. foreach ( $dbKeyArray as $dbKey => $userArray ) {
  160. if ( isset( $userArray[$user->getId()] ) ) {
  161. $this->stats->increment( 'WatchedItemStore.uncacheUser.items' );
  162. $this->cache->delete( $userArray[$user->getId()] );
  163. }
  164. }
  165. }
  166. }
  167. /**
  168. * @param User $user
  169. * @param LinkTarget $target
  170. *
  171. * @return WatchedItem|false
  172. */
  173. private function getCached( User $user, LinkTarget $target ) {
  174. return $this->cache->get( $this->getCacheKey( $user, $target ) );
  175. }
  176. /**
  177. * Return an array of conditions to select or update the appropriate database
  178. * row.
  179. *
  180. * @param User $user
  181. * @param LinkTarget $target
  182. *
  183. * @return array
  184. */
  185. private function dbCond( User $user, LinkTarget $target ) {
  186. return [
  187. 'wl_user' => $user->getId(),
  188. 'wl_namespace' => $target->getNamespace(),
  189. 'wl_title' => $target->getDBkey(),
  190. ];
  191. }
  192. /**
  193. * @param int $dbIndex DB_MASTER or DB_REPLICA
  194. *
  195. * @return IDatabase
  196. * @throws MWException
  197. */
  198. private function getConnectionRef( $dbIndex ) {
  199. return $this->loadBalancer->getConnectionRef( $dbIndex, [ 'watchlist' ] );
  200. }
  201. /**
  202. * Deletes ALL watched items for the given user when under
  203. * $updateRowsPerQuery entries exist.
  204. *
  205. * @since 1.30
  206. *
  207. * @param User $user
  208. *
  209. * @return bool true on success, false when too many items are watched
  210. */
  211. public function clearUserWatchedItems( User $user ) {
  212. if ( $this->countWatchedItems( $user ) > $this->updateRowsPerQuery ) {
  213. return false;
  214. }
  215. $dbw = $this->loadBalancer->getConnectionRef( DB_MASTER );
  216. $dbw->delete(
  217. 'watchlist',
  218. [ 'wl_user' => $user->getId() ],
  219. __METHOD__
  220. );
  221. $this->uncacheAllItemsForUser( $user );
  222. return true;
  223. }
  224. private function uncacheAllItemsForUser( User $user ) {
  225. $userId = $user->getId();
  226. foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
  227. foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
  228. if ( array_key_exists( $userId, $userIndex ) ) {
  229. $this->cache->delete( $userIndex[$userId] );
  230. unset( $this->cacheIndex[$ns][$dbKey][$userId] );
  231. }
  232. }
  233. }
  234. // Cleanup empty cache keys
  235. foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
  236. foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
  237. if ( empty( $this->cacheIndex[$ns][$dbKey] ) ) {
  238. unset( $this->cacheIndex[$ns][$dbKey] );
  239. }
  240. }
  241. if ( empty( $this->cacheIndex[$ns] ) ) {
  242. unset( $this->cacheIndex[$ns] );
  243. }
  244. }
  245. }
  246. /**
  247. * Queues a job that will clear the users watchlist using the Job Queue.
  248. *
  249. * @since 1.31
  250. *
  251. * @param User $user
  252. */
  253. public function clearUserWatchedItemsUsingJobQueue( User $user ) {
  254. $job = ClearUserWatchlistJob::newForUser( $user, $this->getMaxId() );
  255. // TODO inject me.
  256. JobQueueGroup::singleton()->push( $job );
  257. }
  258. /**
  259. * @since 1.31
  260. * @return int The maximum current wl_id
  261. */
  262. public function getMaxId() {
  263. $dbr = $this->getConnectionRef( DB_REPLICA );
  264. return (int)$dbr->selectField(
  265. 'watchlist',
  266. 'MAX(wl_id)',
  267. '',
  268. __METHOD__
  269. );
  270. }
  271. /**
  272. * @since 1.31
  273. * @param User $user
  274. * @return int
  275. */
  276. public function countWatchedItems( User $user ) {
  277. $dbr = $this->getConnectionRef( DB_REPLICA );
  278. $return = (int)$dbr->selectField(
  279. 'watchlist',
  280. 'COUNT(*)',
  281. [
  282. 'wl_user' => $user->getId()
  283. ],
  284. __METHOD__
  285. );
  286. return $return;
  287. }
  288. /**
  289. * @since 1.27
  290. * @param LinkTarget $target
  291. * @return int
  292. */
  293. public function countWatchers( LinkTarget $target ) {
  294. $dbr = $this->getConnectionRef( DB_REPLICA );
  295. $return = (int)$dbr->selectField(
  296. 'watchlist',
  297. 'COUNT(*)',
  298. [
  299. 'wl_namespace' => $target->getNamespace(),
  300. 'wl_title' => $target->getDBkey(),
  301. ],
  302. __METHOD__
  303. );
  304. return $return;
  305. }
  306. /**
  307. * @since 1.27
  308. * @param LinkTarget $target
  309. * @param string|int $threshold
  310. * @return int
  311. */
  312. public function countVisitingWatchers( LinkTarget $target, $threshold ) {
  313. $dbr = $this->getConnectionRef( DB_REPLICA );
  314. $visitingWatchers = (int)$dbr->selectField(
  315. 'watchlist',
  316. 'COUNT(*)',
  317. [
  318. 'wl_namespace' => $target->getNamespace(),
  319. 'wl_title' => $target->getDBkey(),
  320. 'wl_notificationtimestamp >= ' .
  321. $dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
  322. ' OR wl_notificationtimestamp IS NULL'
  323. ],
  324. __METHOD__
  325. );
  326. return $visitingWatchers;
  327. }
  328. /**
  329. * @since 1.27
  330. * @param LinkTarget[] $targets
  331. * @param array $options
  332. * @return array
  333. */
  334. public function countWatchersMultiple( array $targets, array $options = [] ) {
  335. $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
  336. $dbr = $this->getConnectionRef( DB_REPLICA );
  337. if ( array_key_exists( 'minimumWatchers', $options ) ) {
  338. $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
  339. }
  340. $lb = new LinkBatch( $targets );
  341. $res = $dbr->select(
  342. 'watchlist',
  343. [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
  344. [ $lb->constructSet( 'wl', $dbr ) ],
  345. __METHOD__,
  346. $dbOptions
  347. );
  348. $watchCounts = [];
  349. foreach ( $targets as $linkTarget ) {
  350. $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
  351. }
  352. foreach ( $res as $row ) {
  353. $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
  354. }
  355. return $watchCounts;
  356. }
  357. /**
  358. * @since 1.27
  359. * @param array $targetsWithVisitThresholds
  360. * @param int|null $minimumWatchers
  361. * @return array
  362. */
  363. public function countVisitingWatchersMultiple(
  364. array $targetsWithVisitThresholds,
  365. $minimumWatchers = null
  366. ) {
  367. if ( $targetsWithVisitThresholds === [] ) {
  368. // No titles requested => no results returned
  369. return [];
  370. }
  371. $dbr = $this->getConnectionRef( DB_REPLICA );
  372. $conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds );
  373. $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
  374. if ( $minimumWatchers !== null ) {
  375. $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
  376. }
  377. $res = $dbr->select(
  378. 'watchlist',
  379. [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
  380. $conds,
  381. __METHOD__,
  382. $dbOptions
  383. );
  384. $watcherCounts = [];
  385. foreach ( $targetsWithVisitThresholds as list( $target ) ) {
  386. /* @var LinkTarget $target */
  387. $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
  388. }
  389. foreach ( $res as $row ) {
  390. $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
  391. }
  392. return $watcherCounts;
  393. }
  394. /**
  395. * Generates condition for the query used in a batch count visiting watchers.
  396. *
  397. * @param IDatabase $db
  398. * @param array $targetsWithVisitThresholds array of pairs (LinkTarget, last visit threshold)
  399. * @return string
  400. */
  401. private function getVisitingWatchersCondition(
  402. IDatabase $db,
  403. array $targetsWithVisitThresholds
  404. ) {
  405. $missingTargets = [];
  406. $namespaceConds = [];
  407. foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
  408. if ( $threshold === null ) {
  409. $missingTargets[] = $target;
  410. continue;
  411. }
  412. /* @var LinkTarget $target */
  413. $namespaceConds[$target->getNamespace()][] = $db->makeList( [
  414. 'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
  415. $db->makeList( [
  416. 'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
  417. 'wl_notificationtimestamp IS NULL'
  418. ], LIST_OR )
  419. ], LIST_AND );
  420. }
  421. $conds = [];
  422. foreach ( $namespaceConds as $namespace => $pageConds ) {
  423. $conds[] = $db->makeList( [
  424. 'wl_namespace = ' . $namespace,
  425. '(' . $db->makeList( $pageConds, LIST_OR ) . ')'
  426. ], LIST_AND );
  427. }
  428. if ( $missingTargets ) {
  429. $lb = new LinkBatch( $missingTargets );
  430. $conds[] = $lb->constructSet( 'wl', $db );
  431. }
  432. return $db->makeList( $conds, LIST_OR );
  433. }
  434. /**
  435. * @since 1.27
  436. * @param User $user
  437. * @param LinkTarget $target
  438. * @return bool
  439. */
  440. public function getWatchedItem( User $user, LinkTarget $target ) {
  441. if ( $user->isAnon() ) {
  442. return false;
  443. }
  444. $cached = $this->getCached( $user, $target );
  445. if ( $cached ) {
  446. $this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' );
  447. return $cached;
  448. }
  449. $this->stats->increment( 'WatchedItemStore.getWatchedItem.load' );
  450. return $this->loadWatchedItem( $user, $target );
  451. }
  452. /**
  453. * @since 1.27
  454. * @param User $user
  455. * @param LinkTarget $target
  456. * @return WatchedItem|bool
  457. */
  458. public function loadWatchedItem( User $user, LinkTarget $target ) {
  459. // Only loggedin user can have a watchlist
  460. if ( $user->isAnon() ) {
  461. return false;
  462. }
  463. $dbr = $this->getConnectionRef( DB_REPLICA );
  464. $row = $dbr->selectRow(
  465. 'watchlist',
  466. 'wl_notificationtimestamp',
  467. $this->dbCond( $user, $target ),
  468. __METHOD__
  469. );
  470. if ( !$row ) {
  471. return false;
  472. }
  473. $item = new WatchedItem(
  474. $user,
  475. $target,
  476. wfTimestampOrNull( TS_MW, $row->wl_notificationtimestamp )
  477. );
  478. $this->cache( $item );
  479. return $item;
  480. }
  481. /**
  482. * @since 1.27
  483. * @param User $user
  484. * @param array $options
  485. * @return WatchedItem[]
  486. */
  487. public function getWatchedItemsForUser( User $user, array $options = [] ) {
  488. $options += [ 'forWrite' => false ];
  489. $dbOptions = [];
  490. if ( array_key_exists( 'sort', $options ) ) {
  491. Assert::parameter(
  492. ( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
  493. '$options[\'sort\']',
  494. 'must be SORT_ASC or SORT_DESC'
  495. );
  496. $dbOptions['ORDER BY'] = [
  497. "wl_namespace {$options['sort']}",
  498. "wl_title {$options['sort']}"
  499. ];
  500. }
  501. $db = $this->getConnectionRef( $options['forWrite'] ? DB_MASTER : DB_REPLICA );
  502. $res = $db->select(
  503. 'watchlist',
  504. [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
  505. [ 'wl_user' => $user->getId() ],
  506. __METHOD__,
  507. $dbOptions
  508. );
  509. $watchedItems = [];
  510. foreach ( $res as $row ) {
  511. // @todo: Should we add these to the process cache?
  512. $watchedItems[] = new WatchedItem(
  513. $user,
  514. new TitleValue( (int)$row->wl_namespace, $row->wl_title ),
  515. $row->wl_notificationtimestamp
  516. );
  517. }
  518. return $watchedItems;
  519. }
  520. /**
  521. * @since 1.27
  522. * @param User $user
  523. * @param LinkTarget $target
  524. * @return bool
  525. */
  526. public function isWatched( User $user, LinkTarget $target ) {
  527. return (bool)$this->getWatchedItem( $user, $target );
  528. }
  529. /**
  530. * @since 1.27
  531. * @param User $user
  532. * @param LinkTarget[] $targets
  533. * @return array
  534. */
  535. public function getNotificationTimestampsBatch( User $user, array $targets ) {
  536. $timestamps = [];
  537. foreach ( $targets as $target ) {
  538. $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
  539. }
  540. if ( $user->isAnon() ) {
  541. return $timestamps;
  542. }
  543. $targetsToLoad = [];
  544. foreach ( $targets as $target ) {
  545. $cachedItem = $this->getCached( $user, $target );
  546. if ( $cachedItem ) {
  547. $timestamps[$target->getNamespace()][$target->getDBkey()] =
  548. $cachedItem->getNotificationTimestamp();
  549. } else {
  550. $targetsToLoad[] = $target;
  551. }
  552. }
  553. if ( !$targetsToLoad ) {
  554. return $timestamps;
  555. }
  556. $dbr = $this->getConnectionRef( DB_REPLICA );
  557. $lb = new LinkBatch( $targetsToLoad );
  558. $res = $dbr->select(
  559. 'watchlist',
  560. [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
  561. [
  562. $lb->constructSet( 'wl', $dbr ),
  563. 'wl_user' => $user->getId(),
  564. ],
  565. __METHOD__
  566. );
  567. foreach ( $res as $row ) {
  568. $timestamps[$row->wl_namespace][$row->wl_title] =
  569. wfTimestampOrNull( TS_MW, $row->wl_notificationtimestamp );
  570. }
  571. return $timestamps;
  572. }
  573. /**
  574. * @since 1.27
  575. * @param User $user
  576. * @param LinkTarget $target
  577. */
  578. public function addWatch( User $user, LinkTarget $target ) {
  579. $this->addWatchBatchForUser( $user, [ $target ] );
  580. }
  581. /**
  582. * @since 1.27
  583. * @param User $user
  584. * @param LinkTarget[] $targets
  585. * @return bool
  586. */
  587. public function addWatchBatchForUser( User $user, array $targets ) {
  588. if ( $this->readOnlyMode->isReadOnly() ) {
  589. return false;
  590. }
  591. // Only loggedin user can have a watchlist
  592. if ( $user->isAnon() ) {
  593. return false;
  594. }
  595. if ( !$targets ) {
  596. return true;
  597. }
  598. $rows = [];
  599. $items = [];
  600. foreach ( $targets as $target ) {
  601. $rows[] = [
  602. 'wl_user' => $user->getId(),
  603. 'wl_namespace' => $target->getNamespace(),
  604. 'wl_title' => $target->getDBkey(),
  605. 'wl_notificationtimestamp' => null,
  606. ];
  607. $items[] = new WatchedItem(
  608. $user,
  609. $target,
  610. null
  611. );
  612. $this->uncache( $user, $target );
  613. }
  614. $dbw = $this->getConnectionRef( DB_MASTER );
  615. foreach ( array_chunk( $rows, 100 ) as $toInsert ) {
  616. // Use INSERT IGNORE to avoid overwriting the notification timestamp
  617. // if there's already an entry for this page
  618. $dbw->insert( 'watchlist', $toInsert, __METHOD__, 'IGNORE' );
  619. }
  620. // Update process cache to ensure skin doesn't claim that the current
  621. // page is unwatched in the response of action=watch itself (T28292).
  622. // This would otherwise be re-queried from a replica by isWatched().
  623. foreach ( $items as $item ) {
  624. $this->cache( $item );
  625. }
  626. return true;
  627. }
  628. /**
  629. * @since 1.27
  630. * @param User $user
  631. * @param LinkTarget $target
  632. * @return bool
  633. */
  634. public function removeWatch( User $user, LinkTarget $target ) {
  635. // Only logged in user can have a watchlist
  636. if ( $this->readOnlyMode->isReadOnly() || $user->isAnon() ) {
  637. return false;
  638. }
  639. $this->uncache( $user, $target );
  640. $dbw = $this->getConnectionRef( DB_MASTER );
  641. $dbw->delete( 'watchlist',
  642. [
  643. 'wl_user' => $user->getId(),
  644. 'wl_namespace' => $target->getNamespace(),
  645. 'wl_title' => $target->getDBkey(),
  646. ], __METHOD__
  647. );
  648. $success = (bool)$dbw->affectedRows();
  649. return $success;
  650. }
  651. /**
  652. * @since 1.27
  653. * @param User $user
  654. * @param string|int $timestamp
  655. * @param LinkTarget[] $targets
  656. * @return bool
  657. */
  658. public function setNotificationTimestampsForUser( User $user, $timestamp, array $targets = [] ) {
  659. // Only loggedin user can have a watchlist
  660. if ( $user->isAnon() ) {
  661. return false;
  662. }
  663. $dbw = $this->getConnectionRef( DB_MASTER );
  664. $conds = [ 'wl_user' => $user->getId() ];
  665. if ( $targets ) {
  666. $batch = new LinkBatch( $targets );
  667. $conds[] = $batch->constructSet( 'wl', $dbw );
  668. }
  669. if ( $timestamp !== null ) {
  670. $timestamp = $dbw->timestamp( $timestamp );
  671. }
  672. $success = $dbw->update(
  673. 'watchlist',
  674. [ 'wl_notificationtimestamp' => $timestamp ],
  675. $conds,
  676. __METHOD__
  677. );
  678. $this->uncacheUser( $user );
  679. return $success;
  680. }
  681. public function resetAllNotificationTimestampsForUser( User $user ) {
  682. // Only loggedin user can have a watchlist
  683. if ( $user->isAnon() ) {
  684. return;
  685. }
  686. // If the page is watched by the user (or may be watched), update the timestamp
  687. $job = new ClearWatchlistNotificationsJob(
  688. $user->getUserPage(),
  689. [ 'userId' => $user->getId(), 'casTime' => time() ]
  690. );
  691. // Try to run this post-send
  692. // Calls DeferredUpdates::addCallableUpdate in normal operation
  693. call_user_func(
  694. $this->deferredUpdatesAddCallableUpdateCallback,
  695. function () use ( $job ) {
  696. $job->run();
  697. }
  698. );
  699. }
  700. /**
  701. * @since 1.27
  702. * @param User $editor
  703. * @param LinkTarget $target
  704. * @param string|int $timestamp
  705. * @return int[]
  706. */
  707. public function updateNotificationTimestamp( User $editor, LinkTarget $target, $timestamp ) {
  708. $dbw = $this->getConnectionRef( DB_MASTER );
  709. $uids = $dbw->selectFieldValues(
  710. 'watchlist',
  711. 'wl_user',
  712. [
  713. 'wl_user != ' . intval( $editor->getId() ),
  714. 'wl_namespace' => $target->getNamespace(),
  715. 'wl_title' => $target->getDBkey(),
  716. 'wl_notificationtimestamp IS NULL',
  717. ],
  718. __METHOD__
  719. );
  720. $watchers = array_map( 'intval', $uids );
  721. if ( $watchers ) {
  722. // Update wl_notificationtimestamp for all watching users except the editor
  723. $fname = __METHOD__;
  724. DeferredUpdates::addCallableUpdate(
  725. function () use ( $timestamp, $watchers, $target, $fname ) {
  726. global $wgUpdateRowsPerQuery;
  727. $dbw = $this->getConnectionRef( DB_MASTER );
  728. $factory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
  729. $ticket = $factory->getEmptyTransactionTicket( __METHOD__ );
  730. $watchersChunks = array_chunk( $watchers, $wgUpdateRowsPerQuery );
  731. foreach ( $watchersChunks as $watchersChunk ) {
  732. $dbw->update( 'watchlist',
  733. [ /* SET */
  734. 'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
  735. ], [ /* WHERE - TODO Use wl_id T130067 */
  736. 'wl_user' => $watchersChunk,
  737. 'wl_namespace' => $target->getNamespace(),
  738. 'wl_title' => $target->getDBkey(),
  739. ], $fname
  740. );
  741. if ( count( $watchersChunks ) > 1 ) {
  742. $factory->commitAndWaitForReplication(
  743. __METHOD__, $ticket, [ 'domain' => $dbw->getDomainID() ]
  744. );
  745. }
  746. }
  747. $this->uncacheLinkTarget( $target );
  748. },
  749. DeferredUpdates::POSTSEND,
  750. $dbw
  751. );
  752. }
  753. return $watchers;
  754. }
  755. /**
  756. * @since 1.27
  757. * @param User $user
  758. * @param Title $title
  759. * @param string $force
  760. * @param int $oldid
  761. * @return bool
  762. */
  763. public function resetNotificationTimestamp( User $user, Title $title, $force = '', $oldid = 0 ) {
  764. // Only loggedin user can have a watchlist
  765. if ( $this->readOnlyMode->isReadOnly() || $user->isAnon() ) {
  766. return false;
  767. }
  768. $item = null;
  769. if ( $force != 'force' ) {
  770. $item = $this->loadWatchedItem( $user, $title );
  771. if ( !$item || $item->getNotificationTimestamp() === null ) {
  772. return false;
  773. }
  774. }
  775. // If the page is watched by the user (or may be watched), update the timestamp
  776. $job = new ActivityUpdateJob(
  777. $title,
  778. [
  779. 'type' => 'updateWatchlistNotification',
  780. 'userid' => $user->getId(),
  781. 'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
  782. 'curTime' => time()
  783. ]
  784. );
  785. // Try to run this post-send
  786. // Calls DeferredUpdates::addCallableUpdate in normal operation
  787. call_user_func(
  788. $this->deferredUpdatesAddCallableUpdateCallback,
  789. function () use ( $job ) {
  790. $job->run();
  791. }
  792. );
  793. $this->uncache( $user, $title );
  794. return true;
  795. }
  796. private function getNotificationTimestamp( User $user, Title $title, $item, $force, $oldid ) {
  797. if ( !$oldid ) {
  798. // No oldid given, assuming latest revision; clear the timestamp.
  799. return null;
  800. }
  801. if ( !$title->getNextRevisionID( $oldid ) ) {
  802. // Oldid given and is the latest revision for this title; clear the timestamp.
  803. return null;
  804. }
  805. if ( $item === null ) {
  806. $item = $this->loadWatchedItem( $user, $title );
  807. }
  808. if ( !$item ) {
  809. // This can only happen if $force is enabled.
  810. return null;
  811. }
  812. // Oldid given and isn't the latest; update the timestamp.
  813. // This will result in no further notification emails being sent!
  814. // Calls Revision::getTimestampFromId in normal operation
  815. $notificationTimestamp = call_user_func(
  816. $this->revisionGetTimestampFromIdCallback,
  817. $title,
  818. $oldid
  819. );
  820. // We need to go one second to the future because of various strict comparisons
  821. // throughout the codebase
  822. $ts = new MWTimestamp( $notificationTimestamp );
  823. $ts->timestamp->add( new DateInterval( 'PT1S' ) );
  824. $notificationTimestamp = $ts->getTimestamp( TS_MW );
  825. if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
  826. if ( $force != 'force' ) {
  827. return false;
  828. } else {
  829. // This is a little silly…
  830. return $item->getNotificationTimestamp();
  831. }
  832. }
  833. return $notificationTimestamp;
  834. }
  835. /**
  836. * @since 1.27
  837. * @param User $user
  838. * @param int|null $unreadLimit
  839. * @return int|bool
  840. */
  841. public function countUnreadNotifications( User $user, $unreadLimit = null ) {
  842. $queryOptions = [];
  843. if ( $unreadLimit !== null ) {
  844. $unreadLimit = (int)$unreadLimit;
  845. $queryOptions['LIMIT'] = $unreadLimit;
  846. }
  847. $dbr = $this->getConnectionRef( DB_REPLICA );
  848. $rowCount = $dbr->selectRowCount(
  849. 'watchlist',
  850. '1',
  851. [
  852. 'wl_user' => $user->getId(),
  853. 'wl_notificationtimestamp IS NOT NULL',
  854. ],
  855. __METHOD__,
  856. $queryOptions
  857. );
  858. if ( !isset( $unreadLimit ) ) {
  859. return $rowCount;
  860. }
  861. if ( $rowCount >= $unreadLimit ) {
  862. return true;
  863. }
  864. return $rowCount;
  865. }
  866. /**
  867. * @since 1.27
  868. * @param LinkTarget $oldTarget
  869. * @param LinkTarget $newTarget
  870. */
  871. public function duplicateAllAssociatedEntries( LinkTarget $oldTarget, LinkTarget $newTarget ) {
  872. $oldTarget = Title::newFromLinkTarget( $oldTarget );
  873. $newTarget = Title::newFromLinkTarget( $newTarget );
  874. $this->duplicateEntry( $oldTarget->getSubjectPage(), $newTarget->getSubjectPage() );
  875. $this->duplicateEntry( $oldTarget->getTalkPage(), $newTarget->getTalkPage() );
  876. }
  877. /**
  878. * @since 1.27
  879. * @param LinkTarget $oldTarget
  880. * @param LinkTarget $newTarget
  881. */
  882. public function duplicateEntry( LinkTarget $oldTarget, LinkTarget $newTarget ) {
  883. $dbw = $this->getConnectionRef( DB_MASTER );
  884. $result = $dbw->select(
  885. 'watchlist',
  886. [ 'wl_user', 'wl_notificationtimestamp' ],
  887. [
  888. 'wl_namespace' => $oldTarget->getNamespace(),
  889. 'wl_title' => $oldTarget->getDBkey(),
  890. ],
  891. __METHOD__,
  892. [ 'FOR UPDATE' ]
  893. );
  894. $newNamespace = $newTarget->getNamespace();
  895. $newDBkey = $newTarget->getDBkey();
  896. # Construct array to replace into the watchlist
  897. $values = [];
  898. foreach ( $result as $row ) {
  899. $values[] = [
  900. 'wl_user' => $row->wl_user,
  901. 'wl_namespace' => $newNamespace,
  902. 'wl_title' => $newDBkey,
  903. 'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
  904. ];
  905. }
  906. if ( !empty( $values ) ) {
  907. # Perform replace
  908. # Note that multi-row replace is very efficient for MySQL but may be inefficient for
  909. # some other DBMSes, mostly due to poor simulation by us
  910. $dbw->replace(
  911. 'watchlist',
  912. [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
  913. $values,
  914. __METHOD__
  915. );
  916. }
  917. }
  918. }