Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.15% covered (success)
98.15%
372 / 379
73.68% covered (warning)
73.68%
14 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
WatchedItemQueryService
98.41% covered (success)
98.41%
372 / 378
73.68% covered (warning)
73.68%
14 / 19
131
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getExtensions
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getWatchedItemsWithRecentChangeInfo
100.00% covered (success)
100.00%
92 / 92
100.00% covered (success)
100.00%
1 / 1
14
 getWatchedItemsForUser
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
1 / 1
9
 getRecentChangeFieldsFromRow
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getWatchedItemsWithRCInfoQueryTables
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
10
 getWatchedItemsWithRCInfoQueryFields
97.37% covered (success)
97.37%
37 / 38
0.00% covered (danger)
0.00%
0 / 1
12
 getWatchedItemsWithRCInfoQueryConds
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
9
 getWatchlistOwnerId
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getWatchedItemsWithRCInfoQueryFilterConds
94.12% covered (success)
94.12%
32 / 34
0.00% covered (danger)
0.00%
0 / 1
17.06
 getStartEndConds
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 getUserRelatedConds
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
8
 getExtraDeletedPageLogEntryRelatedCond
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 getStartFromConds
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 addQueryCondsForWatchedItemsForUser
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
10
 getFromUntilTargetConds
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getWatchedItemsWithRCInfoQueryDbOptions
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 addQueryDbOptionsForWatchedItemsForUser
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 getWatchedItemsWithRCInfoQueryJoinConds
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
10
1<?php
2
3namespace MediaWiki\Watchlist;
4
5use ApiUsageException;
6use ChangeTags;
7use LogPage;
8use MediaWiki\CommentStore\CommentStore;
9use MediaWiki\HookContainer\HookContainer;
10use MediaWiki\HookContainer\HookRunner;
11use MediaWiki\Linker\LinkTarget;
12use MediaWiki\Permissions\Authority;
13use MediaWiki\Revision\RevisionRecord;
14use MediaWiki\Title\TitleValue;
15use MediaWiki\User\Options\UserOptionsLookup;
16use MediaWiki\User\TempUser\TempUserConfig;
17use MediaWiki\User\User;
18use MediaWiki\User\UserIdentity;
19use RecentChange;
20use Wikimedia\Assert\Assert;
21use Wikimedia\Rdbms\IConnectionProvider;
22use Wikimedia\Rdbms\IExpression;
23use Wikimedia\Rdbms\IReadableDatabase;
24use Wikimedia\Rdbms\SelectQueryBuilder;
25
26/**
27 * Class performing complex database queries related to WatchedItems.
28 *
29 * @since 1.28
30 *
31 * @file
32 * @ingroup Watchlist
33 *
34 * @license GPL-2.0-or-later
35 */
36class WatchedItemQueryService {
37
38    public const DIR_OLDER = 'older';
39    public const DIR_NEWER = 'newer';
40
41    public const INCLUDE_FLAGS = 'flags';
42    public const INCLUDE_USER = 'user';
43    public const INCLUDE_USER_ID = 'userid';
44    public const INCLUDE_COMMENT = 'comment';
45    public const INCLUDE_PATROL_INFO = 'patrol';
46    public const INCLUDE_AUTOPATROL_INFO = 'autopatrol';
47    public const INCLUDE_SIZES = 'sizes';
48    public const INCLUDE_LOG_INFO = 'loginfo';
49    public const INCLUDE_TAGS = 'tags';
50
51    // FILTER_* constants are part of public API (are used in ApiQueryWatchlist and
52    // ApiQueryWatchlistRaw classes) and should not be changed.
53    // Changing values of those constants will result in a breaking change in the API
54    public const FILTER_MINOR = 'minor';
55    public const FILTER_NOT_MINOR = '!minor';
56    public const FILTER_BOT = 'bot';
57    public const FILTER_NOT_BOT = '!bot';
58    public const FILTER_ANON = 'anon';
59    public const FILTER_NOT_ANON = '!anon';
60    public const FILTER_PATROLLED = 'patrolled';
61    public const FILTER_NOT_PATROLLED = '!patrolled';
62    public const FILTER_AUTOPATROLLED = 'autopatrolled';
63    public const FILTER_NOT_AUTOPATROLLED = '!autopatrolled';
64    public const FILTER_UNREAD = 'unread';
65    public const FILTER_NOT_UNREAD = '!unread';
66    public const FILTER_CHANGED = 'changed';
67    public const FILTER_NOT_CHANGED = '!changed';
68
69    public const SORT_ASC = 'ASC';
70    public const SORT_DESC = 'DESC';
71
72    /**
73     * @var IConnectionProvider
74     */
75    private $dbProvider;
76
77    /** @var WatchedItemQueryServiceExtension[]|null */
78    private $extensions = null;
79
80    /** @var CommentStore */
81    private $commentStore;
82
83    /** @var WatchedItemStoreInterface */
84    private $watchedItemStore;
85
86    /** @var HookRunner */
87    private $hookRunner;
88
89    /** @var UserOptionsLookup */
90    private $userOptionsLookup;
91
92    /** @var TempUserConfig */
93    private $tempUserConfig;
94
95    /**
96     * @var bool Correlates to $wgWatchlistExpiry feature flag.
97     */
98    private $expiryEnabled;
99
100    /**
101     * @var int Max query execution time
102     */
103    private $maxQueryExecutionTime;
104
105    public function __construct(
106        IConnectionProvider $dbProvider,
107        CommentStore $commentStore,
108        WatchedItemStoreInterface $watchedItemStore,
109        HookContainer $hookContainer,
110        UserOptionsLookup $userOptionsLookup,
111        TempUserConfig $tempUserConfig,
112        bool $expiryEnabled = false,
113        int $maxQueryExecutionTime = 0
114    ) {
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;
123    }
124
125    /**
126     * @return WatchedItemQueryServiceExtension[]
127     */
128    private function getExtensions() {
129        if ( $this->extensions === null ) {
130            $this->extensions = [];
131            $this->hookRunner->onWatchedItemQueryServiceExtensions( $this->extensions, $this );
132        }
133        return $this->extensions;
134    }
135
136    /**
137     * @param User $user
138     * @param array $options Allowed keys:
139     *        'includeFields'       => string[] RecentChange fields to be included in the result,
140     *                                 self::INCLUDE_* constants should be used
141     *        'filters'             => string[] optional filters to narrow down resulted items
142     *        'namespaceIds'        => int[] optional namespace IDs to filter by
143     *                                 (defaults to all namespaces)
144     *        'allRevisions'        => bool return multiple revisions of the same page if true,
145     *                                 only the most recent if false (default)
146     *        'rcTypes'             => int[] which types of RecentChanges to include
147     *                                 (defaults to all types), allowed values: RC_EDIT, RC_NEW,
148     *                                 RC_LOG, RC_EXTERNAL, RC_CATEGORIZE
149     *        'onlyByUser'          => string only list changes by a specified user
150     *        'notByUser'           => string do not include changes by a specified user
151     *        'dir'                 => string in which direction to enumerate, accepted values:
152     *                                 - DIR_OLDER list newest first
153     *                                 - DIR_NEWER list oldest first
154     *        'start'               => string (format accepted by wfTimestamp) requires 'dir' option,
155     *                                 timestamp to start enumerating from
156     *        'end'                 => string (format accepted by wfTimestamp) requires 'dir' option,
157     *                                 timestamp to end enumerating
158     *        'watchlistOwner'      => UserIdentity user whose watchlist items should be listed if different
159     *                                 than the one specified with $user param, requires
160     *                                 'watchlistOwnerToken' option
161     *        'watchlistOwnerToken' => string a watchlist token used to access another user's
162     *                                 watchlist, used with 'watchlistOwnerToken' option
163     *        'limit'               => int maximum numbers of items to return
164     *        'usedInGenerator'     => bool include only RecentChange id field required by the
165     *                                 generator ('rc_cur_id' or 'rc_this_oldid') if true, or all
166     *                                 id fields ('rc_cur_id', 'rc_this_oldid', 'rc_last_oldid')
167     *                                 if false (default)
168     * @param array|null &$startFrom Continuation value: [ string $rcTimestamp, int $rcId ]
169     * @return array[] Array of pairs ( WatchedItem $watchedItem, string[] $recentChangeInfo ),
170     *         where $recentChangeInfo contains the following keys:
171     *         - 'rc_id',
172     *         - 'rc_namespace',
173     *         - 'rc_title',
174     *         - 'rc_timestamp',
175     *         - 'rc_type',
176     *         - 'rc_deleted',
177     *         Additional keys could be added by specifying the 'includeFields' option
178     */
179    public function getWatchedItemsWithRecentChangeInfo(
180        User $user, array $options = [], &$startFrom = null
181    ) {
182        $options += [
183            'includeFields' => [],
184            'namespaceIds' => [],
185            'filters' => [],
186            'allRevisions' => false,
187            'usedInGenerator' => false
188        ];
189
190        Assert::parameter(
191            !isset( $options['rcTypes'] )
192                || !array_diff( $options['rcTypes'], [ RC_EDIT, RC_NEW, RC_LOG, RC_EXTERNAL, RC_CATEGORIZE ] ),
193            '$options[\'rcTypes\']',
194            'must be an array containing only: RC_EDIT, RC_NEW, RC_LOG, RC_EXTERNAL and/or RC_CATEGORIZE'
195        );
196        Assert::parameter(
197            !isset( $options['dir'] ) || in_array( $options['dir'], [ self::DIR_OLDER, self::DIR_NEWER ] ),
198            '$options[\'dir\']',
199            'must be DIR_OLDER or DIR_NEWER'
200        );
201        Assert::parameter(
202            ( !isset( $options['start'] ) && !isset( $options['end'] ) && $startFrom === null )
203                || isset( $options['dir'] ),
204            '$options[\'dir\']',
205            'must be provided when providing the "start" or "end" options or the $startFrom parameter'
206        );
207        Assert::parameter(
208            !isset( $options['startFrom'] ),
209            '$options[\'startFrom\']',
210            'must not be provided, use $startFrom instead'
211        );
212        Assert::parameter(
213            !isset( $startFrom ) || ( is_array( $startFrom ) && count( $startFrom ) === 2 ),
214            '$startFrom',
215            'must be a two-element array'
216        );
217        if ( array_key_exists( 'watchlistOwner', $options ) ) {
218            Assert::parameterType(
219                UserIdentity::class,
220                $options['watchlistOwner'],
221                '$options[\'watchlistOwner\']'
222            );
223            Assert::parameter(
224                isset( $options['watchlistOwnerToken'] ),
225                '$options[\'watchlistOwnerToken\']',
226                'must be provided when providing watchlistOwner option'
227            );
228        }
229
230        $db = $this->dbProvider->getReplicaDatabase();
231
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 );
237
238        if ( $startFrom !== null ) {
239            $conds[] = $this->getStartFromConds( $db, $options, $startFrom );
240        }
241
242        foreach ( $this->getExtensions() as $extension ) {
243            $extension->modifyWatchedItemsWithRCInfoQuery(
244                $user, $options, $db,
245                $tables,
246                $fields,
247                $conds,
248                $dbOptions,
249                $joinConds
250            );
251        }
252
253        $res = $db->newSelectQueryBuilder()
254            ->tables( $tables )
255            ->fields( $fields )
256            ->conds( $conds )
257            ->caller( __METHOD__ )
258            ->options( $dbOptions )
259            ->joinConds( $joinConds )
260            ->fetchResultSet();
261
262        $limit = $dbOptions['LIMIT'] ?? INF;
263        $items = [];
264        $startFrom = null;
265        foreach ( $res as $row ) {
266            if ( --$limit <= 0 ) {
267                $startFrom = [ $row->rc_timestamp, $row->rc_id ];
268                break;
269            }
270
271            $target = new TitleValue( (int)$row->rc_namespace, $row->rc_title );
272            $items[] = [
273                new WatchedItem(
274                    $user,
275                    $target,
276                    $this->watchedItemStore->getLatestNotificationTimestamp(
277                        $row->wl_notificationtimestamp, $user, $target
278                    ),
279                    $row->we_expiry ?? null
280                ),
281                $this->getRecentChangeFieldsFromRow( $row )
282            ];
283        }
284
285        foreach ( $this->getExtensions() as $extension ) {
286            $extension->modifyWatchedItemsWithRCInfo( $user, $options, $db, $items, $res, $startFrom );
287        }
288
289        return $items;
290    }
291
292    /**
293     * For simple listing of user's watchlist items, see WatchedItemStore::getWatchedItemsForUser
294     *
295     * @param UserIdentity $user
296     * @param array $options Allowed keys:
297     *        'sort'         => string optional sorting by namespace ID and title
298     *                          one of the self::SORT_* constants
299     *        'namespaceIds' => int[] optional namespace IDs to filter by (defaults to all namespaces)
300     *        'limit'        => int maximum number of items to return
301     *        'filter'       => string optional filter, one of the self::FILTER_* constants
302     *        'from'         => LinkTarget requires 'sort' key, only return items starting from
303     *                          those related to the link target
304     *        'until'        => LinkTarget requires 'sort' key, only return items until
305     *                          those related to the link target
306     *        'startFrom'    => LinkTarget requires 'sort' key, only return items starting from
307     *                          those related to the link target, allows to skip some link targets
308     *                          specified using the form option
309     * @return WatchedItem[]
310     */
311    public function getWatchedItemsForUser( UserIdentity $user, array $options = [] ) {
312        if ( !$user->isRegistered() ) {
313            // TODO: should this just return an empty array or rather complain loud at this point
314            // as e.g. ApiBase::getWatchlistUser does?
315            return [];
316        }
317
318        $options += [ 'namespaceIds' => [] ];
319
320        Assert::parameter(
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'
324        );
325        Assert::parameter(
326            !isset( $options['filter'] ) || in_array(
327                $options['filter'], [ self::FILTER_CHANGED, self::FILTER_NOT_CHANGED ]
328            ),
329            '$options[\'filter\']',
330            'must be FILTER_CHANGED or FILTER_NOT_CHANGED'
331        );
332        Assert::parameter(
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'
337        );
338
339        $db = $this->dbProvider->getReplicaDatabase();
340
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 );
347
348        if ( $this->expiryEnabled ) {
349            // If expiries are enabled, join with the watchlist_expiry table and exclude expired items.
350            $queryBuilder->leftJoin( 'watchlist_expiry', null, 'wl_id = we_item' )
351                ->andWhere( $db->expr( 'we_expiry', '>', $db->timestamp() )->or( 'we_expiry', '=', null ) );
352        }
353        $res = $queryBuilder->fetchResultSet();
354
355        $watchedItems = [];
356        foreach ( $res as $row ) {
357            $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
358            // todo these could all be cached at some point?
359            $watchedItems[] = new WatchedItem(
360                $user,
361                $target,
362                $this->watchedItemStore->getLatestNotificationTimestamp(
363                    $row->wl_notificationtimestamp, $user, $target
364                ),
365                $row->we_expiry ?? null
366            );
367        }
368
369        return $watchedItems;
370    }
371
372    private function getRecentChangeFieldsFromRow( \stdClass $row ) {
373        return array_filter(
374            get_object_vars( $row ),
375            static function ( $key ) {
376                return str_starts_with( $key, 'rc_' );
377            },
378            ARRAY_FILTER_USE_KEY
379        );
380    }
381
382    private function getWatchedItemsWithRCInfoQueryTables( array $options ) {
383        $tables = [ 'recentchanges', 'watchlist' ];
384
385        if ( $this->expiryEnabled ) {
386            $tables[] = 'watchlist_expiry';
387        }
388
389        if ( !$options['allRevisions'] ) {
390            $tables[] = 'page';
391        }
392        if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) {
393            $tables += $this->commentStore->getJoin( 'rc_comment' )['tables'];
394        }
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 )
400        ) {
401            $tables['watchlist_actor'] = 'actor';
402        }
403        return $tables;
404    }
405
406    private function getWatchedItemsWithRCInfoQueryFields( array $options ) {
407        $fields = [
408            'rc_id',
409            'rc_namespace',
410            'rc_title',
411            'rc_timestamp',
412            'rc_type',
413            'rc_deleted',
414            'wl_notificationtimestamp'
415        ];
416
417        if ( $this->expiryEnabled ) {
418            $fields[] = 'we_expiry';
419        }
420
421        $rcIdFields = [
422            'rc_cur_id',
423            'rc_this_oldid',
424            'rc_last_oldid',
425        ];
426        if ( $options['usedInGenerator'] ) {
427            if ( $options['allRevisions'] ) {
428                $rcIdFields = [ 'rc_this_oldid' ];
429            } else {
430                $rcIdFields = [ 'rc_cur_id' ];
431            }
432        }
433        $fields = array_merge( $fields, $rcIdFields );
434
435        if ( in_array( self::INCLUDE_FLAGS, $options['includeFields'] ) ) {
436            $fields = array_merge( $fields, [ 'rc_type', 'rc_minor', 'rc_bot' ] );
437        }
438        if ( in_array( self::INCLUDE_USER, $options['includeFields'] ) ) {
439            $fields['rc_user_text'] = 'watchlist_actor.actor_name';
440        }
441        if ( in_array( self::INCLUDE_USER_ID, $options['includeFields'] ) ) {
442            $fields['rc_user'] = 'watchlist_actor.actor_user';
443        }
444        if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) {
445            $fields += $this->commentStore->getJoin( 'rc_comment' )['fields'];
446        }
447        if ( in_array( self::INCLUDE_PATROL_INFO, $options['includeFields'] ) ) {
448            $fields = array_merge( $fields, [ 'rc_patrolled', 'rc_log_type' ] );
449        }
450        if ( in_array( self::INCLUDE_SIZES, $options['includeFields'] ) ) {
451            $fields = array_merge( $fields, [ 'rc_old_len', 'rc_new_len' ] );
452        }
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' ] );
455        }
456        if ( in_array( self::INCLUDE_TAGS, $options['includeFields'] ) ) {
457            // prefixed with rc_ to include the field in getRecentChangeFieldsFromRow
458            $fields['rc_tags'] = ChangeTags::makeTagSummarySubquery( 'recentchanges' );
459        }
460
461        return $fields;
462    }
463
464    private function getWatchedItemsWithRCInfoQueryConds(
465        IReadableDatabase $db,
466        User $user,
467        array $options
468    ) {
469        $watchlistOwnerId = $this->getWatchlistOwnerId( $user, $options );
470        $conds = [ 'wl_user' => $watchlistOwnerId ];
471
472        if ( $this->expiryEnabled ) {
473            $conds[] = $db->expr( 'we_expiry', '=', null )->or( 'we_expiry', '>', $db->timestamp() );
474        }
475
476        if ( !$options['allRevisions'] ) {
477            $conds[] = $db->makeList(
478                [ 'rc_this_oldid=page_latest', 'rc_type=' . RC_LOG ],
479                LIST_OR
480            );
481        }
482
483        if ( $options['namespaceIds'] ) {
484            $conds['wl_namespace'] = array_map( 'intval', $options['namespaceIds'] );
485        }
486
487        if ( array_key_exists( 'rcTypes', $options ) ) {
488            $conds['rc_type'] = array_map( 'intval', $options['rcTypes'] );
489        }
490
491        $conds = array_merge(
492            $conds,
493            $this->getWatchedItemsWithRCInfoQueryFilterConds( $db, $user, $options )
494        );
495
496        $conds = array_merge( $conds, $this->getStartEndConds( $db, $options ) );
497
498        if ( !isset( $options['start'] ) && !isset( $options['end'] ) && $db->getType() === 'mysql' ) {
499            // This is an index optimization for mysql
500            $conds[] = $db->expr( 'rc_timestamp', '>', '' );
501        }
502
503        $conds = array_merge( $conds, $this->getUserRelatedConds( $db, $user, $options ) );
504
505        $deletedPageLogCond = $this->getExtraDeletedPageLogEntryRelatedCond( $db, $user );
506        if ( $deletedPageLogCond ) {
507            $conds[] = $deletedPageLogCond;
508        }
509
510        return $conds;
511    }
512
513    private function getWatchlistOwnerId( UserIdentity $user, array $options ) {
514        if ( array_key_exists( 'watchlistOwner', $options ) ) {
515            /** @var UserIdentity $watchlistOwner */
516            $watchlistOwner = $options['watchlistOwner'];
517            $ownersToken =
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' );
522            }
523            return $watchlistOwner->getId();
524        }
525        return $user->getId();
526    }
527
528    private function getWatchedItemsWithRCInfoQueryFilterConds(
529        IReadableDatabase $dbr,
530        User $user,
531        array $options
532    ) {
533        $conds = [];
534
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';
539        }
540
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';
545        }
546
547        // Treat temporary users as 'anon', to match ChangesListSpecialPage
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 ) );
553            } else {
554                $conds[] = 'watchlist_actor.actor_user IS NULL';
555            }
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 );
561            }
562        }
563
564        if ( $user->useRCPatrol() || $user->useNPPatrol() ) {
565            // TODO: not sure if this should simply ignore patrolled filters if user does not have the patrol
566            // right, or maybe rather fail loud at this point, same as e.g. ApiQueryWatchlist does?
567            if ( in_array( self::FILTER_PATROLLED, $options['filters'] ) ) {
568                $conds[] = 'rc_patrolled != ' . RecentChange::PRC_UNPATROLLED;
569            } elseif ( in_array( self::FILTER_NOT_PATROLLED, $options['filters'] ) ) {
570                $conds['rc_patrolled'] = RecentChange::PRC_UNPATROLLED;
571            }
572
573            if ( in_array( self::FILTER_AUTOPATROLLED, $options['filters'] ) ) {
574                $conds['rc_patrolled'] = RecentChange::PRC_AUTOPATROLLED;
575            } elseif ( in_array( self::FILTER_NOT_AUTOPATROLLED, $options['filters'] ) ) {
576                $conds[] = 'rc_patrolled != ' . RecentChange::PRC_AUTOPATROLLED;
577            }
578        }
579
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'] ) ) {
583            // TODO: should this be changed to use Database::makeList?
584            $conds[] = 'wl_notificationtimestamp IS NULL OR rc_timestamp < wl_notificationtimestamp';
585        }
586
587        return $conds;
588    }
589
590    private function getStartEndConds( IReadableDatabase $db, array $options ) {
591        if ( !isset( $options['start'] ) && !isset( $options['end'] ) ) {
592            return [];
593        }
594
595        $conds = [];
596        if ( isset( $options['start'] ) ) {
597            $after = $options['dir'] === self::DIR_OLDER ? '<=' : '>=';
598            $conds[] = $db->expr( 'rc_timestamp', $after, $db->timestamp( $options['start'] ) );
599        }
600        if ( isset( $options['end'] ) ) {
601            $before = $options['dir'] === self::DIR_OLDER ? '>=' : '<=';
602            $conds[] = $db->expr( 'rc_timestamp', $before, $db->timestamp( $options['end'] ) );
603        }
604
605        return $conds;
606    }
607
608    private function getUserRelatedConds( IReadableDatabase $db, Authority $user, array $options ) {
609        if ( !array_key_exists( 'onlyByUser', $options ) && !array_key_exists( 'notByUser', $options ) ) {
610            return [];
611        }
612
613        $conds = [];
614
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'] );
619        }
620
621        // Avoid brute force searches (T19342)
622        $bitmask = 0;
623        if ( !$user->isAllowed( 'deletedhistory' ) ) {
624            $bitmask = RevisionRecord::DELETED_USER;
625        } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
626            $bitmask = RevisionRecord::DELETED_USER | RevisionRecord::DELETED_RESTRICTED;
627        }
628        if ( $bitmask ) {
629            $conds[] = $db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask";
630        }
631
632        return $conds;
633    }
634
635    private function getExtraDeletedPageLogEntryRelatedCond( IReadableDatabase $db, Authority $user ) {
636        // LogPage::DELETED_ACTION hides the affected page, too. So hide those
637        // entirely from the watchlist, or someone could guess the title.
638        $bitmask = 0;
639        if ( !$user->isAllowed( 'deletedhistory' ) ) {
640            $bitmask = LogPage::DELETED_ACTION;
641        } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
642            $bitmask = LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED;
643        }
644        if ( $bitmask ) {
645            return $db->makeList( [
646                'rc_type != ' . RC_LOG,
647                $db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask",
648            ], LIST_OR );
649        }
650        return '';
651    }
652
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 );
657        $rcId = (int)$rcId;
658        return $db->buildComparison( $op, [
659            'rc_timestamp' => $rcTimestamp,
660            'rc_id' => $rcId,
661        ] );
662    }
663
664    private function addQueryCondsForWatchedItemsForUser(
665        IReadableDatabase $db, UserIdentity $user, array $options, SelectQueryBuilder $queryBuilder
666    ) {
667        $queryBuilder->where( [ 'wl_user' => $user->getId() ] );
668        if ( $options['namespaceIds'] ) {
669            $queryBuilder->where( [ 'wl_namespace' => array_map( 'intval', $options['namespaceIds'] ) ] );
670        }
671        if ( isset( $options['filter'] ) ) {
672            $filter = $options['filter'];
673            if ( $filter === self::FILTER_CHANGED ) {
674                $queryBuilder->where( 'wl_notificationtimestamp IS NOT NULL' );
675            } else {
676                $queryBuilder->where( 'wl_notificationtimestamp IS NULL' );
677            }
678        }
679
680        if ( isset( $options['from'] ) ) {
681            $op = $options['sort'] === self::SORT_ASC ? '>=' : '<=';
682            $queryBuilder->where( $this->getFromUntilTargetConds( $db, $options['from'], $op ) );
683        }
684        if ( isset( $options['until'] ) ) {
685            $op = $options['sort'] === self::SORT_ASC ? '<=' : '>=';
686            $queryBuilder->where( $this->getFromUntilTargetConds( $db, $options['until'], $op ) );
687        }
688        if ( isset( $options['startFrom'] ) ) {
689            $op = $options['sort'] === self::SORT_ASC ? '>=' : '<=';
690            $queryBuilder->where( $this->getFromUntilTargetConds( $db, $options['startFrom'], $op ) );
691        }
692    }
693
694    /**
695     * Creates a query condition part for getting only items before or after the given link target
696     * (while ordering using $sort mode)
697     *
698     * @param IReadableDatabase $db
699     * @param LinkTarget $target
700     * @param string $op comparison operator to use in the conditions
701     * @return string
702     */
703    private function getFromUntilTargetConds( IReadableDatabase $db, LinkTarget $target, $op ) {
704        return $db->buildComparison( $op, [
705            'wl_namespace' => $target->getNamespace(),
706            'wl_title' => $target->getDBkey(),
707        ] );
708    }
709
710    private function getWatchedItemsWithRCInfoQueryDbOptions( array $options ) {
711        $dbOptions = [];
712
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 ];
716        }
717
718        if ( array_key_exists( 'limit', $options ) ) {
719            $dbOptions['LIMIT'] = (int)$options['limit'] + 1;
720        }
721        if ( $this->maxQueryExecutionTime ) {
722            $dbOptions['MAX_EXECUTION_TIME'] = $this->maxQueryExecutionTime;
723        }
724        return $dbOptions;
725    }
726
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'] );
731            }
732            $queryBuilder->orderBy( 'wl_title', $options['sort'] );
733        }
734        if ( array_key_exists( 'limit', $options ) ) {
735            $queryBuilder->limit( (int)$options['limit'] );
736        }
737        if ( $this->maxQueryExecutionTime ) {
738            $queryBuilder->setMaxExecutionTime( $this->maxQueryExecutionTime );
739        }
740    }
741
742    private function getWatchedItemsWithRCInfoQueryJoinConds( array $options ) {
743        $joinConds = [
744            'watchlist' => [ 'JOIN',
745                [
746                    'wl_namespace=rc_namespace',
747                    'wl_title=rc_title'
748                ]
749            ]
750        ];
751
752        if ( $this->expiryEnabled ) {
753            $joinConds['watchlist_expiry'] = [ 'LEFT JOIN', 'wl_id = we_item' ];
754        }
755
756        if ( !$options['allRevisions'] ) {
757            $joinConds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ];
758        }
759        if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) {
760            $joinConds += $this->commentStore->getJoin( 'rc_comment' )['joins'];
761        }
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 )
767        ) {
768            $joinConds['watchlist_actor'] = [ 'JOIN', 'actor_id=rc_actor' ];
769        }
770        return $joinConds;
771    }
772
773}
774/** @deprecated class alias since 1.43 */
775class_alias( WatchedItemQueryService::class, 'WatchedItemQueryService' );