78 private $extensions =
null;
81 private $commentStore;
84 private $watchedItemStore;
90 private $userOptionsLookup;
93 private $tempUserConfig;
98 private $expiryEnabled;
103 private $maxQueryExecutionTime;
112 bool $expiryEnabled =
false,
113 int $maxQueryExecutionTime = 0
115 $this->dbProvider = $dbProvider;
116 $this->commentStore = $commentStore;
117 $this->watchedItemStore = $watchedItemStore;
118 $this->hookRunner =
new HookRunner( $hookContainer );
119 $this->userOptionsLookup = $userOptionsLookup;
120 $this->tempUserConfig = $tempUserConfig;
121 $this->expiryEnabled = $expiryEnabled;
122 $this->maxQueryExecutionTime = $maxQueryExecutionTime;
128 private function getExtensions() {
129 if ( $this->extensions ===
null ) {
130 $this->extensions = [];
131 $this->hookRunner->onWatchedItemQueryServiceExtensions( $this->extensions, $this );
133 return $this->extensions;
180 User $user, array $options = [], &$startFrom =
null
183 'includeFields' => [],
184 'namespaceIds' => [],
186 'allRevisions' =>
false,
187 'usedInGenerator' => false
191 !isset( $options[
'rcTypes'] )
193 '$options[\'rcTypes\']',
194 'must be an array containing only: RC_EDIT, RC_NEW, RC_LOG, RC_EXTERNAL and/or RC_CATEGORIZE'
197 !isset( $options[
'dir'] ) || in_array( $options[
'dir'], [ self::DIR_OLDER, self::DIR_NEWER ] ),
199 'must be DIR_OLDER or DIR_NEWER'
202 ( !isset( $options[
'start'] ) && !isset( $options[
'end'] ) && $startFrom ===
null )
203 || isset( $options[
'dir'] ),
205 'must be provided when providing the "start" or "end" options or the $startFrom parameter'
208 !isset( $options[
'startFrom'] ),
209 '$options[\'startFrom\']',
210 'must not be provided, use $startFrom instead'
213 !isset( $startFrom ) || ( is_array( $startFrom ) && count( $startFrom ) === 2 ),
215 'must be a two-element array'
217 if ( array_key_exists(
'watchlistOwner', $options ) ) {
218 Assert::parameterType(
220 $options[
'watchlistOwner'],
221 '$options[\'watchlistOwner\']'
224 isset( $options[
'watchlistOwnerToken'] ),
225 '$options[\'watchlistOwnerToken\']',
226 'must be provided when providing watchlistOwner option'
230 $db = $this->dbProvider->getReplicaDatabase();
232 $tables = $this->getWatchedItemsWithRCInfoQueryTables( $options );
233 $fields = $this->getWatchedItemsWithRCInfoQueryFields( $options );
234 $conds = $this->getWatchedItemsWithRCInfoQueryConds( $db, $user, $options );
235 $dbOptions = $this->getWatchedItemsWithRCInfoQueryDbOptions( $options );
236 $joinConds = $this->getWatchedItemsWithRCInfoQueryJoinConds( $options );
238 if ( $startFrom !==
null ) {
239 $conds[] = $this->getStartFromConds( $db, $options, $startFrom );
242 foreach ( $this->getExtensions() as $extension ) {
243 $extension->modifyWatchedItemsWithRCInfoQuery(
244 $user, $options, $db,
253 $res = $db->newSelectQueryBuilder()
257 ->caller( __METHOD__ )
258 ->options( $dbOptions )
259 ->joinConds( $joinConds )
262 $limit = $dbOptions[
'LIMIT'] ?? INF;
265 foreach ( $res as $row ) {
266 if ( --$limit <= 0 ) {
267 $startFrom = [ $row->rc_timestamp, $row->rc_id ];
271 $target =
new TitleValue( (
int)$row->rc_namespace, $row->rc_title );
276 $this->watchedItemStore->getLatestNotificationTimestamp(
277 $row->wl_notificationtimestamp, $user, $target
279 $row->we_expiry ??
null
281 $this->getRecentChangeFieldsFromRow( $row )
285 foreach ( $this->getExtensions() as $extension ) {
286 $extension->modifyWatchedItemsWithRCInfo( $user, $options, $db, $items, $res, $startFrom );
318 $options += [
'namespaceIds' => [] ];
321 !isset( $options[
'sort'] ) || in_array( $options[
'sort'], [ self::SORT_ASC, self::SORT_DESC ] ),
322 '$options[\'sort\']',
323 'must be SORT_ASC or SORT_DESC'
326 !isset( $options[
'filter'] ) || in_array(
327 $options[
'filter'], [ self::FILTER_CHANGED, self::FILTER_NOT_CHANGED ]
329 '$options[\'filter\']',
330 'must be FILTER_CHANGED or FILTER_NOT_CHANGED'
333 ( !isset( $options[
'from'] ) && !isset( $options[
'until'] ) && !isset( $options[
'startFrom'] ) )
334 || isset( $options[
'sort'] ),
335 '$options[\'sort\']',
336 'must be provided if any of "from", "until", "startFrom" options is provided'
339 $db = $this->dbProvider->getReplicaDatabase();
341 $queryBuilder = $db->newSelectQueryBuilder()
342 ->select( [
'wl_namespace',
'wl_title',
'wl_notificationtimestamp' ] )
343 ->from(
'watchlist' )
344 ->caller( __METHOD__ );
345 $this->addQueryCondsForWatchedItemsForUser( $db, $user, $options, $queryBuilder );
346 $this->addQueryDbOptionsForWatchedItemsForUser( $options, $queryBuilder );
348 if ( $this->expiryEnabled ) {
350 $queryBuilder->leftJoin(
'watchlist_expiry',
null,
'wl_id = we_item' )
351 ->andWhere( $db->expr(
'we_expiry',
'>', $db->timestamp() )->or(
'we_expiry',
'=',
null ) );
353 $res = $queryBuilder->fetchResultSet();
356 foreach ( $res as $row ) {
357 $target =
new TitleValue( (
int)$row->wl_namespace, $row->wl_title );
362 $this->watchedItemStore->getLatestNotificationTimestamp(
363 $row->wl_notificationtimestamp, $user, $target
365 $row->we_expiry ??
null
369 return $watchedItems;
372 private function getRecentChangeFieldsFromRow( \stdClass $row ) {
374 get_object_vars( $row ),
375 static function ( $key ) {
376 return str_starts_with( $key,
'rc_' );
382 private function getWatchedItemsWithRCInfoQueryTables( array $options ) {
383 $tables = [
'recentchanges',
'watchlist' ];
385 if ( $this->expiryEnabled ) {
386 $tables[] =
'watchlist_expiry';
389 if ( !$options[
'allRevisions'] ) {
392 if ( in_array( self::INCLUDE_COMMENT, $options[
'includeFields'] ) ) {
393 $tables += $this->commentStore->getJoin(
'rc_comment' )[
'tables'];
395 if ( in_array( self::INCLUDE_USER, $options[
'includeFields'] ) ||
396 in_array( self::INCLUDE_USER_ID, $options[
'includeFields'] ) ||
397 in_array( self::FILTER_ANON, $options[
'filters'] ) ||
398 in_array( self::FILTER_NOT_ANON, $options[
'filters'] ) ||
399 array_key_exists(
'onlyByUser', $options ) || array_key_exists(
'notByUser', $options )
401 $tables[
'watchlist_actor'] =
'actor';
406 private function getWatchedItemsWithRCInfoQueryFields( array $options ) {
414 'wl_notificationtimestamp'
417 if ( $this->expiryEnabled ) {
418 $fields[] =
'we_expiry';
426 if ( $options[
'usedInGenerator'] ) {
427 if ( $options[
'allRevisions'] ) {
428 $rcIdFields = [
'rc_this_oldid' ];
430 $rcIdFields = [
'rc_cur_id' ];
433 $fields = array_merge( $fields, $rcIdFields );
435 if ( in_array( self::INCLUDE_FLAGS, $options[
'includeFields'] ) ) {
436 $fields = array_merge( $fields, [
'rc_type',
'rc_minor',
'rc_bot' ] );
438 if ( in_array( self::INCLUDE_USER, $options[
'includeFields'] ) ) {
439 $fields[
'rc_user_text'] =
'watchlist_actor.actor_name';
441 if ( in_array( self::INCLUDE_USER_ID, $options[
'includeFields'] ) ) {
442 $fields[
'rc_user'] =
'watchlist_actor.actor_user';
444 if ( in_array( self::INCLUDE_COMMENT, $options[
'includeFields'] ) ) {
445 $fields += $this->commentStore->getJoin(
'rc_comment' )[
'fields'];
447 if ( in_array( self::INCLUDE_PATROL_INFO, $options[
'includeFields'] ) ) {
448 $fields = array_merge( $fields, [
'rc_patrolled',
'rc_log_type' ] );
450 if ( in_array( self::INCLUDE_SIZES, $options[
'includeFields'] ) ) {
451 $fields = array_merge( $fields, [
'rc_old_len',
'rc_new_len' ] );
453 if ( in_array( self::INCLUDE_LOG_INFO, $options[
'includeFields'] ) ) {
454 $fields = array_merge( $fields, [
'rc_logid',
'rc_log_type',
'rc_log_action',
'rc_params' ] );
456 if ( in_array( self::INCLUDE_TAGS, $options[
'includeFields'] ) ) {
464 private function getWatchedItemsWithRCInfoQueryConds(
465 IReadableDatabase $db,
469 $watchlistOwnerId = $this->getWatchlistOwnerId( $user, $options );
470 $conds = [
'wl_user' => $watchlistOwnerId ];
472 if ( $this->expiryEnabled ) {
473 $conds[] = $db->expr(
'we_expiry',
'=',
null )->or(
'we_expiry',
'>', $db->timestamp() );
476 if ( !$options[
'allRevisions'] ) {
477 $conds[] = $db->makeList(
478 [
'rc_this_oldid=page_latest',
'rc_type=' .
RC_LOG ],
483 if ( $options[
'namespaceIds'] ) {
484 $conds[
'wl_namespace'] = array_map(
'intval', $options[
'namespaceIds'] );
487 if ( array_key_exists(
'rcTypes', $options ) ) {
488 $conds[
'rc_type'] = array_map(
'intval', $options[
'rcTypes'] );
491 $conds = array_merge(
493 $this->getWatchedItemsWithRCInfoQueryFilterConds( $db, $user, $options )
496 $conds = array_merge( $conds, $this->getStartEndConds( $db, $options ) );
498 if ( !isset( $options[
'start'] ) && !isset( $options[
'end'] ) && $db->getType() ===
'mysql' ) {
500 $conds[] = $db->expr(
'rc_timestamp',
'>',
'' );
503 $conds = array_merge( $conds, $this->getUserRelatedConds( $db, $user, $options ) );
505 $deletedPageLogCond = $this->getExtraDeletedPageLogEntryRelatedCond( $db, $user );
506 if ( $deletedPageLogCond ) {
507 $conds[] = $deletedPageLogCond;
513 private function getWatchlistOwnerId( UserIdentity $user, array $options ) {
514 if ( array_key_exists(
'watchlistOwner', $options ) ) {
516 $watchlistOwner = $options[
'watchlistOwner'];
518 $this->userOptionsLookup->getOption( $watchlistOwner,
'watchlisttoken' );
519 $token = $options[
'watchlistOwnerToken'];
520 if ( $ownersToken ==
'' || !hash_equals( $ownersToken, $token ) ) {
521 throw ApiUsageException::newWithMessage(
null,
'apierror-bad-watchlist-token',
'bad_wltoken' );
523 return $watchlistOwner->getId();
525 return $user->getId();
528 private function getWatchedItemsWithRCInfoQueryFilterConds(
529 IReadableDatabase $dbr,
535 if ( in_array( self::FILTER_MINOR, $options[
'filters'] ) ) {
536 $conds[] =
'rc_minor != 0';
537 } elseif ( in_array( self::FILTER_NOT_MINOR, $options[
'filters'] ) ) {
538 $conds[] =
'rc_minor = 0';
541 if ( in_array( self::FILTER_BOT, $options[
'filters'] ) ) {
542 $conds[] =
'rc_bot != 0';
543 } elseif ( in_array( self::FILTER_NOT_BOT, $options[
'filters'] ) ) {
544 $conds[] =
'rc_bot = 0';
548 if ( in_array( self::FILTER_ANON, $options[
'filters'] ) ) {
549 if ( $this->tempUserConfig->isKnown() ) {
550 $conds[] = $dbr->expr(
'watchlist_actor.actor_user',
'=',
null )
551 ->orExpr( $this->tempUserConfig->getMatchCondition( $dbr,
552 'watchlist_actor.actor_name', IExpression::LIKE ) );
554 $conds[] =
'watchlist_actor.actor_user IS NULL';
556 } elseif ( in_array( self::FILTER_NOT_ANON, $options[
'filters'] ) ) {
557 $conds[] =
'watchlist_actor.actor_user IS NOT NULL';
558 if ( $this->tempUserConfig->isKnown() ) {
559 $conds[] = $this->tempUserConfig->getMatchCondition( $dbr,
560 'watchlist_actor.actor_name', IExpression::NOT_LIKE );
564 if ( $user->useRCPatrol() || $user->useNPPatrol() ) {
567 if ( in_array( self::FILTER_PATROLLED, $options[
'filters'] ) ) {
569 } elseif ( in_array( self::FILTER_NOT_PATROLLED, $options[
'filters'] ) ) {
573 if ( in_array( self::FILTER_AUTOPATROLLED, $options[
'filters'] ) ) {
575 } elseif ( in_array( self::FILTER_NOT_AUTOPATROLLED, $options[
'filters'] ) ) {
580 if ( in_array( self::FILTER_UNREAD, $options[
'filters'] ) ) {
581 $conds[] =
'rc_timestamp >= wl_notificationtimestamp';
582 } elseif ( in_array( self::FILTER_NOT_UNREAD, $options[
'filters'] ) ) {
584 $conds[] =
'wl_notificationtimestamp IS NULL OR rc_timestamp < wl_notificationtimestamp';
590 private function getStartEndConds( IReadableDatabase $db, array $options ) {
591 if ( !isset( $options[
'start'] ) && !isset( $options[
'end'] ) ) {
596 if ( isset( $options[
'start'] ) ) {
597 $after = $options[
'dir'] === self::DIR_OLDER ?
'<=' :
'>=';
598 $conds[] = $db->expr(
'rc_timestamp', $after, $db->timestamp( $options[
'start'] ) );
600 if ( isset( $options[
'end'] ) ) {
601 $before = $options[
'dir'] === self::DIR_OLDER ?
'>=' :
'<=';
602 $conds[] = $db->expr(
'rc_timestamp', $before, $db->timestamp( $options[
'end'] ) );
608 private function getUserRelatedConds( IReadableDatabase $db, Authority $user, array $options ) {
609 if ( !array_key_exists(
'onlyByUser', $options ) && !array_key_exists(
'notByUser', $options ) ) {
615 if ( array_key_exists(
'onlyByUser', $options ) ) {
616 $conds[
'watchlist_actor.actor_name'] = $options[
'onlyByUser'];
617 } elseif ( array_key_exists(
'notByUser', $options ) ) {
618 $conds[] = $db->expr(
'watchlist_actor.actor_name',
'!=', $options[
'notByUser'] );
623 if ( !$user->isAllowed(
'deletedhistory' ) ) {
624 $bitmask = RevisionRecord::DELETED_USER;
625 } elseif ( !$user->isAllowedAny(
'suppressrevision',
'viewsuppressed' ) ) {
626 $bitmask = RevisionRecord::DELETED_USER | RevisionRecord::DELETED_RESTRICTED;
629 $conds[] = $db->bitAnd(
'rc_deleted', $bitmask ) .
" != $bitmask";
635 private function getExtraDeletedPageLogEntryRelatedCond( IReadableDatabase $db, Authority $user ) {
639 if ( !$user->isAllowed(
'deletedhistory' ) ) {
641 } elseif ( !$user->isAllowedAny(
'suppressrevision',
'viewsuppressed' ) ) {
645 return $db->makeList( [
647 $db->bitAnd(
'rc_deleted', $bitmask ) .
" != $bitmask",
653 private function getStartFromConds( IReadableDatabase $db, array $options, array $startFrom ) {
654 $op = $options[
'dir'] === self::DIR_OLDER ?
'<=' :
'>=';
655 [ $rcTimestamp, $rcId ] = $startFrom;
656 $rcTimestamp = $db->timestamp( $rcTimestamp );
658 return $db->buildComparison( $op, [
659 'rc_timestamp' => $rcTimestamp,
664 private function addQueryCondsForWatchedItemsForUser(
665 IReadableDatabase $db, UserIdentity $user, array $options, SelectQueryBuilder $queryBuilder
667 $queryBuilder->where( [
'wl_user' => $user->getId() ] );
668 if ( $options[
'namespaceIds'] ) {
669 $queryBuilder->where( [
'wl_namespace' => array_map(
'intval', $options[
'namespaceIds'] ) ] );
671 if ( isset( $options[
'filter'] ) ) {
672 $filter = $options[
'filter'];
673 if ( $filter === self::FILTER_CHANGED ) {
674 $queryBuilder->where(
'wl_notificationtimestamp IS NOT NULL' );
676 $queryBuilder->where(
'wl_notificationtimestamp IS NULL' );
680 if ( isset( $options[
'from'] ) ) {
681 $op = $options[
'sort'] === self::SORT_ASC ?
'>=' :
'<=';
682 $queryBuilder->where( $this->getFromUntilTargetConds( $db, $options[
'from'], $op ) );
684 if ( isset( $options[
'until'] ) ) {
685 $op = $options[
'sort'] === self::SORT_ASC ?
'<=' :
'>=';
686 $queryBuilder->where( $this->getFromUntilTargetConds( $db, $options[
'until'], $op ) );
688 if ( isset( $options[
'startFrom'] ) ) {
689 $op = $options[
'sort'] === self::SORT_ASC ?
'>=' :
'<=';
690 $queryBuilder->where( $this->getFromUntilTargetConds( $db, $options[
'startFrom'], $op ) );
703 private function getFromUntilTargetConds( IReadableDatabase $db, LinkTarget $target, $op ) {
704 return $db->buildComparison( $op, [
705 'wl_namespace' => $target->getNamespace(),
706 'wl_title' => $target->getDBkey(),
710 private function getWatchedItemsWithRCInfoQueryDbOptions( array $options ) {
713 if ( array_key_exists(
'dir', $options ) ) {
714 $sort = $options[
'dir'] === self::DIR_OLDER ?
' DESC' :
'';
715 $dbOptions[
'ORDER BY'] = [
'rc_timestamp' . $sort,
'rc_id' . $sort ];
718 if ( array_key_exists(
'limit', $options ) ) {
719 $dbOptions[
'LIMIT'] = (int)$options[
'limit'] + 1;
721 if ( $this->maxQueryExecutionTime ) {
722 $dbOptions[
'MAX_EXECUTION_TIME'] = $this->maxQueryExecutionTime;
727 private function addQueryDbOptionsForWatchedItemsForUser( array $options, SelectQueryBuilder $queryBuilder ) {
728 if ( array_key_exists(
'sort', $options ) ) {
729 if ( count( $options[
'namespaceIds'] ) !== 1 ) {
730 $queryBuilder->orderBy(
'wl_namespace', $options[
'sort'] );
732 $queryBuilder->orderBy(
'wl_title', $options[
'sort'] );
734 if ( array_key_exists(
'limit', $options ) ) {
735 $queryBuilder->limit( (
int)$options[
'limit'] );
737 if ( $this->maxQueryExecutionTime ) {
738 $queryBuilder->setMaxExecutionTime( $this->maxQueryExecutionTime );
742 private function getWatchedItemsWithRCInfoQueryJoinConds( array $options ) {
744 'watchlist' => [
'JOIN',
746 'wl_namespace=rc_namespace',
752 if ( $this->expiryEnabled ) {
753 $joinConds[
'watchlist_expiry'] = [
'LEFT JOIN',
'wl_id = we_item' ];
756 if ( !$options[
'allRevisions'] ) {
757 $joinConds[
'page'] = [
'LEFT JOIN',
'rc_cur_id=page_id' ];
759 if ( in_array( self::INCLUDE_COMMENT, $options[
'includeFields'] ) ) {
760 $joinConds += $this->commentStore->getJoin(
'rc_comment' )[
'joins'];
762 if ( in_array( self::INCLUDE_USER, $options[
'includeFields'] ) ||
763 in_array( self::INCLUDE_USER_ID, $options[
'includeFields'] ) ||
764 in_array( self::FILTER_ANON, $options[
'filters'] ) ||
765 in_array( self::FILTER_NOT_ANON, $options[
'filters'] ) ||
766 array_key_exists(
'onlyByUser', $options ) || array_key_exists(
'notByUser', $options )
768 $joinConds[
'watchlist_actor'] = [
'JOIN',
'actor_id=rc_actor' ];