Completed
Branch master (a553dc)
by
unknown
26:33
created

getWatchedItemsWithRecentChangeInfo()   C

Complexity

Conditions 11
Paths 8

Size

Total Lines 82
Code Lines 60

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 11
eloc 60
c 1
b 0
f 0
nc 8
nop 2
dl 0
loc 82
rs 5.2653

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
use Wikimedia\Assert\Assert;
4
5
/**
6
 * Class performing complex database queries related to WatchedItems.
7
 *
8
 * @since 1.28
9
 *
10
 * @file
11
 * @ingroup Watchlist
12
 *
13
 * @license GNU GPL v2+
14
 */
15
class WatchedItemQueryService {
16
17
	const DIR_OLDER = 'older';
18
	const DIR_NEWER = 'newer';
19
20
	const INCLUDE_FLAGS = 'flags';
21
	const INCLUDE_USER = 'user';
22
	const INCLUDE_USER_ID = 'userid';
23
	const INCLUDE_COMMENT = 'comment';
24
	const INCLUDE_PATROL_INFO = 'patrol';
25
	const INCLUDE_SIZES = 'sizes';
26
	const INCLUDE_LOG_INFO = 'loginfo';
27
28
	// FILTER_* constants are part of public API (are used
29
	// in ApiQueryWatchlist class) and should not be changed.
30
	// Changing values of those constants will result in a breaking change in the API
31
	const FILTER_MINOR = 'minor';
32
	const FILTER_NOT_MINOR = '!minor';
33
	const FILTER_BOT = 'bot';
34
	const FILTER_NOT_BOT = '!bot';
35
	const FILTER_ANON = 'anon';
36
	const FILTER_NOT_ANON = '!anon';
37
	const FILTER_PATROLLED = 'patrolled';
38
	const FILTER_NOT_PATROLLED = '!patrolled';
39
	const FILTER_UNREAD = 'unread';
40
	const FILTER_NOT_UNREAD = '!unread';
41
42
	/**
43
	 * @var LoadBalancer
44
	 */
45
	private $loadBalancer;
46
47
	public function __construct( LoadBalancer $loadBalancer ) {
48
		$this->loadBalancer = $loadBalancer;
49
	}
50
51
	/**
52
	 * @return DatabaseBase
53
	 * @throws MWException
54
	 */
55
	private function getConnection() {
56
		return $this->loadBalancer->getConnection( DB_SLAVE, [ 'watchlist' ] );
57
	}
58
59
	/**
60
	 * @param DatabaseBase $connection
61
	 * @throws MWException
62
	 */
63
	private function reuseConnection( DatabaseBase $connection ) {
64
		$this->loadBalancer->reuseConnection( $connection );
65
	}
66
67
	/**
68
	 * @param User $user
69
	 * @param array $options Allowed keys:
70
	 *        'includeFields'       => string[] RecentChange fields to be included in the result,
71
	 *                                 self::INCLUDE_* constants should be used
72
	 *        'filters'             => string[] optional filters to narrow down resulted items
73
	 *        'namespaceIds'        => int[] optional namespace IDs to filter by
74
	 *                                 (defaults to all namespaces)
75
	 *        'allRevisions'        => bool return multiple revisions of the same page if true,
76
	 *                                 only the most recent if false (default)
77
	 *        'rcTypes'             => int[] which types of RecentChanges to include
78
	 *                                 (defaults to all types), allowed values: RC_EDIT, RC_NEW,
79
	 *                                 RC_LOG, RC_EXTERNAL, RC_CATEGORIZE
80
	 *        'onlyByUser'          => string only list changes by a specified user
81
	 *        'notByUser'           => string do not incluide changes by a specified user
82
	 *        'dir'                 => string in which direction to enumerate, accepted values:
83
	 *                                 - DIR_OLDER list newest first
84
	 *                                 - DIR_NEWER list oldest first
85
	 *        'start'               => string (format accepted by wfTimestamp) requires 'dir' option,
86
	 *                                 timestamp to start enumerating from
87
	 *        'end'                 => string (format accepted by wfTimestamp) requires 'dir' option,
88
	 *                                 timestamp to end enumerating
89
	 *        'startFrom'           => [ string $rcTimestamp, int $rcId ] requires 'dir' option,
90
	 *                                 return items starting from the RecentChange specified by this,
91
	 *                                 $rcTimestamp should be in the format accepted by wfTimestamp
92
	 *        'watchlistOwner'      => User user whose watchlist items should be listed if different
93
	 *                                 than the one specified with $user param,
94
	 *                                 requires 'watchlistOwnerToken' option
95
	 *        'watchlistOwnerToken' => string a watchlist token used to access another user's
96
	 *                                 watchlist, used with 'watchlistOwnerToken' option
97
	 *        'limit'               => int maximum numbers of items to return
98
	 *        'usedInGenerator'     => bool include only RecentChange id field required by the
99
	 *                                 generator ('rc_cur_id' or 'rc_this_oldid') if true, or all
100
	 *                                 id fields ('rc_cur_id', 'rc_this_oldid', 'rc_last_oldid')
101
	 *                                 if false (default)
102
	 * @return array of pairs ( WatchedItem $watchedItem, string[] $recentChangeInfo ),
103
	 *         where $recentChangeInfo contains the following keys:
104
	 *         - 'rc_id',
105
	 *         - 'rc_namespace',
106
	 *         - 'rc_title',
107
	 *         - 'rc_timestamp',
108
	 *         - 'rc_type',
109
	 *         - 'rc_deleted',
110
	 *         Additional keys could be added by specifying the 'includeFields' option
111
	 */
112
	public function getWatchedItemsWithRecentChangeInfo( User $user, array $options = [] ) {
113
		$options += [
114
			'includeFields' => [],
115
			'namespaceIds' => [],
116
			'filters' => [],
117
			'allRevisions' => false,
118
			'usedInGenerator' => false
119
		];
120
121
		Assert::parameter(
122
			!isset( $options['rcTypes'] )
123
				|| !array_diff( $options['rcTypes'], [ RC_EDIT, RC_NEW, RC_LOG, RC_EXTERNAL, RC_CATEGORIZE ] ),
124
			'$options[\'rcTypes\']',
125
			'must be an array containing only: RC_EDIT, RC_NEW, RC_LOG, RC_EXTERNAL and/or RC_CATEGORIZE'
126
		);
127
		Assert::parameter(
128
			!isset( $options['dir'] ) || in_array( $options['dir'], [ self::DIR_OLDER, self::DIR_NEWER ] ),
129
			'$options[\'dir\']',
130
			'must be DIR_OLDER or DIR_NEWER'
131
		);
132
		Assert::parameter(
133
			!isset( $options['start'] ) && !isset( $options['end'] ) && !isset( $options['startFrom'] )
134
				|| isset( $options['dir'] ),
135
			'$options[\'dir\']',
136
			'must be provided when providing any of options: start, end, startFrom'
137
		);
138
		Assert::parameter(
139
			!isset( $options['startFrom'] )
140
				|| ( is_array( $options['startFrom'] ) && count( $options['startFrom'] ) === 2 ),
141
			'$options[\'startFrom\']',
142
			'must be a two-element array'
143
		);
144
		if ( array_key_exists( 'watchlistOwner', $options ) ) {
145
			Assert::parameterType(
146
				User::class,
147
				$options['watchlistOwner'],
148
				'$options[\'watchlistOwner\']'
149
			);
150
			Assert::parameter(
151
				isset( $options['watchlistOwnerToken'] ),
152
				'$options[\'watchlistOwnerToken\']',
153
				'must be provided when providing watchlistOwner option'
154
			);
155
		}
156
157
		$tables = [ 'recentchanges', 'watchlist' ];
158
		if ( !$options['allRevisions'] ) {
159
			$tables[] = 'page';
160
		}
161
162
		$db = $this->getConnection();
163
164
		$fields = $this->getFields( $options );
165
		$conds = $this->getConds( $db, $user, $options );
166
		$dbOptions = $this->getDbOptions( $options );
167
		$joinConds = $this->getJoinConds( $options );
168
169
		$res = $db->select(
170
			$tables,
171
			$fields,
172
			$conds,
173
			__METHOD__,
174
			$dbOptions,
175
			$joinConds
176
		);
177
178
		$this->reuseConnection( $db );
179
180
		$items = [];
181
		foreach ( $res as $row ) {
0 ignored issues
show
Bug introduced by
The expression $res of type object<ResultWrapper>|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
182
			$items[] = [
183
				new WatchedItem(
184
					$user,
185
					new TitleValue( (int)$row->rc_namespace, $row->rc_title ),
186
					$row->wl_notificationtimestamp
187
				),
188
				$this->getRecentChangeFieldsFromRow( $row )
189
			];
190
		}
191
192
		return $items;
193
	}
194
195
	private function getRecentChangeFieldsFromRow( stdClass $row ) {
196
		// This can be simplified to single array_filter call filtering by key value,
197
		// once we stop supporting PHP 5.5
198
		$allFields = get_object_vars( $row );
199
		$rcKeys = array_filter(
200
			array_keys( $allFields ),
201
			function( $key ) {
202
				return substr( $key, 0, 3 ) === 'rc_';
203
			}
204
		);
205
		return array_intersect_key( $allFields, array_flip( $rcKeys ) );
206
	}
207
208
	private function getFields( array $options ) {
209
		$fields = [
210
			'rc_id',
211
			'rc_namespace',
212
			'rc_title',
213
			'rc_timestamp',
214
			'rc_type',
215
			'rc_deleted',
216
			'wl_notificationtimestamp'
217
		];
218
219
		$rcIdFields = [
220
			'rc_cur_id',
221
			'rc_this_oldid',
222
			'rc_last_oldid',
223
		];
224
		if ( $options['usedInGenerator'] ) {
225
			if ( $options['allRevisions'] ) {
226
				$rcIdFields = [ 'rc_this_oldid' ];
227
			} else {
228
				$rcIdFields = [ 'rc_cur_id' ];
229
			}
230
		}
231
		$fields = array_merge( $fields, $rcIdFields );
232
233
		if ( in_array( self::INCLUDE_FLAGS, $options['includeFields'] ) ) {
234
			$fields = array_merge( $fields, [ 'rc_type', 'rc_minor', 'rc_bot' ] );
235
		}
236
		if ( in_array( self::INCLUDE_USER, $options['includeFields'] ) ) {
237
			$fields[] = 'rc_user_text';
238
		}
239
		if ( in_array( self::INCLUDE_USER_ID, $options['includeFields'] ) ) {
240
			$fields[] = 'rc_user';
241
		}
242
		if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) {
243
			$fields[] = 'rc_comment';
244
		}
245
		if ( in_array( self::INCLUDE_PATROL_INFO, $options['includeFields'] ) ) {
246
			$fields = array_merge( $fields, [ 'rc_patrolled', 'rc_log_type' ] );
247
		}
248
		if ( in_array( self::INCLUDE_SIZES, $options['includeFields'] ) ) {
249
			$fields = array_merge( $fields, [ 'rc_old_len', 'rc_new_len' ] );
250
		}
251
		if ( in_array( self::INCLUDE_LOG_INFO, $options['includeFields'] ) ) {
252
			$fields = array_merge( $fields, [ 'rc_logid', 'rc_log_type', 'rc_log_action', 'rc_params' ] );
253
		}
254
255
		return $fields;
256
	}
257
258
	private function getConds( DatabaseBase $db, User $user, array $options ) {
259
		$watchlistOwnerId = $this->getWatchlistOwnerId( $user, $options );
260
		$conds = [ 'wl_user' => $watchlistOwnerId ];
261
262
		if ( !$options['allRevisions'] ) {
263
			$conds[] = $db->makeList(
264
				[ 'rc_this_oldid=page_latest', 'rc_type=' . RC_LOG ],
265
				LIST_OR
266
			);
267
		}
268
269
		if ( $options['namespaceIds'] ) {
270
			$conds['wl_namespace'] = array_map( 'intval', $options['namespaceIds'] );
271
		}
272
273
		if ( array_key_exists( 'rcTypes', $options ) ) {
274
			$conds['rc_type'] = array_map( 'intval',  $options['rcTypes'] );
275
		}
276
277
		$conds = array_merge( $conds, $this->getFilterConds( $user, $options ) );
278
279
		$conds = array_merge( $conds, $this->getStartEndConds( $db, $options ) );
280
281
		if ( !isset( $options['start'] ) && !isset( $options['end'] ) ) {
282
			if ( $db->getType() === 'mysql' ) {
283
				// This is an index optimization for mysql
284
				$conds[] = "rc_timestamp > ''";
285
			}
286
		}
287
288
		$conds = array_merge( $conds, $this->getUserRelatedConds( $db, $user, $options ) );
289
290
		$deletedPageLogCond = $this->getExtraDeletedPageLogEntryRelatedCond( $db, $user );
291
		if ( $deletedPageLogCond ) {
292
			$conds[] = $deletedPageLogCond;
293
		}
294
295
		if ( array_key_exists( 'startFrom', $options ) ) {
296
			$conds[] = $this->getStartFromConds( $db, $options );
297
		}
298
299
		return $conds;
300
	}
301
302
	private function getWatchlistOwnerId( User $user, array $options ) {
303
		if ( array_key_exists( 'watchlistOwner', $options ) ) {
304
			/** @var User $watchlistOwner */
305
			$watchlistOwner = $options['watchlistOwner'];
306
			$ownersToken = $watchlistOwner->getOption( 'watchlisttoken' );
307
			$token = $options['watchlistOwnerToken'];
308
			if ( $ownersToken == '' || !hash_equals( $ownersToken, $token ) ) {
309
				throw new UsageException(
310
					'Incorrect watchlist token provided -- please set a correct token in Special:Preferences',
311
					'bad_wltoken'
312
				);
313
			}
314
			return $watchlistOwner->getId();
315
		}
316
		return $user->getId();
317
	}
318
319
	private function getFilterConds( User $user, array $options ) {
320
		$conds = [];
321
322 View Code Duplication
		if ( in_array( self::FILTER_MINOR, $options['filters'] ) ) {
323
			$conds[] = 'rc_minor != 0';
324
		} elseif ( in_array( self::FILTER_NOT_MINOR, $options['filters'] ) ) {
325
			$conds[] = 'rc_minor = 0';
326
		}
327
328 View Code Duplication
		if ( in_array( self::FILTER_BOT, $options['filters'] ) ) {
329
			$conds[] = 'rc_bot != 0';
330
		} elseif ( in_array( self::FILTER_NOT_BOT, $options['filters'] ) ) {
331
			$conds[] = 'rc_bot = 0';
332
		}
333
334 View Code Duplication
		if ( in_array( self::FILTER_ANON, $options['filters'] ) ) {
335
			$conds[] = 'rc_user = 0';
336
		} elseif ( in_array( self::FILTER_NOT_ANON, $options['filters'] ) ) {
337
			$conds[] = 'rc_user != 0';
338
		}
339
340
		if ( $user->useRCPatrol() || $user->useNPPatrol() ) {
341
			// TODO: not sure if this should simply ignore patrolled filters if user does not have the patrol
342
			// right, or maybe rather fail loud at this point, same as e.g. ApiQueryWatchlist does?
343 View Code Duplication
			if ( in_array( self::FILTER_PATROLLED, $options['filters'] ) ) {
344
				$conds[] = 'rc_patrolled != 0';
345
			} elseif ( in_array( self::FILTER_NOT_PATROLLED, $options['filters'] ) ) {
346
				$conds[] = 'rc_patrolled = 0';
347
			}
348
		}
349
350 View Code Duplication
		if ( in_array( self::FILTER_UNREAD, $options['filters'] ) ) {
351
			$conds[] = 'rc_timestamp >= wl_notificationtimestamp';
352
		} elseif ( in_array( self::FILTER_NOT_UNREAD, $options['filters'] ) ) {
353
			// TODO: should this be changed to use Database::makeList?
354
			$conds[] = 'wl_notificationtimestamp IS NULL OR rc_timestamp < wl_notificationtimestamp';
355
		}
356
357
		return $conds;
358
	}
359
360
	private function getStartEndConds( DatabaseBase $db, array $options ) {
361
		if ( !isset( $options['start'] ) && ! isset( $options['end'] ) ) {
362
			return [];
363
		}
364
365
		$conds = [];
366
367 View Code Duplication
		if ( isset( $options['start'] ) ) {
368
			$after = $options['dir'] === self::DIR_OLDER ? '<=' : '>=';
369
			$conds[] = 'rc_timestamp ' . $after . ' ' . $db->addQuotes( $options['start'] );
370
		}
371 View Code Duplication
		if ( isset( $options['end'] ) ) {
372
			$before = $options['dir'] === self::DIR_OLDER ? '>=' : '<=';
373
			$conds[] = 'rc_timestamp ' . $before . ' ' . $db->addQuotes( $options['end'] );
374
		}
375
376
		return $conds;
377
	}
378
379
	private function getUserRelatedConds( DatabaseBase $db, User $user, array $options ) {
380
		if ( !array_key_exists( 'onlyByUser', $options ) && !array_key_exists( 'notByUser', $options ) ) {
381
			return [];
382
		}
383
384
		$conds = [];
385
386
		if ( array_key_exists( 'onlyByUser', $options ) ) {
387
			$conds['rc_user_text'] = $options['onlyByUser'];
388
		} elseif ( array_key_exists( 'notByUser', $options ) ) {
389
			$conds[] = 'rc_user_text != ' . $db->addQuotes( $options['notByUser'] );
390
		}
391
392
		// Avoid brute force searches (bug 17342)
393
		$bitmask = 0;
394
		if ( !$user->isAllowed( 'deletedhistory' ) ) {
395
			$bitmask = Revision::DELETED_USER;
396
		} elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
397
			$bitmask = Revision::DELETED_USER | Revision::DELETED_RESTRICTED;
398
		}
399
		if ( $bitmask ) {
400
			$conds[] = $db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask";
401
		}
402
403
		return $conds;
404
	}
405
406
	private function getExtraDeletedPageLogEntryRelatedCond( DatabaseBase $db, User $user ) {
407
		// LogPage::DELETED_ACTION hides the affected page, too. So hide those
408
		// entirely from the watchlist, or someone could guess the title.
409
		$bitmask = 0;
410
		if ( !$user->isAllowed( 'deletedhistory' ) ) {
411
			$bitmask = LogPage::DELETED_ACTION;
412
		} elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
413
			$bitmask = LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED;
414
		}
415 View Code Duplication
		if ( $bitmask ) {
416
			return $db->makeList( [
417
				'rc_type != ' . RC_LOG,
418
				$db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask",
419
			], LIST_OR );
420
		}
421
		return '';
422
	}
423
424
	private function getStartFromConds( DatabaseBase $db, array $options ) {
425
		$op = $options['dir'] === self::DIR_OLDER ? '<' : '>';
426
		list( $rcTimestamp, $rcId ) = $options['startFrom'];
427
		$rcTimestamp = $db->addQuotes( $db->timestamp( $rcTimestamp ) );
0 ignored issues
show
Security Bug introduced by
It seems like $db->timestamp($rcTimestamp) targeting DatabaseBase::timestamp() can also be of type false; however, DatabaseBase::addQuotes() does only seem to accept string|object<Blob>, did you maybe forget to handle an error condition?
Loading history...
428
		$rcId = (int)$rcId;
429
		return $db->makeList(
430
			[
431
				"rc_timestamp $op $rcTimestamp",
432
				$db->makeList(
433
					[
434
						"rc_timestamp = $rcTimestamp",
435
						"rc_id $op= $rcId"
436
					],
437
					LIST_AND
438
				)
439
			],
440
			LIST_OR
441
		);
442
	}
443
444
	private function getDbOptions( array $options ) {
445
		$dbOptions = [];
446
447
		if ( array_key_exists( 'dir', $options ) ) {
448
			$sort = $options['dir'] === self::DIR_OLDER ? ' DESC' : '';
449
			$dbOptions['ORDER BY'] = [ 'rc_timestamp' . $sort, 'rc_id' . $sort ];
450
		}
451
452
		if ( array_key_exists( 'limit', $options ) ) {
453
			$dbOptions['LIMIT'] = (int)$options['limit'];
454
		}
455
456
		return $dbOptions;
457
	}
458
459
	private function getJoinConds( array $options ) {
460
		$joinConds = [
461
			'watchlist' => [ 'INNER JOIN',
462
				[
463
					'wl_namespace=rc_namespace',
464
					'wl_title=rc_title'
465
				]
466
			]
467
		];
468
		if ( !$options['allRevisions'] ) {
469
			$joinConds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ];
470
		}
471
		return $joinConds;
472
	}
473
474
}
475