Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.11% covered (warning)
72.11%
181 / 251
62.50% covered (warning)
62.50%
10 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
FullTextQueryStringQueryBuilder
72.11% covered (warning)
72.11%
181 / 251
62.50% covered (warning)
62.50%
10 / 16
110.65
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 build
76.67% covered (warning)
76.67%
92 / 120
0.00% covered (danger)
0.00%
0 / 1
22.12
 isPathologicalWildcard
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 buildDegraded
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 buildSearchTextQuery
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 buildQueryString
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 getMultiTermRewriteMethod
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 switchSearchToExactForWildcards
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 switchSearchToExact
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 buildFullTextSearchFields
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
4
 replaceAllPartsOfQuery
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 replacePartsOfQuery
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 buildHighlightQuery
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildPhraseRescoreQuery
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 isPhraseRescoreNeeded
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 maybeWrapWithTokenCountRouter
10.00% covered (danger)
10.00%
2 / 20
0.00% covered (danger)
0.00%
0 / 1
9.56
1<?php
2
3namespace CirrusSearch\Query;
4
5use CirrusSearch\CirrusConfigNames;
6use CirrusSearch\Extra\Query\TokenCountRouter;
7use CirrusSearch\Query\Builder\NearMatchFieldQueryBuilder;
8use CirrusSearch\Search\SearchContext;
9use CirrusSearch\SearchConfig;
10use Elastica\Query\AbstractQuery;
11use Elastica\Query\MatchAll;
12use Elastica\Query\MatchNone;
13use MediaWiki\Logger\LoggerFactory;
14
15/**
16 * Builds an Elastica query backed by an elasticsearch QueryString query
17 * Has many warts and edge cases that are hardly desirable.
18 */
19class FullTextQueryStringQueryBuilder implements FullTextQueryBuilder {
20    /**
21     * @var SearchConfig
22     */
23    protected $config;
24
25    /**
26     * @var KeywordFeature[]
27     */
28    private $features;
29
30    /**
31     * @var string
32     */
33    private $queryStringQueryString = '';
34
35    /**
36     * @var bool
37     */
38    private $useTokenCountRouter;
39
40    private NearMatchFieldQueryBuilder $nearMatchFieldQueryBuilder;
41
42    /**
43     * @param SearchConfig $config
44     * @param KeywordFeature[] $features
45     * @param array[] $settings currently ignored
46     */
47    public function __construct( SearchConfig $config, array $features, array $settings = [] ) {
48        $this->config = $config;
49        $this->features = $features;
50        $this->useTokenCountRouter = $this->config->getElement(
51            CirrusConfigNames::WikimediaExtraPlugin, 'token_count_router' ) === true;
52        $this->nearMatchFieldQueryBuilder = NearMatchFieldQueryBuilder::defaultFromSearchConfig( $config );
53    }
54
55    /**
56     * Search articles with provided term.
57     *
58     * @param SearchContext $searchContext
59     * @param string $term term to search
60     * searches that might be better?
61     */
62    public function build( SearchContext $searchContext, $term ) {
63        $searchContext->addSyntaxUsed( 'full_text' );
64        // Transform MediaWiki specific syntax to filters and extra
65        // (pre-escaped) query string
66        foreach ( $this->features as $feature ) {
67            $term = $feature->apply( $searchContext, $term );
68        }
69
70        if ( !$searchContext->areResultsPossible() ) {
71            return;
72        }
73
74        $term = $searchContext->escaper()->escapeQuotes( $term );
75        $term = trim( $term );
76
77        // Match quoted phrases including those containing escaped quotes.
78        // Those phrases can optionally be followed by ~ then a number (this is
79        // the phrase slop). That can optionally be followed by a ~ (this
80        // matches stemmed words in phrases). The following all match:
81        // "a", "a boat", "a\"boat", "a boat"~, "a boat"~9,
82        // "a boat"~9~, -"a boat", -"a boat"~9~
83        $slop = $this->config->get( CirrusConfigNames::PhraseSlop );
84        $matchQuotesRegex = '(?<![\]])(?<negate>-|!)?(?<main>"((?:[^"]|(?<=\\\)")+)"(?<slop>~\d+)?)(?<fuzzy>~)?';
85        $query = self::replacePartsOfQuery(
86            $term,
87            "/$matchQuotesRegex/",
88            function ( $matches ) use ( $searchContext, $slop ) {
89                $negate = $matches[ 'negate' ][ 0 ] ? 'NOT ' : '';
90                $main = $searchContext->escaper()->fixupQueryStringPart( $matches[ 'main' ][ 0 ] );
91
92                if ( !$negate && !isset( $matches[ 'fuzzy' ] ) && !isset( $matches[ 'slop' ] ) &&
93                    preg_match( '/^"([^"*]+)[*]"/', $main, $matches )
94                ) {
95                    $phraseMatch = new \Elastica\Query\MatchPhrasePrefix();
96                    $phraseMatch->setFieldQuery( "all.plain", $matches[1] );
97                    $searchContext->addNonTextQuery( $phraseMatch );
98                    $searchContext->addSyntaxUsed( 'phrase_match_prefix' );
99
100                    $phraseHighlightMatch = new \Elastica\Query\QueryString();
101                    $phraseHighlightMatch->setQuery( $matches[1] . '*' );
102                    $phraseHighlightMatch->setFields( [ 'all.plain' ] );
103                    $searchContext->addNonTextHighlightQuery( $phraseHighlightMatch );
104
105                    return [];
106                }
107
108                if ( !isset( $matches[ 'fuzzy' ] ) ) {
109                    if ( !isset( $matches[ 'slop' ] ) ) {
110                        $main .= '~' . $slop[ 'precise' ];
111                    }
112                    // Got to collect phrases that don't use the all field so we can highlight them.
113                    // The highlighter locks phrases to the fields that specify them.  It doesn't do
114                    // that with terms.
115                    return [
116                        'escaped' => $negate . self::switchSearchToExact( $searchContext, $main, true ),
117                        'nonAll' => $negate . self::switchSearchToExact( $searchContext, $main, false ),
118                    ];
119                }
120                return [ 'escaped' => $negate . $main ];
121            } );
122        // Find prefix matches and force them to only match against the plain analyzed fields.  This
123        // prevents prefix matches from getting confused by stemming.  Users really don't expect stemming
124        // in prefix queries.
125        $maxWildcards = $this->config->get( CirrusConfigNames::QueryStringMaxWildcards );
126        $query = self::replaceAllPartsOfQuery( $query, '/\w+\*(?:\w*\*?)*/u',
127            function ( $matches ) use ( $searchContext, $maxWildcards ) {
128                // hack to detect pathological wildcard
129                // relates to T102589 but elastic7 seems to have broken our fix by stopping
130                // to propagate the max_determinized_states param to the wildcard queries
131                // We might consider fixing this upstream again when switch to opensearch.
132                // In the meantine simply count the number of wildcard chars and mimic the previous
133                // if we detect such problematic queries
134                if ( self::isPathologicalWildcard( $matches[ 0 ][ 0 ], $maxWildcards ) ) {
135                    $searchContext->addWarning( 'cirrussearch-regex-too-complex-error' );
136                    $searchContext->setResultsPossible( false );
137                }
138                $term = $searchContext->escaper()->fixupQueryStringPart( $matches[ 0 ][ 0 ] );
139                return [
140                    'escaped' => self::switchSearchToExactForWildcards( $searchContext, $term ),
141                    'nonAll' => self::switchSearchToExactForWildcards( $searchContext, $term )
142                ];
143            } );
144
145        $escapedQuery = [];
146        $nonAllQuery = [];
147        $nearMatchQuery = [];
148        foreach ( $query as $queryPart ) {
149            if ( isset( $queryPart[ 'escaped' ] ) ) {
150                $escapedQuery[] = $queryPart[ 'escaped' ];
151                $nonAllQuery[] = $queryPart['nonAll'] ?? $queryPart['escaped'];
152                continue;
153            }
154            if ( isset( $queryPart[ 'raw' ] ) ) {
155                $fixed = $searchContext->escaper()->fixupQueryStringPart( $queryPart[ 'raw' ] );
156                $escapedQuery[] = $fixed;
157                $nonAllQuery[] = $fixed;
158                $nearMatchQuery[] = $queryPart[ 'raw' ];
159                continue;
160            }
161            LoggerFactory::getInstance( 'CirrusSearch' )->warning(
162                'Unknown query part: {queryPart}',
163                [ 'queryPart' => serialize( $queryPart ) ]
164            );
165        }
166
167        // Actual text query
168        $this->queryStringQueryString =
169            $searchContext->escaper()->fixupWholeQueryString( implode( ' ', $escapedQuery ) );
170        $searchContext->setCleanedSearchTerm( $this->queryStringQueryString );
171
172        if ( $this->queryStringQueryString === '' ) {
173            $searchContext->addSyntaxUsed( 'filter_only' );
174            $searchContext->setHighlightQuery( new MatchAll() );
175            return;
176        }
177
178        // Note that no escaping is required for near_match's match query.
179        $nearMatchQuery = implode( ' ', $nearMatchQuery );
180        // If the near match is made only of spaces disable it.
181        if ( preg_match( '/^\s+$/', $nearMatchQuery ) === 1 ) {
182            $nearMatchQuery = '';
183        }
184
185        $queryStringRegex =
186            '(' .
187                // quoted strings
188                $matchQuotesRegex .
189            ')|(' .
190                // patterns that are seen before tokens.
191                '(^|\s)[+!-]\S' .
192            ')|(' .
193                // patterns seen after tokens.
194                '\S(?<!\\\\)~[0-9]?(\s|$)' .
195            ')|(' .
196                // patterns that are separated from tokens by whitespace
197                // on both sides.
198                '\s(AND|OR|NOT|&&|\\|\\|)\s' .
199            ')|(' .
200                // patterns that can be at the start of the string
201                '^NOT\s' .
202            ')|(' .
203                // patterns that can be inside tokens
204                // Note that question mark stripping has already been applied
205                '(?<!\\\\)[?*]' .
206            ')';
207        if ( preg_match( "/$queryStringRegex/", $this->queryStringQueryString ) ) {
208            $searchContext->addSyntaxUsed( 'query_string' );
209        }
210        $fields = array_merge(
211            self::buildFullTextSearchFields( $searchContext, 1, '.plain', true ),
212            self::buildFullTextSearchFields( $searchContext,
213                $this->config->get( CirrusConfigNames::StemmedWeight ), '', true ) );
214
215        $searchContext->setMainQuery(
216            $this->buildSearchTextQuery(
217                $searchContext,
218                $fields,
219                $this->nearMatchFieldQueryBuilder->buildFromQueryString( $nearMatchQuery ),
220                $this->queryStringQueryString
221            )
222        );
223
224        // The highlighter doesn't know about the weighting from the all fields so we have to send
225        // it a query without the all fields.  This swaps one in.
226        $nonAllFields = array_merge(
227            self::buildFullTextSearchFields( $searchContext, 1, '.plain', false ),
228            self::buildFullTextSearchFields( $searchContext,
229                $this->config->get( CirrusConfigNames::StemmedWeight ), '', false ) );
230        $nonAllQueryString = $searchContext->escaper()
231            ->fixupWholeQueryString( implode( ' ', $nonAllQuery ) );
232        $searchContext->setHighlightQuery(
233            $this->buildHighlightQuery( $searchContext, $nonAllFields, $nonAllQueryString, 1 )
234        );
235
236        if ( $this->isPhraseRescoreNeeded( $searchContext ) ) {
237            $rescoreFields = $fields;
238
239            $searchContext->setPhraseRescoreQuery( $this->buildPhraseRescoreQuery(
240                        $searchContext,
241                        $rescoreFields,
242                        $this->queryStringQueryString,
243                        $this->config->getElement( CirrusConfigNames::PhraseSlop, 'boost' )
244                    ) );
245        }
246    }
247
248    private function isPathologicalWildcard( string $term, int $maxWildcard ): bool {
249        $ret = preg_match_all( "/[*?]+/", $term );
250        if ( $ret === false ) {
251            // we failed the regex, out of caution fail the query
252            return true;
253        }
254        return $ret > $maxWildcard;
255    }
256
257    /**
258     * Attempt to build a degraded query from the query already built into $context. Must be
259     * called *after* self::build().
260     *
261     * @param SearchContext $searchContext
262     * @return bool True if a degraded query was built
263     */
264    public function buildDegraded( SearchContext $searchContext ) {
265        if ( $this->queryStringQueryString === '' ) {
266            return false;
267        }
268
269        $fields = array_merge(
270            self::buildFullTextSearchFields( $searchContext, 1, '.plain', true ),
271            self::buildFullTextSearchFields( $searchContext,
272                $this->config->get( CirrusConfigNames::StemmedWeight ), '', true )
273        );
274
275        $searchContext->addSyntaxUsed( 'degraded_full_text' );
276        $simpleQuery = new \Elastica\Query\Simple( [ 'simple_query_string' => [
277            'fields' => $fields,
278            'query' => $this->queryStringQueryString,
279            'default_operator' => 'AND',
280            // Disable all costly operators
281            'flags' => 'OR|AND'
282        ] ] );
283        $searchContext->setMainQuery( $simpleQuery );
284        $searchContext->setHighlightQuery( $simpleQuery );
285
286        return true;
287    }
288
289    /**
290     * Build the primary query used for full text search. This will be a
291     * QueryString query, and optionally a MultiMatch if a $nearMatchQuery
292     * is provided.
293     *
294     * @param SearchContext $searchContext
295     * @param string[] $fields
296     * @param AbstractQuery $nearMatchQuery
297     * @param string $queryString
298     * @return \Elastica\Query\AbstractQuery
299     */
300    protected function buildSearchTextQuery(
301        SearchContext $searchContext,
302        array $fields,
303        AbstractQuery $nearMatchQuery,
304        $queryString
305    ) {
306        $slop = $this->config->getElement( CirrusConfigNames::PhraseSlop, 'default' );
307        $queryForMostFields = $this->buildQueryString( $fields, $queryString, $slop );
308        $searchContext->addSyntaxUsed( 'full_text_querystring', 5 );
309        if ( $nearMatchQuery instanceof MatchNone ) {
310            return $queryForMostFields;
311        }
312
313        // Build one query for the full text fields and one for the near match fields so that
314        // the near match can run unescaped.
315        $bool = new \Elastica\Query\BoolQuery();
316        $bool->setMinimumShouldMatch( 1 );
317        $bool->addShould( $queryForMostFields );
318        $bool->addShould( $nearMatchQuery );
319
320        return $bool;
321    }
322
323    /**
324     * Builds the query using the QueryString, this is the default builder
325     * used by cirrus and uses a default AND between clause.
326     * The query 'the query' and the fields all and all.plain will be like
327     * (all:the OR all.plain:the) AND (all:query OR all.plain:query)
328     *
329     * @param string[] $fields
330     * @param string $queryString
331     * @param int $phraseSlop
332     * @return \Elastica\Query\QueryString
333     */
334    private function buildQueryString( array $fields, $queryString, $phraseSlop ) {
335        $query = new \Elastica\Query\QueryString( $queryString );
336        $query->setFields( $fields );
337        $query->setPhraseSlop( $phraseSlop );
338        $query->setDefaultOperator( 'AND' );
339        $query->setAllowLeadingWildcard( (bool)$this->config->get( CirrusConfigNames::AllowLeadingWildcard ) );
340        $query->setFuzzyPrefixLength( 2 );
341        $query->setRewrite( $this->getMultiTermRewriteMethod() );
342        $states = $this->config->get( CirrusConfigNames::QueryStringMaxDeterminizedStates );
343        if ( $states !== null ) {
344            $query->setParam( 'max_determinized_states', $states );
345        }
346        return $query;
347    }
348
349    /**
350     * the rewrite method to use for multi term queries
351     * @return string
352     */
353    protected function getMultiTermRewriteMethod() {
354        return 'top_terms_boost_1024';
355    }
356
357    /**
358     * Expand wildcard queries to the all.plain and title.plain fields this is reasonable tradeoff
359     * between perf and precision.
360     *
361     * @param SearchContext $context
362     * @param string $term
363     * @return string
364     */
365    private static function switchSearchToExactForWildcards( SearchContext $context, $term ) {
366        // Try to limit the expansion of wildcards to all the subfields
367        // We still need to add title.plain with a high boost otherwise
368        // match in titles be poorly scored (actually it breaks some tests).
369        $titleWeight = $context->getConfig()->getElement( CirrusConfigNames::Weights, 'title' );
370        return "(title.plain:$term^$titleWeight OR all.plain:$term)";
371    }
372
373    /**
374     * Build a QueryString query where all fields being searched are
375     * queried for $term, joined with an OR. This is primarily for the
376     * benefit of the highlighter, the primary search is typically against
377     * the special all field.
378     *
379     * @param SearchContext $context
380     * @param string $term
381     * @param bool $allFieldAllowed
382     * @return string
383     */
384    private static function switchSearchToExact( SearchContext $context, $term, $allFieldAllowed ) {
385        $exact = implode( ' OR ',
386            self::buildFullTextSearchFields( $context, 1, ".plain:$term", $allFieldAllowed ) );
387        return "($exact)";
388    }
389
390    /**
391     * Build fields searched by full text search.
392     *
393     * @param SearchContext $context
394     * @param float $weight weight to multiply by all fields
395     * @param string $fieldSuffix suffix to add to field names
396     * @param bool $allFieldAllowed can we use the all field?  False for
397     *  collecting phrases for the highlighter.
398     * @return string[] array of fields to query
399     */
400    private static function buildFullTextSearchFields(
401        SearchContext $context,
402        $weight,
403        $fieldSuffix,
404        $allFieldAllowed
405    ) {
406        $searchWeights = $context->getConfig()->get( CirrusConfigNames::Weights );
407
408        if ( $allFieldAllowed ) {
409            return [ "all{$fieldSuffix}^{$weight}" ];
410        }
411
412        $fields = [];
413        $titleWeight = $weight * $searchWeights[ 'title' ];
414        $redirectWeight = $weight * $searchWeights[ 'redirect' ];
415        $fields[] = "title{$fieldSuffix}^{$titleWeight}";
416        $fields[] = "redirect.title{$fieldSuffix}^{$redirectWeight}";
417        $categoryWeight = $weight * $searchWeights[ 'category' ];
418        $headingWeight = $weight * $searchWeights[ 'heading' ];
419        $openingTextWeight = $weight * $searchWeights[ 'opening_text' ];
420        $textWeight = $weight * $searchWeights[ 'text' ];
421        $auxiliaryTextWeight = $weight * $searchWeights[ 'auxiliary_text' ];
422        $fields[] = "category{$fieldSuffix}^{$categoryWeight}";
423        $fields[] = "heading{$fieldSuffix}^{$headingWeight}";
424        $fields[] = "opening_text{$fieldSuffix}^{$openingTextWeight}";
425        $fields[] = "text{$fieldSuffix}^{$textWeight}";
426        $fields[] = "auxiliary_text{$fieldSuffix}^{$auxiliaryTextWeight}";
427        $namespaces = $context->getNamespaces();
428        if ( !$namespaces || in_array( NS_FILE, $namespaces ) ) {
429            $fileTextWeight = $weight * $searchWeights[ 'file_text' ];
430            $fields[] = "file_text{$fieldSuffix}^{$fileTextWeight}";
431        }
432        return $fields;
433    }
434
435    /**
436     * Walks through an array of query pieces, as built by
437     * self::replacePartsOfQuery, and replaecs all raw pieces by the result of
438     * self::replacePartsOfQuery when called with the provided regex and
439     * callable. One query piece may turn into one or more query pieces in the
440     * result.
441     *
442     * @param array[] $query The set of query pieces to apply against
443     * @param string $regex Pieces of $queryPart that match this regex will
444     *  be provided to $callable
445     * @param callable $callable A function accepting the $matches from preg_match
446     *  and returning either a raw or escaped query piece.
447     * @return array[] The set of query pieces after applying regex and callable
448     */
449    private static function replaceAllPartsOfQuery( array $query, $regex, $callable ) {
450        $result = [];
451        foreach ( $query as $queryPart ) {
452            if ( isset( $queryPart[ 'raw' ] ) ) {
453                $result = array_merge( $result,
454                    self::replacePartsOfQuery( $queryPart[ 'raw' ], $regex, $callable ) );
455            } else {
456                $result[] = $queryPart;
457            }
458        }
459        return $result;
460    }
461
462    /**
463     * Splits a query string into one or more sequential pieces. Each piece
464     * of the query can either be raw (['raw'=>'stuff']), or escaped
465     * (['escaped'=>'stuff']). escaped can also optionally include a nonAll
466     * query (['escaped'=>'stuff','nonAll'=>'stuff']). If nonAll is not set
467     * the escaped query will be used.
468     *
469     * Pieces of $queryPart that do not match the provided $regex are tagged
470     * as 'raw' and may see further parsing. $callable receives pieces of
471     * the string that match the regex and must return either a raw or escaped
472     * query piece.
473     *
474     * @param string $queryPart Raw piece of a user supplied query string
475     * @param string $regex Pieces of $queryPart that match this regex will
476     *  be provided to $callable
477     * @param callable $callable A function accepting the $matches from preg_match
478     *  and returning either a raw or escaped query piece.
479     * @return array[] The sequential set of quer ypieces $queryPart was
480     *  converted into.
481     */
482    private static function replacePartsOfQuery( $queryPart, $regex, $callable ) {
483        $destination = [];
484        $matches = [];
485        $offset = 0;
486        while ( preg_match( $regex, $queryPart, $matches, PREG_OFFSET_CAPTURE, $offset ) ) {
487            $startOffset = $matches[0][1];
488            if ( $startOffset > $offset ) {
489                $destination[] = [
490                    'raw' => substr( $queryPart, $offset, $startOffset - $offset )
491                ];
492            }
493
494            $callableResult = $callable( $matches );
495            if ( $callableResult ) {
496                $destination[] = $callableResult;
497            }
498
499            $offset = $startOffset + strlen( $matches[0][0] );
500        }
501
502        if ( $offset < strlen( $queryPart ) ) {
503            $destination[] = [
504                'raw' => substr( $queryPart, $offset ),
505            ];
506        }
507
508        return $destination;
509    }
510
511    /**
512     * Builds the highlight query
513     * @param SearchContext $context
514     * @param string[] $fields
515     * @param string $queryText
516     * @param int $slop
517     * @return \Elastica\Query\AbstractQuery
518     */
519    protected function buildHighlightQuery( SearchContext $context, array $fields, $queryText, $slop ) {
520        return $this->buildQueryString( $fields, $queryText, $slop );
521    }
522
523    /**
524     * Builds the phrase rescore query
525     * @param SearchContext $context
526     * @param string[] $fields
527     * @param string $queryText
528     * @param int $slop
529     * @return \Elastica\Query\AbstractQuery
530     */
531    protected function buildPhraseRescoreQuery( SearchContext $context, array $fields, $queryText, $slop ) {
532        return $this->maybeWrapWithTokenCountRouter(
533            $queryText,
534            $this->buildQueryString( $fields, '"' . $queryText . '"', $slop )
535        );
536    }
537
538    /**
539     * Determines if a phrase rescore is needed
540     * @param SearchContext $searchContext
541     * @return bool true if we can a phrase rescore
542     */
543    protected function isPhraseRescoreNeeded( SearchContext $searchContext ) {
544        // Only do a phrase match rescore if the query doesn't include
545        // any quotes and has a space or the token count router is
546        // active.
547        // Queries without spaces are either single term or have a
548        // phrase query generated.
549        // Queries with the quote already contain a phrase query and we
550        // can't build phrase queries out of phrase queries at this
551        // point.
552        if ( !$searchContext->isSpecialKeywordUsed() &&
553            strpos( $this->queryStringQueryString, '"' ) === false &&
554            ( $this->useTokenCountRouter || strpos( $this->queryStringQueryString, ' ' ) !== false )
555        ) {
556            return true;
557        }
558        return false;
559    }
560
561    /**
562     * @param string $queryText
563     * @param AbstractQuery $query
564     * @return AbstractQuery
565     */
566    protected function maybeWrapWithTokenCountRouter( $queryText, \Elastica\Query\AbstractQuery $query ) {
567        if ( $this->useTokenCountRouter ) {
568            $tokCount = new TokenCountRouter(
569                // text
570                $queryText,
571                // fallack
572                new \Elastica\Query\MatchNone(),
573                // field
574                'text'
575            );
576            $maxTokens = $this->config->get( CirrusConfigNames::MaxPhraseTokens );
577            if ( $maxTokens ) {
578                $tokCount->addCondition(
579                    TokenCountRouter::GT,
580                    $maxTokens,
581                    new \Elastica\Query\MatchNone()
582                );
583            }
584            $tokCount->addCondition(
585                TokenCountRouter::GT,
586                1,
587                $query
588            );
589            return $tokCount;
590        }
591        return $query;
592    }
593}