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