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