Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
73 / 73
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
IndexedNumericFieldFeature
100.00% covered (success)
100.00%
73 / 73
100.00% covered (success)
100.00%
11 / 11
32
100.00% covered (success)
100.00%
1 / 1
 getKeywords
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCrossSearchStrategy
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doApply
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 parseValue
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
13
 extractSign
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 nanWarning
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 buildBoundedIntervalQuery
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 buildIntervalQuery
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 buildMatchQuery
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 doGetFilterQuery
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 getFilterQuery
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace CirrusSearch\Query;
4
5use CirrusSearch\CrossSearchStrategy;
6use CirrusSearch\Parser\AST\KeywordFeatureNode;
7use CirrusSearch\Query\Builder\QueryBuildingContext;
8use CirrusSearch\Search\SearchContext;
9use CirrusSearch\WarningCollector;
10use Elastica\Query;
11use Elastica\Query\AbstractQuery;
12use Wikimedia\Assert\Assert;
13
14/**
15 * File features:
16 *  filebits:16  - bit depth
17 *  filesize:>300 - size >= 300 kb
18 *  filew:100,300 - search of 100 <= file_width <= 300
19 * Selects only files of these specified features.
20 */
21class IndexedNumericFieldFeature extends SimpleKeywordFeature implements FilterQueryFeature {
22    /**
23     * Map from feature names to keys
24     * @var string[]
25     */
26    private const KEY_TABLE = [
27        'filesize' => 'file_size',
28        'filebits' => 'file_bits',
29        'fileh' => 'file_height',
30        'filew' => 'file_width',
31        'fileheight' => 'file_height',
32        'filewidth' => 'file_width',
33        'fileres' => 'file_resolution',
34        'textbytes' => 'text_bytes'
35    ];
36
37    /**
38     * @return string[]
39     */
40    protected function getKeywords() {
41        return array_keys( self::KEY_TABLE );
42    }
43
44    /**
45     * @param KeywordFeatureNode $node
46     * @return CrossSearchStrategy
47     */
48    public function getCrossSearchStrategy( KeywordFeatureNode $node ) {
49        return CrossSearchStrategy::allWikisStrategy();
50    }
51
52    /**
53     * @param SearchContext $context
54     * @param string $key The keyword
55     * @param string $value The value attached to the keyword with quotes stripped
56     * @param string $quotedValue The original value in the search string, including quotes
57     *     if used
58     * @param bool $negated Is the search negated? Not used to generate the returned
59     *     AbstractQuery, that will be negated as necessary. Used for any other building/context
60     *     necessary.
61     * @return array Two element array, first an AbstractQuery or null to apply to the
62     *  query. Second a boolean indicating if the quotedValue should be kept in the search
63     *  string.
64     */
65    protected function doApply( SearchContext $context, $key, $value, $quotedValue, $negated ) {
66        $query = $this->doGetFilterQuery( $key,
67            $this->parseValue( $key, $value, $quotedValue, '', '', $context ) );
68        if ( $query === null ) {
69            $context->setResultsPossible( false );
70        }
71
72        return [ $query, false ];
73    }
74
75    /**
76     * @param string $key
77     * @param string $value
78     * @param string $quotedValue
79     * @param string $valueDelimiter
80     * @param string $suffix
81     * @param WarningCollector $warningCollector
82     * @return array|false|null
83     */
84    public function parseValue( $key, $value, $quotedValue, $valueDelimiter, $suffix, WarningCollector $warningCollector ) {
85        $parsedValue = [];
86
87        $field = self::KEY_TABLE[$key];
88        $parsedValue['field'] = $field;
89        [ $sign, $number ] = $this->extractSign( $value );
90        // filesize treats no sign as >, since exact file size matches make no sense
91        if ( !$sign && $key === 'filesize' && strpos( $number, ',' ) === false ) {
92            $sign = 1;
93        }
94
95        $parsedValue['sign'] = $sign;
96
97        if ( $sign && strpos( $number, ',' ) !== false ) {
98            $warningCollector->addWarning(
99                'cirrussearch-file-numeric-feature-multi-argument-w-sign',
100                $key,
101                $number
102            );
103            return null;
104        } elseif ( $sign || strpos( $number, ',' ) === false ) {
105            if ( !is_numeric( $number ) ) {
106                $this->nanWarning( $warningCollector, $key, $number === '' ? $value : $number );
107                return null;
108            }
109            $parsedValue['value'] = intval( $number );
110        } else {
111            $numbers = explode( ',', $number, 2 );
112            $valid = true;
113            if ( !is_numeric( $numbers[0] ) ) {
114                $this->nanWarning( $warningCollector, $key, $numbers[0] );
115                $valid = false;
116            }
117
118            if ( !is_numeric( $numbers[1] ) ) {
119                $this->nanWarning( $warningCollector, $key, $numbers[1] );
120                $valid = false;
121            }
122            if ( !$valid ) {
123                return null;
124            }
125            $parsedValue['range'] = [ intval( $numbers[0] ), intval( $numbers[1] ) ];
126        }
127
128        return $parsedValue;
129    }
130
131    /**
132     * Extract sign prefix which can be < or > or nothing.
133     * @param string $value
134     * @param int $default
135     * @return array Two element array, first the sign: 0 is equal, 1 is more, -1 is less,
136     *  then the number to be compared.
137     */
138    protected function extractSign( $value, $default = 0 ) {
139        if ( $value[0] == '>' || $value[0] == '<' ) {
140            $sign = ( $value[0] == '>' ) ? 1 : -1;
141            return [ $sign, substr( $value, 1 ) ];
142        } else {
143            return [ $default, $value ];
144        }
145    }
146
147    /**
148     * Adds a warning to the search context that the $key keyword
149     * was provided with the invalid value $notANumber.
150     *
151     * @param WarningCollector $warningCollector
152     * @param string $key
153     * @param string $notANumber
154     */
155    protected function nanWarning( WarningCollector $warningCollector, $key, $notANumber ) {
156        $warningCollector->addWarning(
157            'cirrussearch-file-numeric-feature-not-a-number',
158            $key,
159            $notANumber
160        );
161    }
162
163    /**
164     * @param string $field
165     * @param int $from
166     * @param int $to
167     * @param int $multiplier
168     * @return Query\AbstractQuery
169     */
170    private function buildBoundedIntervalQuery( $field, $from, $to, $multiplier = 1 ) {
171        return new Query\Range( $field, [
172            'gte' => $from * $multiplier,
173            'lte' => $to * $multiplier
174        ] );
175    }
176
177    /**
178     * @param string $field
179     * @param int $sign
180     * @param int $value
181     * @param int $multiplier
182     * @return Query\AbstractQuery
183     */
184    private function buildIntervalQuery( $field, $sign, $value, $multiplier = 1 ) {
185        Assert::parameter( $sign != 0, 'sign', 'sign must be non zero' );
186        if ( $sign > 0 ) {
187            $range = [ 'gte' => $value * $multiplier ];
188        } else {
189            $range = [ 'lte' => $value * $multiplier ];
190        }
191        return new Query\Range( $field, $range );
192    }
193
194    /**
195     * @param string $field
196     * @param int $value
197     * @param int $multiplier
198     * @return Query\AbstractQuery
199     */
200    private function buildMatchQuery( $field, $value, $multiplier = 1 ) {
201        $query = new Query\MatchQuery();
202        $query->setFieldQuery( $field, (string)( $value * $multiplier ) );
203        return $query;
204    }
205
206    /**
207     * @param string $key
208     * @param array $parsedValue
209     * @return Query\AbstractQuery|null
210     */
211    protected function doGetFilterQuery( $key, $parsedValue ) {
212        if ( $parsedValue === null ) {
213            return null;
214        }
215        $field = $parsedValue['field'];
216        $sign = $parsedValue['sign'];
217        $multiplier = ( $key === 'filesize' ) ? 1024 : 1;
218
219        if ( isset( $parsedValue['range'] ) ) {
220            $query =
221                $this->buildBoundedIntervalQuery( $parsedValue['field'], $parsedValue['range'][0],
222                    $parsedValue['range'][1], $multiplier );
223        } elseif ( $sign === 0 ) {
224            $query = $this->buildMatchQuery( $field, $parsedValue['value'], $multiplier );
225        } else {
226            $query = $this->buildIntervalQuery( $field, $sign, $parsedValue['value'], $multiplier );
227        }
228
229        return $query;
230    }
231
232    /**
233     * @param KeywordFeatureNode $node
234     * @param QueryBuildingContext $context
235     * @return AbstractQuery|null
236     */
237    public function getFilterQuery( KeywordFeatureNode $node, QueryBuildingContext $context ) {
238        return $this->doGetFilterQuery( $node->getKey(), $node->getParsedValue() );
239    }
240}