Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
57.14% covered (warning)
57.14%
28 / 49
25.00% covered (danger)
25.00%
1 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
PrefixSearchQueryBuilder
57.14% covered (warning)
57.14%
28 / 49
25.00% covered (danger)
25.00%
1 / 4
23.34
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 build
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 wordPrefixQuery
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 keywordPrefixQuery
80.95% covered (warning)
80.95%
17 / 21
0.00% covered (danger)
0.00%
0 / 1
3.06
1<?php
2
3namespace CirrusSearch\Query;
4
5use CirrusSearch\Profile\SearchProfileService;
6use CirrusSearch\Search\SearchContext;
7use CirrusSearch\SecondTry\SecondTryRunner;
8use Elastica\Query\AbstractQuery;
9use Elastica\Query\BoolQuery;
10use Elastica\Query\MatchQuery;
11use Elastica\Query\MultiMatch;
12
13/**
14 * Build a query suited for autocomplete on titles+redirects
15 */
16class PrefixSearchQueryBuilder {
17    use QueryBuilderTraits;
18
19    private SecondTryRunner $secondTryRunner;
20
21    public function __construct( SecondTryRunner $secondTryRunner ) {
22        $this->secondTryRunner = $secondTryRunner;
23    }
24
25    /**
26     * @param SearchContext $searchContext
27     * @param string $term the original search term
28     * @param array<string, string[]>|null $precomputedSecondTryCandidates list of pre-computed second try candidates
29     */
30    public function build( SearchContext $searchContext, string $term, ?array $precomputedSecondTryCandidates = null ): void {
31        if ( !$this->checkTitleSearchRequestLength( $term, $searchContext ) ) {
32            return;
33        }
34        $searchContext->setOriginalSearchTerm( $term );
35        $searchContext->setProfileContext( SearchProfileService::CONTEXT_PREFIXSEARCH );
36        $searchContext->addSyntaxUsed( 'prefix' );
37        if ( strlen( $term ) > 0 ) {
38            $secondTries = $precomputedSecondTryCandidates ?: $this->secondTryRunner->candidates( $term );
39            if ( $searchContext->getConfig()->get( 'CirrusSearchPrefixSearchStartsWithAnyWord' ) ) {
40                $searchContext->addFilter( $this->wordPrefixQuery( $term, $secondTries ) );
41            } else {
42                // TODO: weights should be a profile?
43                $weights = $searchContext->getConfig()->get( 'CirrusSearchPrefixWeights' );
44                $searchContext->setMainQuery( $this->keywordPrefixQuery( $term, $secondTries, $weights ) );
45            }
46        }
47    }
48
49    /**
50     * @param string $term
51     * @param array<string, string[]> $secondTries
52     * @return AbstractQuery
53     */
54    private function wordPrefixQuery( string $term, array $secondTries ): AbstractQuery {
55        $buildMatch = static function ( $searchTerm ) {
56            $match = new MatchQuery();
57            // TODO: redirect.title?
58            $match->setField( 'title.word_prefix', [
59                'query' => $searchTerm,
60                'analyzer' => 'plain',
61                'operator' => 'and',
62            ] );
63            return $match;
64        };
65        $query = new BoolQuery();
66        $query->setMinimumShouldMatch( 1 );
67        $query->addShould( $buildMatch( $term ) );
68        foreach ( $secondTries as $secondTry ) {
69            foreach ( $secondTry as $variant ) {
70                // This is a filter we don't really care about
71                // discounting variant matches.
72                $query->addShould( $buildMatch( $variant ) );
73            }
74        }
75        return $query;
76    }
77
78    /**
79     * @param string $term
80     * @param array<string, string[]> $secondTries
81     * @param int[] $weights
82     * @return AbstractQuery
83     */
84    private function keywordPrefixQuery( string $term, array $secondTries, array $weights ): AbstractQuery {
85        // Elasticsearch seems to have trouble extracting the proper terms to highlight
86        // from the default query we make so we feed it exactly the right query to highlight.
87        $buildMatch = static function ( string $searchTerm, float $weight ) use ( $weights ): AbstractQuery {
88            $query = new MultiMatch();
89            $query->setQuery( $searchTerm );
90            $query->setFields( [
91                'title.prefix^' . ( $weights['title'] * $weight ),
92                'redirect.title.prefix^' . ( $weights['redirect'] * $weight ),
93                'title.prefix_asciifolding^' . ( $weights['title_asciifolding'] * $weight ),
94                'redirect.title.prefix_asciifolding^' . ( $weights['redirect_asciifolding'] * $weight ),
95            ] );
96            return $query;
97        };
98        $query = new BoolQuery();
99        $query->setMinimumShouldMatch( 1 );
100        $query->addShould( $buildMatch( $term, 1 ) );
101        $candidateIndex = 0;
102        foreach ( $secondTries as $strategy => $secondTry ) {
103            $weight = $this->secondTryRunner->weight( $strategy );
104            foreach ( $secondTry as $variant ) {
105                $candidateIndex++;
106                $query->addShould( $buildMatch( $variant, $weight * ( 0.2 ** $candidateIndex ) ) );
107            }
108        }
109        return $query;
110    }
111}