72 private $extensions =
null;
75 private $commentStore;
78 private $watchedItemStore;
84 private $userOptionsLookup;
87 private $tempUserConfig;
92 private $expiryEnabled;
97 private $maxQueryExecutionTime;
106 bool $expiryEnabled =
false,
107 int $maxQueryExecutionTime = 0
109 $this->dbProvider = $dbProvider;
110 $this->commentStore = $commentStore;
111 $this->watchedItemStore = $watchedItemStore;
112 $this->hookRunner =
new HookRunner( $hookContainer );
113 $this->userOptionsLookup = $userOptionsLookup;
114 $this->tempUserConfig = $tempUserConfig;
115 $this->expiryEnabled = $expiryEnabled;
116 $this->maxQueryExecutionTime = $maxQueryExecutionTime;
122 private function getExtensions() {
123 if ( $this->extensions ===
null ) {
124 $this->extensions = [];
125 $this->hookRunner->onWatchedItemQueryServiceExtensions( $this->extensions, $this );
127 return $this->extensions;
174 User $user, array $options = [], &$startFrom =
null
177 'includeFields' => [],
178 'namespaceIds' => [],
180 'allRevisions' =>
false,
181 'usedInGenerator' => false
185 !isset( $options[
'rcTypes'] )
187 '$options[\'rcTypes\']',
188 'must be an array containing only: RC_EDIT, RC_NEW, RC_LOG, RC_EXTERNAL and/or RC_CATEGORIZE'
191 !isset( $options[
'dir'] ) || in_array( $options[
'dir'], [ self::DIR_OLDER, self::DIR_NEWER ] ),
193 'must be DIR_OLDER or DIR_NEWER'
196 ( !isset( $options[
'start'] ) && !isset( $options[
'end'] ) && $startFrom ===
null )
197 || isset( $options[
'dir'] ),
199 'must be provided when providing the "start" or "end" options or the $startFrom parameter'
202 !isset( $options[
'startFrom'] ),
203 '$options[\'startFrom\']',
204 'must not be provided, use $startFrom instead'
207 !isset( $startFrom ) || ( is_array( $startFrom ) && count( $startFrom ) === 2 ),
209 'must be a two-element array'
211 if ( array_key_exists(
'watchlistOwner', $options ) ) {
212 Assert::parameterType(
214 $options[
'watchlistOwner'],
215 '$options[\'watchlistOwner\']'
218 isset( $options[
'watchlistOwnerToken'] ),
219 '$options[\'watchlistOwnerToken\']',
220 'must be provided when providing watchlistOwner option'
224 $db = $this->dbProvider->getReplicaDatabase();
226 $tables = $this->getWatchedItemsWithRCInfoQueryTables( $options );
227 $fields = $this->getWatchedItemsWithRCInfoQueryFields( $options );
228 $conds = $this->getWatchedItemsWithRCInfoQueryConds( $db, $user, $options );
229 $dbOptions = $this->getWatchedItemsWithRCInfoQueryDbOptions( $options );
230 $joinConds = $this->getWatchedItemsWithRCInfoQueryJoinConds( $options );
232 if ( $startFrom !==
null ) {
233 $conds[] = $this->getStartFromConds( $db, $options, $startFrom );
236 foreach ( $this->getExtensions() as $extension ) {
237 $extension->modifyWatchedItemsWithRCInfoQuery(
238 $user, $options, $db,
251 ->caller( __METHOD__ )
252 ->options( $dbOptions )
253 ->joinConds( $joinConds )
256 $limit = $dbOptions[
'LIMIT'] ?? INF;
259 foreach ( $res as $row ) {
260 if ( --$limit <= 0 ) {
261 $startFrom = [ $row->rc_timestamp, $row->rc_id ];
265 $target =
new TitleValue( (
int)$row->rc_namespace, $row->rc_title );
270 $this->watchedItemStore->getLatestNotificationTimestamp(
271 $row->wl_notificationtimestamp, $user, $target
273 $row->we_expiry ??
null
275 $this->getRecentChangeFieldsFromRow( $row )
279 foreach ( $this->getExtensions() as $extension ) {
280 $extension->modifyWatchedItemsWithRCInfo( $user, $options, $db, $items, $res, $startFrom );
312 $options += [
'namespaceIds' => [] ];
315 !isset( $options[
'sort'] ) || in_array( $options[
'sort'], [ self::SORT_ASC, self::SORT_DESC ] ),
316 '$options[\'sort\']',
317 'must be SORT_ASC or SORT_DESC'
320 !isset( $options[
'filter'] ) || in_array(
321 $options[
'filter'], [ self::FILTER_CHANGED, self::FILTER_NOT_CHANGED ]
323 '$options[\'filter\']',
324 'must be FILTER_CHANGED or FILTER_NOT_CHANGED'
327 ( !isset( $options[
'from'] ) && !isset( $options[
'until'] ) && !isset( $options[
'startFrom'] ) )
328 || isset( $options[
'sort'] ),
329 '$options[\'sort\']',
330 'must be provided if any of "from", "until", "startFrom" options is provided'
333 $db = $this->dbProvider->getReplicaDatabase();
336 ->select( [
'wl_namespace',
'wl_title',
'wl_notificationtimestamp' ] )
337 ->from(
'watchlist' )
338 ->caller( __METHOD__ );
339 $this->addQueryCondsForWatchedItemsForUser( $db, $user, $options, $queryBuilder );
340 $this->addQueryDbOptionsForWatchedItemsForUser( $options, $queryBuilder );
342 if ( $this->expiryEnabled ) {
344 $queryBuilder->
leftJoin(
'watchlist_expiry',
null,
'wl_id = we_item' )
345 ->andWhere( $db->
expr(
'we_expiry',
'>', $db->
timestamp() )->or(
'we_expiry',
'=',
null ) );
350 foreach ( $res as $row ) {
351 $target =
new TitleValue( (
int)$row->wl_namespace, $row->wl_title );
356 $this->watchedItemStore->getLatestNotificationTimestamp(
357 $row->wl_notificationtimestamp, $user, $target
359 $row->we_expiry ??
null
363 return $watchedItems;
366 private function getRecentChangeFieldsFromRow( stdClass $row ) {
368 get_object_vars( $row ),
369 static function ( $key ) {
370 return str_starts_with( $key,
'rc_' );
376 private function getWatchedItemsWithRCInfoQueryTables( array $options ) {
377 $tables = [
'recentchanges',
'watchlist' ];
379 if ( $this->expiryEnabled ) {
380 $tables[] =
'watchlist_expiry';
383 if ( !$options[
'allRevisions'] ) {
386 if ( in_array( self::INCLUDE_COMMENT, $options[
'includeFields'] ) ) {
387 $tables += $this->commentStore->getJoin(
'rc_comment' )[
'tables'];
389 if ( in_array( self::INCLUDE_USER, $options[
'includeFields'] ) ||
390 in_array( self::INCLUDE_USER_ID, $options[
'includeFields'] ) ||
391 in_array( self::FILTER_ANON, $options[
'filters'] ) ||
392 in_array( self::FILTER_NOT_ANON, $options[
'filters'] ) ||
393 array_key_exists(
'onlyByUser', $options ) || array_key_exists(
'notByUser', $options )
395 $tables[
'watchlist_actor'] =
'actor';
400 private function getWatchedItemsWithRCInfoQueryFields( array $options ) {
408 'wl_notificationtimestamp'
411 if ( $this->expiryEnabled ) {
412 $fields[] =
'we_expiry';
420 if ( $options[
'usedInGenerator'] ) {
421 if ( $options[
'allRevisions'] ) {
422 $rcIdFields = [
'rc_this_oldid' ];
424 $rcIdFields = [
'rc_cur_id' ];
427 $fields = array_merge( $fields, $rcIdFields );
429 if ( in_array( self::INCLUDE_FLAGS, $options[
'includeFields'] ) ) {
430 $fields = array_merge( $fields, [
'rc_type',
'rc_minor',
'rc_bot' ] );
432 if ( in_array( self::INCLUDE_USER, $options[
'includeFields'] ) ) {
433 $fields[
'rc_user_text'] =
'watchlist_actor.actor_name';
435 if ( in_array( self::INCLUDE_USER_ID, $options[
'includeFields'] ) ) {
436 $fields[
'rc_user'] =
'watchlist_actor.actor_user';
438 if ( in_array( self::INCLUDE_COMMENT, $options[
'includeFields'] ) ) {
439 $fields += $this->commentStore->getJoin(
'rc_comment' )[
'fields'];
441 if ( in_array( self::INCLUDE_PATROL_INFO, $options[
'includeFields'] ) ) {
442 $fields = array_merge( $fields, [
'rc_patrolled',
'rc_log_type' ] );
444 if ( in_array( self::INCLUDE_SIZES, $options[
'includeFields'] ) ) {
445 $fields = array_merge( $fields, [
'rc_old_len',
'rc_new_len' ] );
447 if ( in_array( self::INCLUDE_LOG_INFO, $options[
'includeFields'] ) ) {
448 $fields = array_merge( $fields, [
'rc_logid',
'rc_log_type',
'rc_log_action',
'rc_params' ] );
450 if ( in_array( self::INCLUDE_TAGS, $options[
'includeFields'] ) ) {
458 private function getWatchedItemsWithRCInfoQueryConds(
463 $watchlistOwnerId = $this->getWatchlistOwnerId( $user, $options );
464 $conds = [
'wl_user' => $watchlistOwnerId ];
466 if ( $this->expiryEnabled ) {
467 $conds[] = $db->
expr(
'we_expiry',
'=',
null )->or(
'we_expiry',
'>', $db->
timestamp() );
470 if ( !$options[
'allRevisions'] ) {
472 [
'rc_this_oldid=page_latest',
'rc_type=' .
RC_LOG ],
477 if ( $options[
'namespaceIds'] ) {
478 $conds[
'wl_namespace'] = array_map(
'intval', $options[
'namespaceIds'] );
481 if ( array_key_exists(
'rcTypes', $options ) ) {
482 $conds[
'rc_type'] = array_map(
'intval', $options[
'rcTypes'] );
485 $conds = array_merge(
487 $this->getWatchedItemsWithRCInfoQueryFilterConds( $db, $user, $options )
490 $conds = array_merge( $conds, $this->getStartEndConds( $db, $options ) );
492 if ( !isset( $options[
'start'] ) && !isset( $options[
'end'] ) && $db->
getType() ===
'mysql' ) {
494 $conds[] = $db->
expr(
'rc_timestamp',
'>',
'' );
497 $conds = array_merge( $conds, $this->getUserRelatedConds( $db, $user, $options ) );
499 $deletedPageLogCond = $this->getExtraDeletedPageLogEntryRelatedCond( $db, $user );
500 if ( $deletedPageLogCond ) {
501 $conds[] = $deletedPageLogCond;
507 private function getWatchlistOwnerId(
UserIdentity $user, array $options ) {
508 if ( array_key_exists(
'watchlistOwner', $options ) ) {
510 $watchlistOwner = $options[
'watchlistOwner'];
512 $this->userOptionsLookup->getOption( $watchlistOwner,
'watchlisttoken' );
513 $token = $options[
'watchlistOwnerToken'];
514 if ( $ownersToken ==
'' || !hash_equals( $ownersToken, $token ) ) {
517 return $watchlistOwner->getId();
519 return $user->
getId();
522 private function getWatchedItemsWithRCInfoQueryFilterConds(
529 if ( in_array( self::FILTER_MINOR, $options[
'filters'] ) ) {
530 $conds[] =
'rc_minor != 0';
531 } elseif ( in_array( self::FILTER_NOT_MINOR, $options[
'filters'] ) ) {
532 $conds[] =
'rc_minor = 0';
535 if ( in_array( self::FILTER_BOT, $options[
'filters'] ) ) {
536 $conds[] =
'rc_bot != 0';
537 } elseif ( in_array( self::FILTER_NOT_BOT, $options[
'filters'] ) ) {
538 $conds[] =
'rc_bot = 0';
542 if ( in_array( self::FILTER_ANON, $options[
'filters'] ) ) {
543 if ( $this->tempUserConfig->isEnabled() ) {
544 $conds[] = $dbr->
expr(
'watchlist_actor.actor_user',
'=',
null )
545 ->orExpr( $this->tempUserConfig->getMatchCondition( $dbr,
546 'watchlist_actor.actor_name', IExpression::LIKE ) );
548 $conds[] =
'watchlist_actor.actor_user IS NULL';
550 } elseif ( in_array( self::FILTER_NOT_ANON, $options[
'filters'] ) ) {
551 $conds[] =
'watchlist_actor.actor_user IS NOT NULL';
552 if ( $this->tempUserConfig->isEnabled() ) {
553 $conds[] = $this->tempUserConfig->getMatchCondition( $dbr,
554 'watchlist_actor.actor_name', IExpression::NOT_LIKE );
561 if ( in_array( self::FILTER_PATROLLED, $options[
'filters'] ) ) {
563 } elseif ( in_array( self::FILTER_NOT_PATROLLED, $options[
'filters'] ) ) {
567 if ( in_array( self::FILTER_AUTOPATROLLED, $options[
'filters'] ) ) {
569 } elseif ( in_array( self::FILTER_NOT_AUTOPATROLLED, $options[
'filters'] ) ) {
574 if ( in_array( self::FILTER_UNREAD, $options[
'filters'] ) ) {
575 $conds[] =
'rc_timestamp >= wl_notificationtimestamp';
576 } elseif ( in_array( self::FILTER_NOT_UNREAD, $options[
'filters'] ) ) {
578 $conds[] =
'wl_notificationtimestamp IS NULL OR rc_timestamp < wl_notificationtimestamp';
585 if ( !isset( $options[
'start'] ) && !isset( $options[
'end'] ) ) {
590 if ( isset( $options[
'start'] ) ) {
591 $after = $options[
'dir'] === self::DIR_OLDER ?
'<=' :
'>=';
592 $conds[] = $db->
expr(
'rc_timestamp', $after, $db->
timestamp( $options[
'start'] ) );
594 if ( isset( $options[
'end'] ) ) {
595 $before = $options[
'dir'] === self::DIR_OLDER ?
'>=' :
'<=';
596 $conds[] = $db->
expr(
'rc_timestamp', $before, $db->
timestamp( $options[
'end'] ) );
603 if ( !array_key_exists(
'onlyByUser', $options ) && !array_key_exists(
'notByUser', $options ) ) {
609 if ( array_key_exists(
'onlyByUser', $options ) ) {
610 $conds[
'watchlist_actor.actor_name'] = $options[
'onlyByUser'];
611 } elseif ( array_key_exists(
'notByUser', $options ) ) {
612 $conds[] = $db->
expr(
'watchlist_actor.actor_name',
'!=', $options[
'notByUser'] );
617 if ( !$user->
isAllowed(
'deletedhistory' ) ) {
618 $bitmask = RevisionRecord::DELETED_USER;
619 } elseif ( !$user->
isAllowedAny(
'suppressrevision',
'viewsuppressed' ) ) {
620 $bitmask = RevisionRecord::DELETED_USER | RevisionRecord::DELETED_RESTRICTED;
623 $conds[] = $db->
bitAnd(
'rc_deleted', $bitmask ) .
" != $bitmask";
633 if ( !$user->
isAllowed(
'deletedhistory' ) ) {
635 } elseif ( !$user->
isAllowedAny(
'suppressrevision',
'viewsuppressed' ) ) {
641 $db->
bitAnd(
'rc_deleted', $bitmask ) .
" != $bitmask",
647 private function getStartFromConds(
IReadableDatabase $db, array $options, array $startFrom ) {
648 $op = $options[
'dir'] === self::DIR_OLDER ?
'<=' :
'>=';
649 [ $rcTimestamp, $rcId ] = $startFrom;
650 $rcTimestamp = $db->
timestamp( $rcTimestamp );
653 'rc_timestamp' => $rcTimestamp,
658 private function addQueryCondsForWatchedItemsForUser(
661 $queryBuilder->
where( [
'wl_user' => $user->
getId() ] );
662 if ( $options[
'namespaceIds'] ) {
663 $queryBuilder->
where( [
'wl_namespace' => array_map(
'intval', $options[
'namespaceIds'] ) ] );
665 if ( isset( $options[
'filter'] ) ) {
666 $filter = $options[
'filter'];
667 if ( $filter === self::FILTER_CHANGED ) {
668 $queryBuilder->
where(
'wl_notificationtimestamp IS NOT NULL' );
670 $queryBuilder->
where(
'wl_notificationtimestamp IS NULL' );
674 if ( isset( $options[
'from'] ) ) {
675 $op = $options[
'sort'] === self::SORT_ASC ?
'>=' :
'<=';
676 $queryBuilder->
where( $this->getFromUntilTargetConds( $db, $options[
'from'], $op ) );
678 if ( isset( $options[
'until'] ) ) {
679 $op = $options[
'sort'] === self::SORT_ASC ?
'<=' :
'>=';
680 $queryBuilder->
where( $this->getFromUntilTargetConds( $db, $options[
'until'], $op ) );
682 if ( isset( $options[
'startFrom'] ) ) {
683 $op = $options[
'sort'] === self::SORT_ASC ?
'>=' :
'<=';
684 $queryBuilder->
where( $this->getFromUntilTargetConds( $db, $options[
'startFrom'], $op ) );
704 private function getWatchedItemsWithRCInfoQueryDbOptions( array $options ) {
707 if ( array_key_exists(
'dir', $options ) ) {
708 $sort = $options[
'dir'] === self::DIR_OLDER ?
' DESC' :
'';
709 $dbOptions[
'ORDER BY'] = [
'rc_timestamp' . $sort,
'rc_id' . $sort ];
712 if ( array_key_exists(
'limit', $options ) ) {
713 $dbOptions[
'LIMIT'] = (int)$options[
'limit'] + 1;
715 if ( $this->maxQueryExecutionTime ) {
716 $dbOptions[
'MAX_EXECUTION_TIME'] = $this->maxQueryExecutionTime;
721 private function addQueryDbOptionsForWatchedItemsForUser( array $options,
SelectQueryBuilder $queryBuilder ) {
722 if ( array_key_exists(
'sort', $options ) ) {
723 if ( count( $options[
'namespaceIds'] ) !== 1 ) {
724 $queryBuilder->
orderBy(
'wl_namespace', $options[
'sort'] );
726 $queryBuilder->
orderBy(
'wl_title', $options[
'sort'] );
728 if ( array_key_exists(
'limit', $options ) ) {
729 $queryBuilder->
limit( (
int)$options[
'limit'] );
731 if ( $this->maxQueryExecutionTime ) {
736 private function getWatchedItemsWithRCInfoQueryJoinConds( array $options ) {
738 'watchlist' => [
'JOIN',
740 'wl_namespace=rc_namespace',
746 if ( $this->expiryEnabled ) {
747 $joinConds[
'watchlist_expiry'] = [
'LEFT JOIN',
'wl_id = we_item' ];
750 if ( !$options[
'allRevisions'] ) {
751 $joinConds[
'page'] = [
'LEFT JOIN',
'rc_cur_id=page_id' ];
753 if ( in_array( self::INCLUDE_COMMENT, $options[
'includeFields'] ) ) {
754 $joinConds += $this->commentStore->getJoin(
'rc_comment' )[
'joins'];
756 if ( in_array( self::INCLUDE_USER, $options[
'includeFields'] ) ||
757 in_array( self::INCLUDE_USER_ID, $options[
'includeFields'] ) ||
758 in_array( self::FILTER_ANON, $options[
'filters'] ) ||
759 in_array( self::FILTER_NOT_ANON, $options[
'filters'] ) ||
760 array_key_exists(
'onlyByUser', $options ) || array_key_exists(
'notByUser', $options )
762 $joinConds[
'watchlist_actor'] = [
'JOIN',
'actor_id=rc_actor' ];