Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.07% covered (success)
91.07%
51 / 56
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
DatabaseQueryBuilder
91.07% covered (success)
91.07%
51 / 56
50.00% covered (danger)
50.00%
3 / 6
16.18
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 buildQuery
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 buildRangeQuery
93.55% covered (success)
93.55%
29 / 31
0.00% covered (danger)
0.00%
0 / 1
4.00
 buildDiscreteQuery
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 makeOresClassificationTableAlias
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateSelected
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
5.27
1<?php
2/**
3 * This program is free software: you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation, either version 3 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License
14 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
15 */
16
17namespace ORES\Storage;
18
19use ORES\Range;
20use Wikimedia\Rdbms\IExpression;
21use Wikimedia\Rdbms\IReadableDatabase;
22use Wikimedia\Rdbms\OrExpressionGroup;
23
24class DatabaseQueryBuilder {
25
26    /**
27     * @var ThresholdLookup
28     */
29    private $thresholdLookup;
30
31    /**
32     * @var IReadableDatabase
33     */
34    private $db;
35
36    public function __construct( ThresholdLookup $thresholdLookup, IReadableDatabase $db ) {
37        $this->thresholdLookup = $thresholdLookup;
38        $this->db = $db;
39    }
40
41    /**
42     * Build a WHERE clause for selecting only the ores_classification rows
43     * that match the specified classes for a model.
44     *
45     * @param string $modelName Model to query
46     * @param string|string[] $selected Array (or comma-separated string) of class names to select
47     * @param bool $isDiscrete
48     *     True for a model with distinct rows for each class
49     *     False for a model with thresholds within the score of a single row
50     * @return IExpression|OrExpressionGroup|false SQL Condition that can be used in WHERE directly, or false when there
51     * is nothing to filter on
52     */
53    public function buildQuery( $modelName, $selected, $isDiscrete = false ) {
54        return $isDiscrete ?
55            $this->buildDiscreteQuery( $modelName, $selected ) :
56            $this->buildRangeQuery( $modelName, $selected );
57    }
58
59    /**
60     * Build a WHERE clause for selecting only the ores_classification rows
61     * that match the specified classes for a model with thresholds.
62     *
63     * NOTE: This is used by PageTriage to filter on 'articlequality'
64     *
65     * @param string $modelName Model to filter
66     * @param string|string[] $selected Array (or comma-separated string) of class names to select
67     * @return OrExpressionGroup|false SQL Condition that can be used in WHERE directly, or false when there
68     * is nothing to filter on
69     */
70    private function buildRangeQuery( $modelName, $selected ) {
71        $thresholds = $this->thresholdLookup->getThresholds( $modelName );
72        $tableAlias = $this->makeOresClassificationTableAlias( $modelName );
73
74        $selected = $this->validateSelected( $selected, array_keys( $thresholds ) );
75        if ( !$selected ) {
76            return false;
77        }
78
79        $ranges = [];
80        foreach ( $selected as $className ) {
81            $range = new Range(
82                $thresholds[$className]['min'],
83                $thresholds[$className]['max']
84            );
85
86            $result = array_filter(
87                $ranges,
88                static function ( Range $r ) use ( $range ) {
89                    return $r->overlaps( $range );
90                }
91            );
92            $overlap = reset( $result );
93            if ( $overlap ) {
94                /** @var Range $overlap */
95                $overlap->combineWith( $range );
96            } else {
97                $ranges[] = $range;
98            }
99        }
100
101        $betweenConditions = array_map(
102            function ( Range $range ) use ( $tableAlias ) {
103                $min = $range->getMin();
104                $max = $range->getMax();
105                return $this->db->expr( "$tableAlias.oresc_probability", '>=', $min )
106                        ->and( "$tableAlias.oresc_probability", '<=', $max );
107            },
108            $ranges
109        );
110
111        return new OrExpressionGroup( ...$betweenConditions );
112    }
113
114    /**
115     * Build a WHERE clause for selecting only the ores_classification rows
116     * that match the specified levels for a model with discrete classification entries.
117     *
118     * NOTE: This is used by PageTriage to filter on 'draftquality'
119     *
120     * @param string $modelName Model to filter
121     * @param string|string[] $selected Array (or comma-separated string) of class names to select
122     * @return IExpression|false SQL Condition that can be used in WHERE directly, or false when there
123     * is nothing to filter on
124     */
125    private function buildDiscreteQuery( $modelName, $selected ) {
126        global $wgOresModelClasses;
127        $modelClasses = $wgOresModelClasses[ $modelName ];
128        $tableAlias = $this->makeOresClassificationTableAlias( $modelName );
129
130        $selected = $this->validateSelected( $selected, array_keys( $modelClasses ) );
131        if ( !$selected ) {
132            return false;
133        }
134
135        $classIds = [];
136        foreach ( $selected as $className ) {
137            $classIds[] = $modelClasses[ $className ];
138        }
139
140        return $this->db->expr( "$tableAlias.oresc_is_predicted", '=', 1 )
141            ->and( "$tableAlias.oresc_class", '=', $classIds );
142    }
143
144    private function makeOresClassificationTableAlias( $modelName ) {
145        return "ores_{$modelName}_cls";
146    }
147
148    /**
149     * @param string|string[] $selected Selected class names, can be a comma-separated
150     * @param string[] $possible All available class names for the model
151     * @return string[]|false Valid and unique selected class names or false if
152     *    no filters should be created
153     */
154    private function validateSelected( $selected, $possible ) {
155        $selected = is_array( $selected ) ? $selected :
156            explode( ',', $selected );
157        $selectedValid = array_intersect( $selected, $possible );
158        $selectedValidUnique = array_unique( $selectedValid );
159
160        if ( count( $selectedValidUnique ) === 0 ) {
161            // none selected
162            return false;
163        }
164
165        // all filters selected, and more than one possible filter exists.
166        // For filters like e.g. revertrisklanguageagnostic, where only one threshold exists,
167        // allow for selecting a single filter.
168        if ( count( $selectedValidUnique ) === count( $possible ) && count( $possible ) > 1 ) {
169            return false;
170        }
171
172        return $selectedValidUnique;
173    }
174
175}