Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.60% covered (warning)
89.60%
112 / 125
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiHooksHandler
89.60% covered (warning)
89.60%
112 / 125
50.00% covered (danger)
50.00%
5 / 10
74.36
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onAPIGetAllowedParams
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
10
 onApiQueryBaseBeforeQuery
85.29% covered (warning)
85.29%
29 / 34
0.00% covered (danger)
0.00%
0 / 1
13.54
 onApiQueryBaseAfterQuery
80.00% covered (warning)
80.00%
20 / 25
0.00% covered (danger)
0.00%
0 / 1
15.57
 loadScoresForRevisions
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 onApiQueryBaseProcessRow
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
7.03
 addScoresForAPI
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
10.02
 onApiQueryWatchlistPrepareWatchedItemQueryServiceOptions
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
7.14
 onApiQueryWatchlistExtractOutputData
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 onWatchedItemQueryServiceExtensions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
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
19namespace ORES\Hooks\Api;
20
21use ApiBase;
22use ApiQueryAllRevisions;
23use ApiQueryBase;
24use ApiQueryGeneratorBase;
25use ApiQueryRecentChanges;
26use ApiQueryRevisions;
27use ApiQueryUserContribs;
28use ApiQueryWatchlist;
29use ApiResult;
30use MediaWiki\Api\Hook\APIGetAllowedParamsHook;
31use MediaWiki\Api\Hook\ApiQueryBaseAfterQueryHook;
32use MediaWiki\Api\Hook\ApiQueryBaseBeforeQueryHook;
33use MediaWiki\Api\Hook\ApiQueryBaseProcessRowHook;
34use MediaWiki\Api\Hook\ApiQueryWatchlistExtractOutputDataHook;
35use MediaWiki\Api\Hook\ApiQueryWatchlistPrepareWatchedItemQueryServiceOptionsHook;
36use MediaWiki\Hook\WatchedItemQueryServiceExtensionsHook;
37use ORES\Hooks\Helpers;
38use ORES\Services\ORESServices;
39use WatchedItem;
40use WatchedItemQueryService;
41use Wikimedia\ParamValidator\ParamValidator;
42use Wikimedia\Rdbms\IConnectionProvider;
43use Wikimedia\Rdbms\IResultWrapper;
44
45class 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}