Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
98.15% |
372 / 379 |
|
73.68% |
14 / 19 |
CRAP | |
0.00% |
0 / 1 |
WatchedItemQueryService | |
98.41% |
372 / 378 |
|
73.68% |
14 / 19 |
131 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
getExtensions | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
getWatchedItemsWithRecentChangeInfo | |
100.00% |
92 / 92 |
|
100.00% |
1 / 1 |
14 | |||
getWatchedItemsForUser | |
100.00% |
44 / 44 |
|
100.00% |
1 / 1 |
9 | |||
getRecentChangeFieldsFromRow | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
getWatchedItemsWithRCInfoQueryTables | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
10 | |||
getWatchedItemsWithRCInfoQueryFields | |
97.37% |
37 / 38 |
|
0.00% |
0 / 1 |
12 | |||
getWatchedItemsWithRCInfoQueryConds | |
96.00% |
24 / 25 |
|
0.00% |
0 / 1 |
9 | |||
getWatchlistOwnerId | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
getWatchedItemsWithRCInfoQueryFilterConds | |
94.12% |
32 / 34 |
|
0.00% |
0 / 1 |
17.06 | |||
getStartEndConds | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
7 | |||
getUserRelatedConds | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
8 | |||
getExtraDeletedPageLogEntryRelatedCond | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
4 | |||
getStartFromConds | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
addQueryCondsForWatchedItemsForUser | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
10 | |||
getFromUntilTargetConds | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
getWatchedItemsWithRCInfoQueryDbOptions | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
5.03 | |||
addQueryDbOptionsForWatchedItemsForUser | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
5.05 | |||
getWatchedItemsWithRCInfoQueryJoinConds | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
10 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Watchlist; |
4 | |
5 | use ApiUsageException; |
6 | use ChangeTags; |
7 | use LogPage; |
8 | use MediaWiki\CommentStore\CommentStore; |
9 | use MediaWiki\HookContainer\HookContainer; |
10 | use MediaWiki\HookContainer\HookRunner; |
11 | use MediaWiki\Linker\LinkTarget; |
12 | use MediaWiki\Permissions\Authority; |
13 | use MediaWiki\Revision\RevisionRecord; |
14 | use MediaWiki\Title\TitleValue; |
15 | use MediaWiki\User\Options\UserOptionsLookup; |
16 | use MediaWiki\User\TempUser\TempUserConfig; |
17 | use MediaWiki\User\User; |
18 | use MediaWiki\User\UserIdentity; |
19 | use RecentChange; |
20 | use Wikimedia\Assert\Assert; |
21 | use Wikimedia\Rdbms\IConnectionProvider; |
22 | use Wikimedia\Rdbms\IExpression; |
23 | use Wikimedia\Rdbms\IReadableDatabase; |
24 | use 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 | */ |
36 | class 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 */ |
775 | class_alias( WatchedItemQueryService::class, 'WatchedItemQueryService' ); |