Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.19% covered (success)
96.19%
101 / 105
78.57% covered (warning)
78.57%
11 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
BaseRegexFeature
96.19% covered (success)
96.19%
101 / 105
78.57% covered (warning)
78.57%
11 / 14
42
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 effectiveFields
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getValueDelimiters
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 parseValue
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 getFeatureName
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getCrossSearchStrategy
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 doApplyExtended
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 getFilterQuery
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 buildHighlightFields
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 getNonRegexFilterQuery
n/a
0 / 0
n/a
0 / 0
0
 getRegexHLFlavor
n/a
0 / 0
n/a
0 / 0
0
 configureHighlighting
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 doGetRegexHLFields
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 buildRegexQuery
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
6.17
 buildNonRegexHLFields
n/a
0 / 0
n/a
0 / 0
0
 isRegexQuery
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 trimFirstOccurrenceOfSlash
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace CirrusSearch\Query;
4
5use CirrusSearch\CirrusConfigNames;
6use CirrusSearch\CrossSearchStrategy;
7use CirrusSearch\Extra\Query\SourceRegex;
8use CirrusSearch\Parser\AST\KeywordFeatureNode;
9use CirrusSearch\Query\Builder\QueryBuildingContext;
10use CirrusSearch\Search\Fetch\FetchPhaseConfigBuilder;
11use CirrusSearch\Search\Fetch\HighlightedField;
12use CirrusSearch\Search\Fetch\HighlightFieldGenerator;
13use CirrusSearch\Search\Filters;
14use CirrusSearch\Search\SearchContext;
15use CirrusSearch\SearchConfig;
16use CirrusSearch\WarningCollector;
17use Elastica\Query\AbstractQuery;
18use MediaWiki\MainConfigNames;
19use Wikimedia\Assert\Assert;
20
21/**
22 * Base class supporting regex searches. Requires the wikimedia-extra plugin for
23 * elasticsearch. Can be really expensive, but mostly ok with the extra plugin
24 * enabled.
25 *
26 * Examples:
27 *   insource:/abc?/
28 *
29 * @see SourceRegex
30 */
31abstract class BaseRegexFeature extends SimpleKeywordFeature implements FilterQueryFeature, HighlightingFeature {
32    /**
33     * @var string[] Elasticsearch field(s) to search against
34     */
35    private $fields;
36
37    /**
38     * @var bool Is this feature enabled? Requires both CirrusSearchEnableRegex
39     *  and the wikimedia-extra plugin's regex support to be enabled.
40     */
41    private $enabled;
42
43    /**
44     * @var string Locale used for case conversions. It's important that this
45     *  matches the locale used for lowercasing in the ngram index.
46     */
47    private $languageCode;
48
49    /**
50     * @var string[] Configuration flags for the regex plugin
51     */
52    private $regexPlugin;
53
54    /**
55     * @var int The maximum number of automaton states that Lucene's regex
56     * compilation can expand to (even temporarily). Provides protection
57     * against overloading the search cluster.
58     */
59    private $maxDeterminizedStates;
60
61    /**
62     * @var string timeout for regex queries
63     * with the extra plugin
64     */
65    private $shardTimeout;
66
67    /**
68     * @param SearchConfig $config
69     * @param string[] $fields
70     */
71    public function __construct( SearchConfig $config, array $fields ) {
72        $this->languageCode = $config->get( MainConfigNames::LanguageCode );
73        $this->regexPlugin = $config->getElement( CirrusConfigNames::WikimediaExtraPlugin, 'regex' );
74        // Regex is only usable when the wikimedia-extra plugin is available to serve it.
75        $this->enabled = $config->get( CirrusConfigNames::EnableRegex )
76            && $this->regexPlugin && in_array( 'use', $this->regexPlugin );
77        $this->maxDeterminizedStates = $config->get( CirrusConfigNames::RegexMaxDeterminizedStates );
78        Assert::precondition( $fields !== [], 'must have at least one field' );
79        $this->fields = $fields;
80        $this->shardTimeout = $config->getElement( CirrusConfigNames::SearchShardTimeout, 'regex' );
81    }
82
83    /**
84     * The field set to query/highlight for this request.
85     *
86     * In redirect scope the fields under the `redirect.` prefix are dropped,
87     * otherwise the full set is used. This has to be part of parsing, rather
88     * than having appropriate fields provided in the constructor, because at
89     * construction time we don't know if redirect scope is enabled.
90     *
91     * @param bool $isRedirectScope
92     * @return string[]
93     */
94    private function effectiveFields( bool $isRedirectScope ): array {
95        if ( !$isRedirectScope ) {
96            return $this->fields;
97        }
98        return array_filter(
99            $this->fields,
100            static fn ( $field ) => Filters::allowFieldInRedirectScope( $field ),
101            ARRAY_FILTER_USE_KEY
102        );
103    }
104
105    /**
106     * @return string[][]
107     */
108    public function getValueDelimiters() {
109        return [
110            [
111                // simple search
112                'delimiter' => '"'
113            ],
114            [
115                // regex searches
116                'delimiter' => '/',
117                // optional case insensitive suffix
118                'suffixes' => 'i'
119            ]
120        ];
121    }
122
123    /**
124     * @param string $key
125     * @param string $value
126     * @param string $quotedValue
127     * @param string $valueDelimiter
128     * @param string $suffix
129     * @param WarningCollector $warningCollector
130     * @return array|false|null
131     */
132    public function parseValue( $key, $value, $quotedValue, $valueDelimiter, $suffix, WarningCollector $warningCollector ) {
133        if ( $valueDelimiter === '/' ) {
134            if ( !$this->enabled ) {
135                $warningCollector->addWarning( 'cirrussearch-feature-not-available', "$key regex" );
136            }
137
138            $pattern = $this->trimFirstOccurrenceOfSlash( $quotedValue );
139
140            if ( $pattern === '' ) {
141                $warningCollector->addWarning( 'cirrussearch-regex-empty-expression', $key );
142            }
143
144            return [
145                'type' => 'regex',
146                'pattern' => $pattern,
147                'insensitive' => $suffix === 'i',
148            ];
149        }
150        return parent::parseValue( $key, $value, $quotedValue, $valueDelimiter, $suffix, $warningCollector );
151    }
152
153    /**
154     * @param string $key
155     * @param string $valueDelimiter
156     * @return string
157     */
158    public function getFeatureName( $key, $valueDelimiter ) {
159        if ( $valueDelimiter === '/' ) {
160            return 'regex';
161        }
162        return parent::getFeatureName( $key, $valueDelimiter );
163    }
164
165    /**
166     * @param KeywordFeatureNode $node
167     * @return CrossSearchStrategy
168     */
169    public function getCrossSearchStrategy( KeywordFeatureNode $node ) {
170        if ( $node->getDelimiter() === '/' ) {
171            return CrossSearchStrategy::hostWikiOnlyStrategy();
172        } else {
173            return CrossSearchStrategy::allWikisStrategy();
174        }
175    }
176
177    /**
178     * @param SearchContext $context
179     * @param string $key
180     * @param string $value
181     * @param string $quotedValue
182     * @param bool $negated
183     * @param string $delimiter
184     * @param string $suffix
185     * @return array
186     */
187    public function doApplyExtended( SearchContext $context, $key, $value, $quotedValue, $negated, $delimiter, $suffix ) {
188        $parsedValue = $this->parseValue( $key, $value, $quotedValue, $delimiter, $suffix, $context );
189        if ( $this->isRegexQuery( $parsedValue ) ) {
190            if ( !$this->enabled ) {
191                return [ null, false ];
192            }
193            '@phan-var array $parsedValue';
194            $pattern = $parsedValue['pattern'];
195            $insensitive = $parsedValue['insensitive'];
196
197            if ( $pattern === '' ) {
198                $context->setResultsPossible( false );
199
200                return [ null, false ];
201            }
202
203            $fields = $this->effectiveFields( $context->isRedirectScope() );
204            $filter = $this->buildRegexQuery( $fields, $pattern, $insensitive );
205            if ( !$negated ) {
206                $this->configureHighlighting( $fields, $pattern, $insensitive, $context->getFetchPhaseBuilder() );
207            }
208            return [ $filter, false ];
209        } else {
210            return $this->doApply( $context, $key, $value, $quotedValue, $negated );
211        }
212    }
213
214    /**
215     * @inheritDoc
216     */
217    public function getFilterQuery( KeywordFeatureNode $node, QueryBuildingContext $context ) {
218        $parsedValue = $node->getParsedValue();
219        if ( $this->isRegexQuery( $parsedValue ) ) {
220            if ( !$this->enabled ) {
221                return null;
222            }
223            '@phan-var array $parsedValue';
224            $pattern = $parsedValue['pattern'];
225            $insensitive = $parsedValue['insensitive'];
226            return $this->buildRegexQuery( $this->effectiveFields( $context->isRedirectScope() ), $pattern, $insensitive );
227        } else {
228            return $this->getNonRegexFilterQuery( $node, $context );
229        }
230    }
231
232    /**
233     * @inheritDoc
234     */
235    public function buildHighlightFields( KeywordFeatureNode $node, QueryBuildingContext $context ) {
236        $parsedValue = $node->getParsedValue();
237        if ( $this->isRegexQuery( $parsedValue ) ) {
238            if ( !$this->enabled ) {
239                return [];
240            }
241            '@phan-var array $parsedValue';
242            $pattern = $parsedValue['pattern'];
243            $insensitive = $parsedValue['insensitive'];
244            return $this->doGetRegexHLFields( $context->getHighlightFieldGenerator(),
245                $this->effectiveFields( $context->isRedirectScope() ), $pattern, $insensitive );
246        }
247        return $this->buildNonRegexHLFields( $node, $context );
248    }
249
250    /**
251     * Obtain the filter when the keyword is used in non regex mode.
252     * This method will be called on syntax like keyword:word or keyword:"phrase"
253     * @param KeywordFeatureNode $node
254     * @param QueryBuildingContext $context
255     * @return AbstractQuery|null
256     */
257    abstract protected function getNonRegexFilterQuery( KeywordFeatureNode $node, QueryBuildingContext $context );
258
259    /**
260     * Determine the flavor of regex highlighting to apply.
261     * @return string one of: java, lucene, lucene_extended, lucene_anchored
262     */
263    abstract protected function getRegexHLFlavor(): string;
264
265    /**
266     * @param string[] $fields
267     * @param string $pattern
268     * @param bool $insensitive
269     * @param FetchPhaseConfigBuilder $fetchPhaseConfigBuilder
270     */
271    private function configureHighlighting( array $fields, $pattern, $insensitive, FetchPhaseConfigBuilder $fetchPhaseConfigBuilder ) {
272        foreach ( $this->doGetRegexHLFields( $fetchPhaseConfigBuilder, $fields, $pattern, $insensitive ) as $f ) {
273            $fetchPhaseConfigBuilder->addHLField( $f );
274        }
275    }
276
277    /**
278     * @param HighlightFieldGenerator $generator
279     * @param string[] $fields
280     * @param string $pattern
281     * @param bool $insensitive
282     * @return HighlightedField[]
283     */
284    private function doGetRegexHLFields( HighlightFieldGenerator $generator, array $fields, $pattern, $insensitive ) {
285        $hlFields = [];
286        if ( !$generator->supportsRegexFields() ) {
287            return $hlFields;
288        }
289        $regexFlavor = $this->getRegexHLFlavor();
290        foreach ( $fields as $field => $hlTarget ) {
291            $hlFields[] = $generator->newRegexField( "$field.plain", $hlTarget,
292                $pattern, $insensitive, HighlightedField::COSTLY_EXPERT_SYNTAX_PRIORITY,
293                $regexFlavor );
294        }
295        return $hlFields;
296    }
297
298    /**
299     * Builds a regular expression query using the wikimedia-extra plugin.
300     *
301     * @param string[] $fields
302     * @param string $pattern The regular expression to match
303     * @param bool $insensitive Should the match be case insensitive?
304     * @return AbstractQuery Regular expression query
305     */
306    private function buildRegexQuery( array $fields, $pattern, $insensitive ) {
307        $filters = [];
308        // TODO: Update plugin to accept multiple values for the field property
309        // so that at index time we can create a single trigram index with
310        // copy_to instead of creating multiple queries.
311        foreach ( $fields as $field => $hlTarget ) {
312            $filter = new SourceRegex( $pattern, $field, $field . '.trigram' );
313            // set some defaults
314            $filter->setMaxDeterminizedStates( $this->maxDeterminizedStates );
315            if ( isset( $this->regexPlugin['max_ngrams_extracted'] ) && is_numeric( $this->regexPlugin['max_ngrams_extracted'] ) ) {
316                $filter->setMaxNgramsExtracted( (int)$this->regexPlugin['max_ngrams_extracted'] );
317            }
318            if ( isset( $this->regexPlugin['max_ngram_clauses'] ) && is_numeric( $this->regexPlugin['max_ngram_clauses'] ) ) {
319                $filter->setMaxNgramClauses( (int)$this->regexPlugin['max_ngram_clauses'] );
320            }
321            $filter->setCaseSensitive( !$insensitive );
322            $filter->setLocale( $this->languageCode );
323
324            $filters[] = $filter;
325        }
326
327        return Filters::booleanOr( $filters );
328    }
329
330    /**
331     * @param KeywordFeatureNode $node
332     * @param QueryBuildingContext $context
333     * @return HighlightedField[]
334     */
335    abstract public function buildNonRegexHLFields( KeywordFeatureNode $node, QueryBuildingContext $context );
336
337    /**
338     * @param array|null $parsedValue
339     * @return bool
340     */
341    private function isRegexQuery( ?array $parsedValue = null ) {
342        return is_array( $parsedValue ) && isset( $parsedValue['type'] ) &&
343               $parsedValue['type'] === 'regex';
344    }
345
346    /**
347     * @param string $quotedValue
348     * @return false|string
349     */
350    private function trimFirstOccurrenceOfSlash( string $quotedValue ) {
351        $pattern = $quotedValue;
352        if ( str_starts_with( $pattern, '/' ) ) {
353            $pattern = substr( $pattern, 1 );
354        }
355        if ( str_ends_with( $pattern, '/' ) ) {
356            $pattern = substr( $pattern, 0, -1 );
357        }
358
359        return $pattern;
360    }
361}