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