Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
97.53% |
237 / 243 |
|
73.68% |
14 / 19 |
CRAP | |
0.00% |
0 / 1 |
FilterLookup | |
97.53% |
237 / 243 |
|
73.68% |
14 / 19 |
59 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getFilter | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
5.01 | |||
getAllActiveFiltersInGroup | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
6 | |||
getAllActiveFiltersInGroupFromDB | |
91.30% |
21 / 23 |
|
0.00% |
0 / 1 |
7.03 | |||
getDBConnection | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getActionsFromDB | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
3.01 | |||
getFilterVersion | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
5.01 | |||
getLastHistoryVersion | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
3 | |||
getClosestVersion | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
5 | |||
getFirstFilterVersionID | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
3 | |||
clearLocalCache | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
purgeGroupWANCache | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getGlobalRulesKey | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
getPrivacyLevelFromFlags | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
3.03 | |||
filterFromHistoryRow | |
100.00% |
26 / 26 |
|
100.00% |
1 / 1 |
3 | |||
filterFromRow | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
4 | |||
getAbuseFilterQueryBuilder | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
1 | |||
getAbuseFilterHistoryQueryBuilder | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
1 | |||
getCacheKey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
hideLocalFiltersForTesting | n/a |
0 / 0 |
n/a |
0 / 0 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\AbuseFilter; |
4 | |
5 | use MediaWiki\Extension\AbuseFilter\Filter\ClosestFilterVersionNotFoundException; |
6 | use MediaWiki\Extension\AbuseFilter\Filter\ExistingFilter; |
7 | use MediaWiki\Extension\AbuseFilter\Filter\FilterNotFoundException; |
8 | use MediaWiki\Extension\AbuseFilter\Filter\FilterVersionNotFoundException; |
9 | use MediaWiki\Extension\AbuseFilter\Filter\Flags; |
10 | use MediaWiki\Extension\AbuseFilter\Filter\HistoryFilter; |
11 | use MediaWiki\Extension\AbuseFilter\Filter\LastEditInfo; |
12 | use MediaWiki\Extension\AbuseFilter\Filter\Specs; |
13 | use RuntimeException; |
14 | use stdClass; |
15 | use Wikimedia\ObjectCache\WANObjectCache; |
16 | use Wikimedia\Rdbms\IDBAccessObject; |
17 | use Wikimedia\Rdbms\ILoadBalancer; |
18 | use Wikimedia\Rdbms\IReadableDatabase; |
19 | use Wikimedia\Rdbms\SelectQueryBuilder; |
20 | |
21 | /** |
22 | * This class provides read access to the filters stored in the database. |
23 | * |
24 | * @todo Cache exceptions |
25 | */ |
26 | class FilterLookup implements IDBAccessObject { |
27 | public const SERVICE_NAME = 'AbuseFilterFilterLookup'; |
28 | |
29 | // Used in getClosestVersion |
30 | public const DIR_PREV = 'prev'; |
31 | public const DIR_NEXT = 'next'; |
32 | |
33 | /** |
34 | * @var ExistingFilter[] Individual filters cache. Keys can be integer IDs, or global names |
35 | */ |
36 | private $cache = []; |
37 | |
38 | /** |
39 | * @var ExistingFilter[][][] Cache of all active filters in each group. This is not related to |
40 | * the individual cache, and is replicated in WAN cache. The structure is |
41 | * [ local|global => [ group => [ ID => filter ] ] ] |
42 | * where the cache for each group has the same format as $this->cache |
43 | * Note that the keys are also in the form 'global-ID' for filters in 'global', although redundant. |
44 | */ |
45 | private $groupCache = [ 'local' => [], 'global' => [] ]; |
46 | |
47 | /** @var HistoryFilter[] */ |
48 | private $historyCache = []; |
49 | |
50 | /** @var int[] */ |
51 | private $firstVersionCache = []; |
52 | |
53 | /** @var int[] */ |
54 | private $lastVersionCache = []; |
55 | |
56 | /** |
57 | * @var int[][] [ filter => [ historyID => [ prev, next ] ] ] |
58 | * @phan-var array<int,array<int,array{prev?:int,next?:int}>> |
59 | */ |
60 | private $closestVersionsCache = []; |
61 | |
62 | /** @var ILoadBalancer */ |
63 | private $loadBalancer; |
64 | |
65 | /** @var WANObjectCache */ |
66 | private $wanCache; |
67 | |
68 | /** @var CentralDBManager */ |
69 | private $centralDBManager; |
70 | |
71 | /** |
72 | * @var bool Flag used in PHPUnit tests to "hide" local filters when testing global ones, so that we can use the |
73 | * local database pretending it's not local. |
74 | */ |
75 | private bool $localFiltersHiddenForTest = false; |
76 | |
77 | /** |
78 | * @param ILoadBalancer $loadBalancer |
79 | * @param WANObjectCache $cache |
80 | * @param CentralDBManager $centralDBManager |
81 | */ |
82 | public function __construct( |
83 | ILoadBalancer $loadBalancer, |
84 | WANObjectCache $cache, |
85 | CentralDBManager $centralDBManager |
86 | ) { |
87 | $this->loadBalancer = $loadBalancer; |
88 | $this->wanCache = $cache; |
89 | $this->centralDBManager = $centralDBManager; |
90 | } |
91 | |
92 | /** |
93 | * @param int $filterID |
94 | * @param bool $global |
95 | * @param int $flags One of the IDBAccessObject::READ_* constants |
96 | * @return ExistingFilter |
97 | * @throws FilterNotFoundException if the filter doesn't exist |
98 | * @throws CentralDBNotAvailableException |
99 | */ |
100 | public function getFilter( |
101 | int $filterID, bool $global, int $flags = IDBAccessObject::READ_NORMAL |
102 | ): ExistingFilter { |
103 | $cacheKey = $this->getCacheKey( $filterID, $global ); |
104 | if ( $flags !== IDBAccessObject::READ_NORMAL || !isset( $this->cache[$cacheKey] ) ) { |
105 | $dbr = ( $flags & IDBAccessObject::READ_LATEST ) |
106 | ? $this->getDBConnection( DB_PRIMARY, $global ) |
107 | : $this->getDBConnection( DB_REPLICA, $global ); |
108 | $row = $this->getAbuseFilterQueryBuilder( $dbr ) |
109 | ->where( [ 'af_id' => $filterID ] ) |
110 | ->recency( $flags ) |
111 | ->caller( __METHOD__ )->fetchRow(); |
112 | |
113 | if ( !$row ) { |
114 | throw new FilterNotFoundException( $filterID, $global ); |
115 | } |
116 | $fname = __METHOD__; |
117 | $getActionsCB = function () use ( $dbr, $fname, $row ): array { |
118 | return $this->getActionsFromDB( $dbr, $fname, $row->af_id ); |
119 | }; |
120 | $this->cache[$cacheKey] = $this->filterFromRow( $row, $getActionsCB ); |
121 | } |
122 | |
123 | return $this->cache[$cacheKey]; |
124 | } |
125 | |
126 | /** |
127 | * Get all filters that are active (and not deleted) and in the given group |
128 | * @param string $group |
129 | * @param bool $global |
130 | * @param int $flags |
131 | * @return ExistingFilter[] |
132 | * @throws CentralDBNotAvailableException |
133 | */ |
134 | public function getAllActiveFiltersInGroup( |
135 | string $group, bool $global, int $flags = IDBAccessObject::READ_NORMAL |
136 | ): array { |
137 | $domainKey = $global ? 'global' : 'local'; |
138 | if ( $flags !== IDBAccessObject::READ_NORMAL || !isset( $this->groupCache[$domainKey][$group] ) ) { |
139 | if ( $global ) { |
140 | $globalRulesKey = $this->getGlobalRulesKey( $group ); |
141 | $ret = $this->wanCache->getWithSetCallback( |
142 | $globalRulesKey, |
143 | WANObjectCache::TTL_WEEK, |
144 | function () use ( $group, $global, $flags ) { |
145 | return $this->getAllActiveFiltersInGroupFromDB( $group, $global, $flags ); |
146 | }, |
147 | [ |
148 | 'checkKeys' => [ $globalRulesKey ], |
149 | 'lockTSE' => 300, |
150 | 'version' => 3 |
151 | ] |
152 | ); |
153 | } else { |
154 | $ret = $this->getAllActiveFiltersInGroupFromDB( $group, $global, $flags ); |
155 | } |
156 | |
157 | $this->groupCache[$domainKey][$group] = []; |
158 | foreach ( $ret as $key => $filter ) { |
159 | $this->groupCache[$domainKey][$group][$key] = $filter; |
160 | $this->cache[$key] = $filter; |
161 | } |
162 | } |
163 | return $this->groupCache[$domainKey][$group]; |
164 | } |
165 | |
166 | /** |
167 | * @param string $group |
168 | * @param bool $global |
169 | * @param int $flags |
170 | * @return ExistingFilter[] |
171 | */ |
172 | private function getAllActiveFiltersInGroupFromDB( string $group, bool $global, int $flags ): array { |
173 | if ( $this->localFiltersHiddenForTest && !$global ) { |
174 | return []; |
175 | } |
176 | $dbr = ( $flags & IDBAccessObject::READ_LATEST ) |
177 | ? $this->getDBConnection( DB_PRIMARY, $global ) |
178 | : $this->getDBConnection( DB_REPLICA, $global ); |
179 | $queryBuilder = $this->getAbuseFilterQueryBuilder( $dbr ) |
180 | ->where( [ 'af_enabled' => 1, 'af_deleted' => 0, 'af_group' => $group ] ) |
181 | ->recency( $flags ); |
182 | |
183 | if ( $global ) { |
184 | $queryBuilder->andWhere( [ 'af_global' => 1 ] ); |
185 | } |
186 | |
187 | // Note, excluding individually cached filter now wouldn't help much, so take it as |
188 | // an occasion to refresh the cache later |
189 | $rows = $queryBuilder->caller( __METHOD__ )->fetchResultSet(); |
190 | |
191 | $fname = __METHOD__; |
192 | $ret = []; |
193 | foreach ( $rows as $row ) { |
194 | $filterKey = $this->getCacheKey( $row->af_id, $global ); |
195 | $getActionsCB = function () use ( $dbr, $fname, $row ): array { |
196 | return $this->getActionsFromDB( $dbr, $fname, $row->af_id ); |
197 | }; |
198 | $ret[$filterKey] = $this->filterFromRow( |
199 | $row, |
200 | // Don't pass a closure if global, as this is going to be serialized when caching |
201 | $global ? $getActionsCB() : $getActionsCB |
202 | ); |
203 | } |
204 | return $ret; |
205 | } |
206 | |
207 | /** |
208 | * @param int $dbIndex |
209 | * @param bool $global |
210 | * @return IReadableDatabase |
211 | * @throws CentralDBNotAvailableException |
212 | */ |
213 | private function getDBConnection( int $dbIndex, bool $global ): IReadableDatabase { |
214 | if ( $global ) { |
215 | return $this->centralDBManager->getConnection( $dbIndex ); |
216 | } else { |
217 | return $this->loadBalancer->getConnection( $dbIndex ); |
218 | } |
219 | } |
220 | |
221 | /** |
222 | * @param IReadableDatabase $db |
223 | * @param string $fname |
224 | * @param int $id |
225 | * @return array |
226 | */ |
227 | private function getActionsFromDB( IReadableDatabase $db, string $fname, int $id ): array { |
228 | $res = $db->newSelectQueryBuilder() |
229 | ->select( [ 'afa_consequence', 'afa_parameters' ] ) |
230 | ->from( 'abuse_filter_action' ) |
231 | ->where( [ 'afa_filter' => $id ] ) |
232 | ->caller( $fname ) |
233 | ->fetchResultSet(); |
234 | |
235 | $actions = []; |
236 | foreach ( $res as $actionRow ) { |
237 | $actions[$actionRow->afa_consequence] = $actionRow->afa_parameters !== '' |
238 | ? explode( "\n", $actionRow->afa_parameters ) |
239 | : []; |
240 | } |
241 | return $actions; |
242 | } |
243 | |
244 | /** |
245 | * Get an old version of the given (local) filter, with its actions |
246 | * |
247 | * @param int $version Unique identifier of the version |
248 | * @param int $flags |
249 | * @return HistoryFilter |
250 | * @throws FilterVersionNotFoundException if the version doesn't exist |
251 | */ |
252 | public function getFilterVersion( |
253 | int $version, |
254 | int $flags = IDBAccessObject::READ_NORMAL |
255 | ): HistoryFilter { |
256 | if ( $flags !== IDBAccessObject::READ_NORMAL || !isset( $this->historyCache[$version] ) ) { |
257 | $dbr = ( $flags & IDBAccessObject::READ_LATEST ) |
258 | ? $this->loadBalancer->getConnection( DB_PRIMARY ) |
259 | : $this->loadBalancer->getConnection( DB_REPLICA ); |
260 | $row = $this->getAbuseFilterHistoryQueryBuilder( $dbr ) |
261 | ->where( [ 'afh_id' => $version ] ) |
262 | ->recency( $flags ) |
263 | ->caller( __METHOD__ )->fetchRow(); |
264 | if ( !$row ) { |
265 | throw new FilterVersionNotFoundException( $version ); |
266 | } |
267 | $this->historyCache[$version] = $this->filterFromHistoryRow( $row ); |
268 | } |
269 | |
270 | return $this->historyCache[$version]; |
271 | } |
272 | |
273 | /** |
274 | * @param int $filterID |
275 | * @return HistoryFilter |
276 | * @throws FilterNotFoundException If the filter doesn't exist |
277 | */ |
278 | public function getLastHistoryVersion( int $filterID ): HistoryFilter { |
279 | if ( !isset( $this->lastVersionCache[$filterID] ) ) { |
280 | $dbr = $this->loadBalancer->getConnection( DB_REPLICA ); |
281 | $row = $this->getAbuseFilterHistoryQueryBuilder( $dbr ) |
282 | ->where( [ 'afh_filter' => $filterID ] ) |
283 | ->orderBy( 'afh_id', SelectQueryBuilder::SORT_DESC ) |
284 | ->caller( __METHOD__ )->fetchRow(); |
285 | if ( !$row ) { |
286 | throw new FilterNotFoundException( $filterID, false ); |
287 | } |
288 | $filterObj = $this->filterFromHistoryRow( $row ); |
289 | $this->lastVersionCache[$filterID] = $filterObj->getHistoryID(); |
290 | $this->historyCache[$filterObj->getHistoryID()] = $filterObj; |
291 | } |
292 | return $this->historyCache[ $this->lastVersionCache[$filterID] ]; |
293 | } |
294 | |
295 | /** |
296 | * @param int $historyID |
297 | * @param int $filterID |
298 | * @param string $direction self::DIR_PREV or self::DIR_NEXT |
299 | * @return HistoryFilter |
300 | * @throws ClosestFilterVersionNotFoundException |
301 | */ |
302 | public function getClosestVersion( int $historyID, int $filterID, string $direction ): HistoryFilter { |
303 | if ( !isset( $this->closestVersionsCache[$filterID][$historyID][$direction] ) ) { |
304 | $comparison = $direction === self::DIR_PREV ? '<' : '>'; |
305 | $order = $direction === self::DIR_PREV ? 'DESC' : 'ASC'; |
306 | $dbr = $this->loadBalancer->getConnection( DB_REPLICA ); |
307 | $row = $this->getAbuseFilterHistoryQueryBuilder( $dbr ) |
308 | ->where( [ 'afh_filter' => $filterID ] ) |
309 | ->andWhere( $dbr->expr( 'afh_id', $comparison, $historyID ) ) |
310 | ->orderBy( 'afh_timestamp', $order ) |
311 | ->caller( __METHOD__ )->fetchRow(); |
312 | if ( !$row ) { |
313 | throw new ClosestFilterVersionNotFoundException( $filterID, $historyID ); |
314 | } |
315 | $filterObj = $this->filterFromHistoryRow( $row ); |
316 | $this->closestVersionsCache[$filterID][$historyID][$direction] = $filterObj->getHistoryID(); |
317 | $this->historyCache[$filterObj->getHistoryID()] = $filterObj; |
318 | } |
319 | $histID = $this->closestVersionsCache[$filterID][$historyID][$direction]; |
320 | return $this->historyCache[$histID]; |
321 | } |
322 | |
323 | /** |
324 | * Get the history ID of the first change to a given filter |
325 | * |
326 | * @param int $filterID |
327 | * @return int |
328 | * @throws FilterNotFoundException |
329 | */ |
330 | public function getFirstFilterVersionID( int $filterID ): int { |
331 | if ( !isset( $this->firstVersionCache[$filterID] ) ) { |
332 | $dbr = $this->loadBalancer->getConnection( DB_REPLICA ); |
333 | $historyID = $dbr->newSelectQueryBuilder() |
334 | ->select( 'MIN(afh_id)' ) |
335 | ->from( 'abuse_filter_history' ) |
336 | ->where( [ 'afh_filter' => $filterID ] ) |
337 | ->caller( __METHOD__ ) |
338 | ->fetchField(); |
339 | if ( $historyID === false ) { |
340 | throw new FilterNotFoundException( $filterID, false ); |
341 | } |
342 | $this->firstVersionCache[$filterID] = (int)$historyID; |
343 | } |
344 | |
345 | return $this->firstVersionCache[$filterID]; |
346 | } |
347 | |
348 | /** |
349 | * Resets the internal cache of Filter objects |
350 | */ |
351 | public function clearLocalCache(): void { |
352 | $this->cache = []; |
353 | $this->groupCache = [ 'local' => [], 'global' => [] ]; |
354 | $this->historyCache = []; |
355 | $this->firstVersionCache = []; |
356 | $this->lastVersionCache = []; |
357 | $this->closestVersionsCache = []; |
358 | } |
359 | |
360 | /** |
361 | * Purge the shared cache of global filters in the given group. |
362 | * @note This doesn't purge the local cache |
363 | * @param string $group |
364 | */ |
365 | public function purgeGroupWANCache( string $group ): void { |
366 | $this->wanCache->touchCheckKey( $this->getGlobalRulesKey( $group ) ); |
367 | } |
368 | |
369 | /** |
370 | * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) |
371 | * @return string |
372 | */ |
373 | private function getGlobalRulesKey( string $group ): string { |
374 | if ( !$this->centralDBManager->filterIsCentral() ) { |
375 | return $this->wanCache->makeGlobalKey( |
376 | 'abusefilter', |
377 | 'rules', |
378 | $this->centralDBManager->getCentralDBName(), |
379 | $group |
380 | ); |
381 | } |
382 | |
383 | return $this->wanCache->makeKey( 'abusefilter', 'rules', $group ); |
384 | } |
385 | |
386 | /** |
387 | * @param array $flags |
388 | * @return int |
389 | */ |
390 | private function getPrivacyLevelFromFlags( $flags ): int { |
391 | $hidden = in_array( 'hidden', $flags, true ) ? |
392 | Flags::FILTER_HIDDEN : |
393 | 0; |
394 | $protected = in_array( 'protected', $flags, true ) ? |
395 | Flags::FILTER_USES_PROTECTED_VARS : |
396 | 0; |
397 | return $hidden | $protected; |
398 | } |
399 | |
400 | /** |
401 | * Note: this is private because no external caller should access DB rows directly. |
402 | * @param stdClass $row |
403 | * @return HistoryFilter |
404 | */ |
405 | private function filterFromHistoryRow( stdClass $row ): HistoryFilter { |
406 | $actionsRaw = unserialize( $row->afh_actions ); |
407 | $actions = is_array( $actionsRaw ) ? $actionsRaw : []; |
408 | $flags = $row->afh_flags ? explode( ',', $row->afh_flags ) : []; |
409 | return new HistoryFilter( |
410 | new Specs( |
411 | trim( $row->afh_pattern ), |
412 | $row->afh_comments, |
413 | // FIXME: Make the DB field NOT NULL (T263324) |
414 | (string)$row->afh_public_comments, |
415 | array_keys( $actions ), |
416 | // FIXME Make the field NOT NULL and add default (T263324) |
417 | $row->afh_group ?? 'default' |
418 | ), |
419 | new Flags( |
420 | in_array( 'enabled', $flags, true ), |
421 | in_array( 'deleted', $flags, true ), |
422 | $this->getPrivacyLevelFromFlags( $flags ), |
423 | in_array( 'global', $flags, true ) |
424 | ), |
425 | $actions, |
426 | new LastEditInfo( |
427 | (int)$row->afh_user, |
428 | $row->afh_user_text, |
429 | $row->afh_timestamp |
430 | ), |
431 | (int)$row->afh_filter, |
432 | $row->afh_id |
433 | ); |
434 | } |
435 | |
436 | /** |
437 | * Note: this is private because no external caller should access DB rows directly. |
438 | * @param stdClass $row |
439 | * @param array[]|callable $actions |
440 | * @return ExistingFilter |
441 | */ |
442 | private function filterFromRow( stdClass $row, $actions ): ExistingFilter { |
443 | return new ExistingFilter( |
444 | new Specs( |
445 | trim( $row->af_pattern ), |
446 | // FIXME: Make the DB fields for these NOT NULL (T263324) |
447 | (string)$row->af_comments, |
448 | (string)$row->af_public_comments, |
449 | $row->af_actions !== '' ? explode( ',', $row->af_actions ) : [], |
450 | $row->af_group |
451 | ), |
452 | new Flags( |
453 | (bool)$row->af_enabled, |
454 | (bool)$row->af_deleted, |
455 | (int)$row->af_hidden, |
456 | (bool)$row->af_global |
457 | ), |
458 | $actions, |
459 | new LastEditInfo( |
460 | (int)$row->af_user, |
461 | $row->af_user_text, |
462 | $row->af_timestamp |
463 | ), |
464 | (int)$row->af_id, |
465 | isset( $row->af_hit_count ) ? (int)$row->af_hit_count : null, |
466 | isset( $row->af_throttled ) ? (bool)$row->af_throttled : null |
467 | ); |
468 | } |
469 | |
470 | private function getAbuseFilterQueryBuilder( IReadableDatabase $dbr ): SelectQueryBuilder { |
471 | return $dbr->newSelectQueryBuilder() |
472 | ->select( [ |
473 | 'af_id', |
474 | 'af_pattern', |
475 | 'af_timestamp', |
476 | 'af_enabled', |
477 | 'af_comments', |
478 | 'af_public_comments', |
479 | 'af_hidden', |
480 | 'af_hit_count', |
481 | 'af_throttled', |
482 | 'af_deleted', |
483 | 'af_actions', |
484 | 'af_global', |
485 | 'af_group', |
486 | 'af_user' => 'actor_af_user.actor_user', |
487 | 'af_user_text' => 'actor_af_user.actor_name', |
488 | 'af_actor' => 'af_actor' |
489 | ] ) |
490 | ->from( 'abuse_filter' ) |
491 | ->join( 'actor', 'actor_af_user', 'actor_af_user.actor_id = af_actor' ); |
492 | } |
493 | |
494 | private function getAbuseFilterHistoryQueryBuilder( IReadableDatabase $dbr ): SelectQueryBuilder { |
495 | return $dbr->newSelectQueryBuilder() |
496 | ->select( [ |
497 | 'afh_id', |
498 | 'afh_pattern', |
499 | 'afh_timestamp', |
500 | 'afh_filter', |
501 | 'afh_comments', |
502 | 'afh_public_comments', |
503 | 'afh_flags', |
504 | 'afh_actions', |
505 | 'afh_group', |
506 | 'afh_user' => 'actor_afh_user.actor_user', |
507 | 'afh_user_text' => 'actor_afh_user.actor_name', |
508 | 'afh_actor' => 'afh_actor' |
509 | ] ) |
510 | ->from( 'abuse_filter_history' ) |
511 | ->join( 'actor', 'actor_afh_user', 'actor_afh_user.actor_id = afh_actor' ); |
512 | } |
513 | |
514 | /** |
515 | * @param int $filterID |
516 | * @param bool $global |
517 | * @return string |
518 | */ |
519 | private function getCacheKey( int $filterID, bool $global ): string { |
520 | return GlobalNameUtils::buildGlobalName( $filterID, $global ); |
521 | } |
522 | |
523 | /** |
524 | * "Hides" local filters when testing global ones, so that we can use the |
525 | * local database pretending it's not local. |
526 | * @codeCoverageIgnore |
527 | */ |
528 | public function hideLocalFiltersForTesting(): void { |
529 | if ( !defined( 'MW_PHPUNIT_TEST' ) ) { |
530 | throw new RuntimeException( 'Can only be called in tests' ); |
531 | } |
532 | $this->localFiltersHiddenForTest = true; |
533 | } |
534 | } |