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