Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.01% covered (success)
91.01%
81 / 89
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
IndexLookupFallbackMethod
91.01% covered (success)
91.01%
81 / 89
50.00% covered (danger)
50.00%
4 / 8
28.57
0.00% covered (danger)
0.00%
0 / 1
 getMetrics
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSearchRequest
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
5
 extractParam
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 build
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
4.00
 successApproximation
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
5.15
 extractMethodResponse
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 rewrite
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
5.73
1<?php
2
3namespace CirrusSearch\Fallbacks;
4
5use CirrusSearch\InterwikiResolver;
6use CirrusSearch\Parser\AST\Visitor\QueryFixer;
7use CirrusSearch\Profile\ArrayPathSetter;
8use CirrusSearch\Profile\SearchProfileException;
9use CirrusSearch\Profile\SearchProfileService;
10use CirrusSearch\Search\SearchMetricsProvider;
11use CirrusSearch\Search\SearchQuery;
12use Elastica\Client;
13use Elastica\Query;
14use Elastica\Search;
15
16class IndexLookupFallbackMethod implements FallbackMethod, ElasticSearchRequestFallbackMethod, SearchMetricsProvider {
17    use FallbackMethodTrait;
18
19    /**
20     * @var SearchQuery
21     */
22    private $query;
23
24    /**
25     * @var string
26     */
27    private $index;
28
29    /**
30     * @var array
31     */
32    private $queryTemplate;
33
34    /**
35     * @var string[]
36     */
37    private $queryParams;
38
39    /**
40     * @var string
41     */
42    private $suggestionField;
43
44    /**
45     * @var array
46     */
47    private $profileParams;
48
49    /**
50     * @var QueryFixer
51     */
52    private $queryFixer;
53
54    /**
55     * @var array
56     */
57    private $searchMetrics = [];
58
59    /**
60     * @var string[] Stored fields to request from elasticsearch
61     */
62    private $storedFields;
63
64    public function getMetrics(): array {
65        return $this->searchMetrics;
66    }
67
68    /**
69     * @param Client $client
70     * @return Search|null null if no additional request is to be executed for this method.
71     * @see FallbackRunnerContext::getMethodResponse()
72     */
73    public function getSearchRequest( Client $client ) {
74        $fixablePart = $this->queryFixer->getFixablePart();
75        if ( $fixablePart === null ) {
76            return null;
77        }
78        $queryParams = array_map(
79            function ( $v ) {
80                switch ( $v ) {
81                    case 'query':
82                        return $this->queryFixer->getFixablePart();
83                    case 'wiki':
84                        return $this->query->getSearchConfig()->getWikiId();
85                    default:
86                        return $this->extractParam( $v );
87                }
88            },
89            $this->queryParams
90        );
91        $arrayPathSetter = new ArrayPathSetter( $queryParams );
92        $query = $arrayPathSetter->transform( $this->queryTemplate );
93        $query = new Query( [ 'query' => $query ] );
94        $query->setFrom( 0 )
95            ->setSize( 1 )
96            ->setSource( false )
97            ->setStoredFields( $this->storedFields );
98        $search = new Search( $client );
99        $search->setQuery( $query )
100            ->addIndex( $client->getIndex( $this->index ) );
101        return $search;
102    }
103
104    /**
105     * @param string $keyAndValue
106     * @return mixed
107     */
108    private function extractParam( $keyAndValue ) {
109        $ar = explode( ':', $keyAndValue, 2 );
110        if ( count( $ar ) != 2 ) {
111            throw new SearchProfileException( "Invalid profile parameter [$keyAndValue]" );
112        }
113        [ $key, $value ] = $ar;
114        switch ( $key ) {
115            case 'params':
116                $paramValue = $this->profileParams[$value] ?? null;
117                if ( $paramValue == null ) {
118                    throw new SearchProfileException( "Missing profile parameter [$value]" );
119                }
120                return $paramValue;
121            default:
122                throw new SearchProfileException( "Unsupported profile parameter type [$key]" );
123        }
124    }
125
126    /**
127     * @param SearchQuery $query
128     * @param string $index
129     * @param array $queryTemplate
130     * @param string $suggestionField
131     * @param string[] $queryParams
132     * @param string[] $metricFields Additional stored fields to request and
133     *  report with metrics.
134     * @param array $profileParams
135     */
136    public function __construct(
137        SearchQuery $query,
138        $index,
139        $queryTemplate,
140        $suggestionField,
141        array $queryParams,
142        array $metricFields,
143        array $profileParams
144    ) {
145        $this->query = $query;
146        $this->index = $index;
147        $this->queryTemplate = $queryTemplate;
148        $this->suggestionField = $suggestionField;
149        $this->queryParams = $queryParams;
150        $this->profileParams = $profileParams;
151        $this->queryFixer = QueryFixer::build( $this->query->getParsedQuery() );
152        $this->storedFields = $metricFields;
153        $this->storedFields[] = $suggestionField;
154    }
155
156    /**
157     * @param SearchQuery $query
158     * @param array $params
159     * @param InterwikiResolver|null $interwikiResolver
160     * @return FallbackMethod|null the method instance or null if unavailable
161     */
162    public static function build( SearchQuery $query, array $params, InterwikiResolver $interwikiResolver = null ) {
163        if ( !$query->isWithDYMSuggestion() ) {
164            return null;
165        }
166        // TODO: Should this be tested at an upper level?
167        if ( $query->getOffset() !== 0 ) {
168            return null;
169        }
170        if ( !isset( $params['profile'] ) ) {
171            throw new SearchProfileException( "Missing mandatory field profile" );
172        }
173
174        $profileParams = $params['profile_params'] ?? [];
175
176        $profile = $query->getSearchConfig()->getProfileService()
177            ->loadProfileByName( SearchProfileService::INDEX_LOOKUP_FALLBACK, $params['profile'] );
178        '@phan-var array $profile';
179
180        return new self(
181            $query,
182            $profile['index'],
183            $profile['query'],
184            $profile['suggestion_field'],
185            $profile['params'],
186            $profile['metric_fields'],
187            $profileParams
188        );
189    }
190
191    /**
192     * @param FallbackRunnerContext $context
193     * @return float
194     */
195    public function successApproximation( FallbackRunnerContext $context ) {
196        $rset = $this->extractMethodResponse( $context );
197        if ( $rset === null || $rset->getResults() === [] ) {
198            return 0.0;
199        }
200        $fields = $rset->getResults()[0]->getFields();
201        $suggestion = $fields[$this->suggestionField][0] ?? null;
202        if ( $suggestion === null ) {
203            return 0.0;
204        }
205        // Metrics fields are everything except the suggestion field
206        unset( $fields[$this->suggestionField] );
207        if ( $fields ) {
208            $this->searchMetrics += $fields;
209        }
210        return 0.5;
211    }
212
213    /**
214     * @param FallbackRunnerContext $context
215     * @return \Elastica\ResultSet|null null if there are no response or no results
216     */
217    private function extractMethodResponse( FallbackRunnerContext $context ) {
218        if ( !$context->hasMethodResponse() ) {
219            return null;
220        }
221
222        return $context->getMethodResponse();
223    }
224
225    /**
226     * Rewrite the results,
227     * A costly call is allowed here, if nothing is to be done $previousSet
228     * must be returned.
229     *
230     * @param FallbackRunnerContext $context
231     * @return FallbackStatus
232     */
233    public function rewrite( FallbackRunnerContext $context ): FallbackStatus {
234        $previousSet = $context->getPreviousResultSet();
235        if ( !$context->costlyCallAllowed() ) {
236            // a method rewrote the query before us.
237            return FallbackStatus::noSuggestion();
238        }
239        if ( $previousSet->getSuggestionQuery() !== null ) {
240            // a method suggested something before us
241            return FallbackStatus::noSuggestion();
242        }
243        $resultSet = $context->getMethodResponse();
244        if ( !$resultSet->getResults() ) {
245            return FallbackStatus::noSuggestion();
246        }
247        $res = $resultSet->getResults()[0];
248        $suggestedQuery = $res->getFields()[$this->suggestionField][0] ?? null;
249        if ( $suggestedQuery === null ) {
250            return FallbackStatus::noSuggestion();
251        }
252        // Maybe rewrite
253        return $this->maybeSearchAndRewrite( $context, $this->query, $suggestedQuery );
254    }
255}