Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
89.60% |
112 / 125 |
|
50.00% |
5 / 10 |
CRAP | |
0.00% |
0 / 1 |
ApiHooksHandler | |
89.60% |
112 / 125 |
|
50.00% |
5 / 10 |
74.36 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
onAPIGetAllowedParams | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
10 | |||
onApiQueryBaseBeforeQuery | |
85.29% |
29 / 34 |
|
0.00% |
0 / 1 |
13.54 | |||
onApiQueryBaseAfterQuery | |
80.00% |
20 / 25 |
|
0.00% |
0 / 1 |
15.57 | |||
loadScoresForRevisions | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
onApiQueryBaseProcessRow | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
7.03 | |||
addScoresForAPI | |
94.44% |
17 / 18 |
|
0.00% |
0 / 1 |
10.02 | |||
onApiQueryWatchlistPrepareWatchedItemQueryServiceOptions | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
7.14 | |||
onApiQueryWatchlistExtractOutputData | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
onWatchedItemQueryServiceExtensions | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | /** |
3 | * Copyright (C) 2016 Brad Jorsch <bjorsch@wikimedia.org> |
4 | * |
5 | * This program is free software: you can redistribute it and/or modify |
6 | * it under the terms of the GNU General Public License as published by |
7 | * the Free Software Foundation, either version 3 of the License, or |
8 | * (at your option) any later version. |
9 | * |
10 | * This program is distributed in the hope that it will be useful, |
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
13 | * GNU General Public License for more details. |
14 | * |
15 | * You should have received a copy of the GNU General Public License |
16 | * along with this program. If not, see <http://www.gnu.org/licenses/>. |
17 | */ |
18 | |
19 | namespace ORES\Hooks\Api; |
20 | |
21 | use MediaWiki\Api\ApiBase; |
22 | use MediaWiki\Api\ApiQueryAllRevisions; |
23 | use MediaWiki\Api\ApiQueryBase; |
24 | use MediaWiki\Api\ApiQueryGeneratorBase; |
25 | use MediaWiki\Api\ApiQueryRecentChanges; |
26 | use MediaWiki\Api\ApiQueryRevisions; |
27 | use MediaWiki\Api\ApiQueryUserContribs; |
28 | use MediaWiki\Api\ApiQueryWatchlist; |
29 | use MediaWiki\Api\ApiResult; |
30 | use MediaWiki\Api\Hook\APIGetAllowedParamsHook; |
31 | use MediaWiki\Api\Hook\ApiQueryBaseAfterQueryHook; |
32 | use MediaWiki\Api\Hook\ApiQueryBaseBeforeQueryHook; |
33 | use MediaWiki\Api\Hook\ApiQueryBaseProcessRowHook; |
34 | use MediaWiki\Api\Hook\ApiQueryWatchlistExtractOutputDataHook; |
35 | use MediaWiki\Api\Hook\ApiQueryWatchlistPrepareWatchedItemQueryServiceOptionsHook; |
36 | use MediaWiki\Hook\WatchedItemQueryServiceExtensionsHook; |
37 | use MediaWiki\Watchlist\WatchedItem; |
38 | use MediaWiki\Watchlist\WatchedItemQueryService; |
39 | use ORES\Hooks\Helpers; |
40 | use ORES\Services\ORESServices; |
41 | use Wikimedia\ParamValidator\ParamValidator; |
42 | use Wikimedia\Rdbms\IConnectionProvider; |
43 | use Wikimedia\Rdbms\IResultWrapper; |
44 | |
45 | class ApiHooksHandler implements |
46 | APIGetAllowedParamsHook, |
47 | ApiQueryBaseBeforeQueryHook, |
48 | ApiQueryBaseAfterQueryHook, |
49 | ApiQueryBaseProcessRowHook, |
50 | ApiQueryWatchlistExtractOutputDataHook, |
51 | ApiQueryWatchlistPrepareWatchedItemQueryServiceOptionsHook, |
52 | WatchedItemQueryServiceExtensionsHook |
53 | { |
54 | |
55 | private IConnectionProvider $dbProvider; |
56 | |
57 | public function __construct( IConnectionProvider $dbProvider ) { |
58 | $this->dbProvider = $dbProvider; |
59 | } |
60 | |
61 | /** |
62 | * Inject parameters into certain API modules |
63 | * |
64 | * - Adds an 'oresscores' prop to ApiQueryRevisions, ApiQueryAllRevisions, |
65 | * ApiQueryRecentChanges, ApiQueryWatchlist, and ApiQueryUserContribs |
66 | * - Adds 'oresreview' and '!oresreview' to the 'show' parameters of |
67 | * ApiQueryRecentChanges, ApiQueryWatchlist, and ApiQueryUserContribs. |
68 | * |
69 | * The actual implementations of these new parameters are handled by the |
70 | * various hook functions below and by \ORES\WatchedItemQueryServiceExtension. |
71 | * |
72 | * @param ApiBase $module |
73 | * @param array &$params |
74 | * @param int $flags zero or OR-ed flags like ApiBase::GET_VALUES_FOR_HELP |
75 | */ |
76 | public function onAPIGetAllowedParams( $module, &$params, $flags ) { |
77 | if ( $module instanceof ApiQueryRevisions || |
78 | $module instanceof ApiQueryAllRevisions || |
79 | $module instanceof ApiQueryRecentChanges || |
80 | $module instanceof ApiQueryWatchlist || |
81 | $module instanceof ApiQueryUserContribs |
82 | ) { |
83 | $params['prop'][ParamValidator::PARAM_TYPE][] = 'oresscores'; |
84 | } |
85 | |
86 | if ( Helpers::isModelEnabled( 'damaging' ) && ( |
87 | $module instanceof ApiQueryRecentChanges || |
88 | $module instanceof ApiQueryWatchlist || |
89 | $module instanceof ApiQueryUserContribs |
90 | ) ) { |
91 | $params['show'][ParamValidator::PARAM_TYPE][] = 'oresreview'; |
92 | $params['show'][ParamValidator::PARAM_TYPE][] = '!oresreview'; |
93 | $params['show'][ApiBase::PARAM_HELP_MSG_APPEND][] = 'ores-api-show-note'; |
94 | } |
95 | } |
96 | |
97 | /** |
98 | * Modify the API query before it's made. |
99 | * |
100 | * This mainly adds the joins and conditions necessary to implement the |
101 | * 'oresreview' and '!oresreview' values added to the 'show' parameters of |
102 | * ApiQueryRecentChanges and ApiQueryUserContribs. |
103 | * |
104 | * It also ensures that the query from ApiQueryRecentChanges includes the |
105 | * fields necessary to process rcprop=oresscores. |
106 | * |
107 | * @warning Any joins added *must* join on a unique key of the target table |
108 | * unless you really know what you're doing. |
109 | * @param ApiQueryBase $module |
110 | * @param array &$tables tables to be queried |
111 | * @param array &$fields columns to select |
112 | * @param array &$conds WHERE conditionals for query |
113 | * @param array &$options options for the database request |
114 | * @param array &$joinConds join conditions for the tables |
115 | * @param array &$hookData Inter-hook communication |
116 | */ |
117 | public function onApiQueryBaseBeforeQuery( |
118 | $module, &$tables, &$fields, &$conds, &$options, &$joinConds, &$hookData |
119 | ) { |
120 | $params = $module->extractRequestParams(); |
121 | |
122 | if ( $module instanceof ApiQueryRecentChanges ) { |
123 | $field = 'rc_this_oldid'; |
124 | |
125 | // Make sure the needed fields are included in the query, if necessary |
126 | if ( !$module->isInGeneratorMode() && in_array( 'oresscores', $params['prop'], true ) ) { |
127 | if ( !in_array( 'rc_this_oldid', $fields, true ) ) { |
128 | $fields[] = 'rc_this_oldid'; |
129 | } |
130 | if ( !in_array( 'rc_type', $fields, true ) ) { |
131 | $fields[] = 'rc_type'; |
132 | } |
133 | } |
134 | } elseif ( $module instanceof ApiQueryUserContribs ) { |
135 | $field = 'rev_id'; |
136 | } else { |
137 | return; |
138 | } |
139 | |
140 | $show = Helpers::isModelEnabled( 'damaging' ) |
141 | ? array_flip( $params['show'] ?? [] ) |
142 | : []; |
143 | if ( isset( $show['oresreview'] ) || isset( $show['!oresreview'] ) ) { |
144 | if ( isset( $show['oresreview'] ) && isset( $show['!oresreview'] ) ) { |
145 | $module->dieWithError( 'apierror-show' ); |
146 | } |
147 | |
148 | $threshold = |
149 | Helpers::getThreshold( 'damaging', $module->getUser(), $module->getTitle() ); |
150 | $dbr = $this->dbProvider->getReplicaDatabase(); |
151 | |
152 | $tables[] = 'ores_classification'; |
153 | |
154 | if ( isset( $show['oresreview'] ) ) { |
155 | $join = 'INNER JOIN'; |
156 | |
157 | // Filter out non-damaging and unscored edits. |
158 | $conds[] = $dbr->expr( 'oresc_probability', '>', $threshold ); |
159 | |
160 | // Performance hack: add STRAIGHT_JOIN (T146111) |
161 | $options[] = 'STRAIGHT_JOIN'; |
162 | } else { |
163 | $join = 'LEFT JOIN'; |
164 | |
165 | // Filter out damaging edits. |
166 | $conds[] = $dbr->expr( 'oresc_probability', '<=', $threshold ) |
167 | ->or( 'oresc_probability', '=', null ); |
168 | } |
169 | |
170 | $modelId = ORESServices::getModelLookup()->getModelId( 'damaging' ); |
171 | $joinConds['ores_classification'] = [ $join, [ |
172 | "oresc_rev=$field", |
173 | 'oresc_model' => $modelId, |
174 | 'oresc_class' => 1 |
175 | ] ]; |
176 | } |
177 | } |
178 | |
179 | /** |
180 | * Perform work after the API query is made |
181 | * |
182 | * This fetches the data necessary to handle the 'oresscores' prop added to |
183 | * ApiQueryRevisions, ApiQueryAllRevisions, ApiQueryRecentChanges, and |
184 | * ApiQueryUserContribs, to avoid having to make up to 5000 fetches to do |
185 | * it individually per row. |
186 | * |
187 | * The list of revids is extracted from $res and scores are fetched using |
188 | * self::loadScoresForRevisions(). The following keys are written into |
189 | * $hookData, if our ApiQueryBaseProcessRow hook function needs to do |
190 | * anything at all: |
191 | * - oresField: (string) Field in the result rows holding the revid |
192 | * - oresCheckRCType: (bool) Whether to skip rows where rc_type is not |
193 | * RC_EDIT or RC_NEW. |
194 | * - oresScores: (array) Array of arrays of row objects holding the scores |
195 | * for each revision we were able to fetch. |
196 | * |
197 | * @param ApiQueryBase $module |
198 | * @param IResultWrapper|bool $res |
199 | * @param array &$hookData Inter-hook communication |
200 | */ |
201 | public function onApiQueryBaseAfterQuery( $module, $res, &$hookData ) { |
202 | if ( !$res ) { |
203 | return; |
204 | } |
205 | |
206 | // If the module is being used as a generator, don't bother. Generators |
207 | // don't return props. |
208 | if ( $module instanceof ApiQueryGeneratorBase && $module->isInGeneratorMode() ) { |
209 | return; |
210 | } |
211 | |
212 | if ( $module instanceof ApiQueryRevisions || |
213 | $module instanceof ApiQueryAllRevisions || |
214 | $module instanceof ApiQueryUserContribs |
215 | ) { |
216 | $field = 'rev_id'; |
217 | $checkRCType = false; |
218 | } elseif ( $module instanceof ApiQueryRecentChanges ) { |
219 | $field = 'rc_this_oldid'; |
220 | $checkRCType = true; |
221 | } else { |
222 | return; |
223 | } |
224 | |
225 | $params = $module->extractRequestParams(); |
226 | if ( in_array( 'oresscores', $params['prop'], true ) ) { |
227 | // Extract revision IDs from the result set |
228 | $revids = []; |
229 | foreach ( $res as $row ) { |
230 | if ( !$checkRCType || (int)$row->rc_type === RC_EDIT || (int)$row->rc_type === RC_NEW ) { |
231 | $revids[] = $row->$field; |
232 | } |
233 | } |
234 | $res->rewind(); |
235 | |
236 | if ( $revids ) { |
237 | $hookData['oresField'] = $field; |
238 | $hookData['oresCheckRCType'] = $checkRCType; |
239 | $scores = self::loadScoresForRevisions( $revids ); |
240 | $hookData['oresScores'] = $scores; |
241 | } |
242 | } |
243 | } |
244 | |
245 | /** |
246 | * Load ORES score data for a list of revisions |
247 | * |
248 | * Scores already cached are fetched from the database. |
249 | * |
250 | * @param int[] $revids Revision IDs |
251 | * @return array |
252 | */ |
253 | public static function loadScoresForRevisions( array $revids ) { |
254 | $scores = []; |
255 | $models = []; |
256 | foreach ( ORESServices::getModelLookup()->getModels() as $modelName => $modelDatum ) { |
257 | $models[$modelDatum['id']] = $modelName; |
258 | } |
259 | |
260 | // Load cached score data |
261 | $dbResult = ORESServices::getScoreLookup()->getScores( $revids, array_values( $models ) ); |
262 | foreach ( $dbResult as $row ) { |
263 | $scores[$row->oresc_rev][] = $row; |
264 | } |
265 | |
266 | return $scores; |
267 | } |
268 | |
269 | /** |
270 | * Modify each data row before it's returned. |
271 | * |
272 | * This uses the data added to $hookData by |
273 | * self::onApiQueryBaseAfterQuery() to actually inject the scores into the |
274 | * result data structure. See the documentation of that method for the |
275 | * details of that data. |
276 | * |
277 | * @param ApiQueryBase $module |
278 | * @param \stdClass $row |
279 | * @param array &$data |
280 | * @param array &$hookData Inter-hook communication |
281 | */ |
282 | public function onApiQueryBaseProcessRow( |
283 | $module, |
284 | $row, |
285 | &$data, |
286 | &$hookData |
287 | ) { |
288 | if ( isset( $hookData['oresField'] ) && |
289 | ( !$hookData['oresCheckRCType'] || |
290 | (int)$row->rc_type === RC_NEW || (int)$row->rc_type === RC_EDIT |
291 | ) |
292 | ) { |
293 | $data['oresscores'] = []; |
294 | $revid = $row->{$hookData['oresField']}; |
295 | |
296 | $modelData = ORESServices::getModelLookup()->getModels(); |
297 | $models = []; |
298 | foreach ( $modelData as $modelName => $modelDatum ) { |
299 | $models[$modelDatum['id']] = $modelName; |
300 | } |
301 | |
302 | if ( !isset( $hookData['oresScores'][$revid] ) ) { |
303 | return; |
304 | } |
305 | |
306 | self::addScoresForAPI( $data, $hookData['oresScores'][$revid], $models ); |
307 | } |
308 | } |
309 | |
310 | /** |
311 | * Helper to actuall add scores to an API result array |
312 | * |
313 | * @param array &$data Output array |
314 | * @param \stdClass[] $scores Array of score data |
315 | * @param array $models |
316 | */ |
317 | private static function addScoresForAPI( array &$data, array $scores, array $models ) { |
318 | global $wgOresModelClasses; |
319 | static $classMap = null; |
320 | |
321 | if ( $classMap === null ) { |
322 | $classMap = array_map( 'array_flip', $wgOresModelClasses ); |
323 | } |
324 | |
325 | foreach ( $scores as $row ) { |
326 | if ( !isset( $row->oresm_name ) && isset( $row->oresc_model ) ) { |
327 | $row->oresm_name = $models[$row->oresc_model]; |
328 | } |
329 | |
330 | if ( !isset( $row->oresm_name ) || !isset( $classMap[$row->oresm_name][$row->oresc_class] ) ) { |
331 | // Missing configuration, ignore it |
332 | continue; |
333 | } |
334 | $data['oresscores'][$row->oresm_name][$classMap[$row->oresm_name][$row->oresc_class]] = |
335 | (float)$row->oresc_probability; |
336 | } |
337 | |
338 | foreach ( $data['oresscores'] as $model => &$outputScores ) { |
339 | // Recalculate the class-0 result, as it's not stored in the database |
340 | if ( isset( $classMap[$model][0] ) && !isset( $outputScores[$classMap[$model][0]] ) ) { |
341 | $outputScores[$classMap[$model][0]] = 1.0 - array_sum( $outputScores ); |
342 | } |
343 | |
344 | ApiResult::setArrayType( $outputScores, 'kvp', 'name' ); |
345 | ApiResult::setIndexedTagName( $outputScores, 'class' ); |
346 | } |
347 | unset( $outputScores ); |
348 | |
349 | ApiResult::setArrayType( $data['oresscores'], 'kvp', 'name' ); |
350 | ApiResult::setIndexedTagName( $data['oresscores'], 'model' ); |
351 | } |
352 | |
353 | /** |
354 | * Convert API parameters to WatchedItemQueryService options |
355 | * |
356 | * @param ApiQueryBase $module |
357 | * @param array $params |
358 | * @param array &$options |
359 | */ |
360 | public function onApiQueryWatchlistPrepareWatchedItemQueryServiceOptions( |
361 | $module, $params, &$options |
362 | ) { |
363 | if ( in_array( 'oresscores', $params['prop'], true ) ) { |
364 | $options['includeFields'][] = 'oresscores'; |
365 | } |
366 | |
367 | $show = array_flip( $params['show'] ?? [] ); |
368 | if ( isset( $show['oresreview'] ) || isset( $show['!oresreview'] ) ) { |
369 | if ( isset( $show['oresreview'] ) && isset( $show['!oresreview'] ) ) { |
370 | $module->dieWithError( 'apierror-show' ); |
371 | } |
372 | |
373 | $options['filters'][] = isset( $show['oresreview'] ) ? 'oresreview' : '!oresreview'; |
374 | } |
375 | } |
376 | |
377 | /** |
378 | * Add data to ApiQueryWatchlist output |
379 | * |
380 | * @param ApiQueryBase $module |
381 | * @param WatchedItem $watchedItem |
382 | * @param array $recentChangeInfo |
383 | * @param array &$output |
384 | */ |
385 | public function onApiQueryWatchlistExtractOutputData( |
386 | $module, $watchedItem, $recentChangeInfo, &$output |
387 | ) { |
388 | if ( isset( $recentChangeInfo['oresScores'] ) ) { |
389 | $modelData = ORESServices::getModelLookup()->getModels(); |
390 | |
391 | $models = []; |
392 | foreach ( $modelData as $modelName => $modelDatum ) { |
393 | $models[$modelDatum['id']] = $modelName; |
394 | } |
395 | self::addScoresForAPI( $output, $recentChangeInfo['oresScores'], $models ); |
396 | } |
397 | } |
398 | |
399 | /** |
400 | * @param array &$extensions |
401 | * @param WatchedItemQueryService $watchedItemQueryService |
402 | */ |
403 | public function onWatchedItemQueryServiceExtensions( &$extensions, $watchedItemQueryService ) { |
404 | $extensions[] = new WatchedItemQueryServiceExtension(); |
405 | } |
406 | } |