Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
46.11% covered (danger)
46.11%
77 / 167
33.33% covered (danger)
33.33%
5 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchRequestBuilder
46.11% covered (danger)
46.11%
77 / 167
33.33% covered (danger)
33.33%
5 / 15
584.54
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 build
31.86% covered (danger)
31.86%
36 / 113
0.00% covered (danger)
0.00%
0 / 1
399.76
 getOffset
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setOffset
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getLimit
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setLimit
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getTimeout
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setTimeout
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getIndex
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 isCrossClusterSearch
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 inferIndexFromConcreteNamespaceMap
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 setIndex
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getSort
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setSort
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getSearchContext
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace CirrusSearch\Search;
4
5use CirrusSearch\CirrusConfigNames;
6use CirrusSearch\Connection;
7use CirrusSearch\LogChannel;
8use CirrusSearch\SearchConfig;
9use CirrusSearch\Util;
10use Elastica\Index;
11use Elastica\Query;
12use MediaWiki\Logger\LoggerFactory;
13use Wikimedia\Assert\Assert;
14
15/**
16 * Build the search request body
17 */
18class SearchRequestBuilder {
19    /** @var SearchContext */
20    private $searchContext;
21
22    /** @var Connection */
23    private $connection;
24
25    /** @var string */
26    private $indexBaseName;
27
28    /** @var int */
29    private $offset = 0;
30
31    /** @var int */
32    private $limit = 20;
33
34    /** @var string search timeout, string with time and unit, e.g. 20s for 20 seconds */
35    private $timeout;
36
37    /**
38     * @var Index|null force the index when set, use {@link Connection::pickIndexSuffixForNamespaces}
39     */
40    private $index;
41
42    /** @var string set the sort option, controls the use of rescore functions or elastic sort */
43    private $sort = 'relevance';
44
45    /**
46     * @param SearchContext $searchContext
47     * @param Connection $connection
48     * @param string $indexBaseName
49     */
50    public function __construct( SearchContext $searchContext, Connection $connection, $indexBaseName ) {
51        $this->searchContext = $searchContext;
52        $this->connection = $connection;
53        $this->indexBaseName = $indexBaseName;
54    }
55
56    /**
57     * Build the search request
58     * @return \Elastica\Search
59     */
60    public function build() {
61        $resultsType = $this->searchContext->getResultsType();
62
63        $query = new Query();
64        // Track at least offset + limit + 1 hits if precise total_hits is not requested
65        // This useful to know if more results are available on the next page
66        $query->setTrackTotalHits( $this->searchContext->getTrackTotalHits() ? true : $this->offset + $this->limit + 1 );
67        $query->setSource( $resultsType->getSourceFiltering() );
68        $query->setParam( "fields", $resultsType->getFields() );
69
70        $extraIndexes = $this->searchContext->getExtraIndices();
71
72        if ( $extraIndexes &&
73            $this->searchContext->getConfig()->getElement( CirrusConfigNames::DeduplicateInQuery ) !== false
74        ) {
75            $this->searchContext->addNotFilter( new \Elastica\Query\Term(
76                [ 'local_sites_with_dupe' => $this->indexBaseName ]
77            ) );
78        }
79
80        $mainQuery = $this->searchContext->getQuery();
81        $query->setQuery( $mainQuery );
82
83        foreach ( $this->searchContext->getAggregations() as $agg ) {
84            $query->addAggregation( $agg );
85        }
86
87        $highlight = $this->searchContext->getHighlight( $resultsType, $mainQuery );
88        if ( $highlight ) {
89            $query->setHighlight( $highlight );
90        }
91
92        $suggestQueries = $this->searchContext->getFallbackRunner()->getElasticSuggesters();
93        if ( $suggestQueries ) {
94            $query->setParam( 'suggest', [
95                // TODO: remove special case on 1-elt array, added to not change the test fixtures
96                // We should switch to explicit naming
97                'suggest' => count( $suggestQueries ) === 1 ? reset( $suggestQueries ) : $suggestQueries
98            ] );
99            $query->addParam( 'stats', 'suggest' );
100        }
101
102        foreach ( $this->searchContext->getSyntaxUsed() as $syntax ) {
103            $query->addParam( 'stats', $syntax );
104        }
105
106        // See also CirrusSearch::getValidSorts()
107        switch ( $this->sort ) {
108            case 'just_match':
109                // Use just matching scores, without any rescoring, and default sort.
110                break;
111            case 'relevance':
112                // Add some rescores to improve relevance
113                $rescores = $this->searchContext->getRescore();
114                if ( $rescores !== [] ) {
115                    $query->setParam( 'rescore', $rescores );
116                }
117                break;  // The default
118            case 'create_timestamp_asc':
119                $query->setSort( [ 'create_timestamp' => 'asc' ] );
120                break;
121            case 'create_timestamp_desc':
122                $query->setSort( [ 'create_timestamp' => 'desc' ] );
123                break;
124            case 'last_edit_asc':
125                $query->setSort( [ 'timestamp' => 'asc' ] );
126                break;
127            case 'last_edit_desc':
128                $query->setSort( [ 'timestamp' => 'desc' ] );
129                break;
130            case 'incoming_links_asc':
131                $query->setSort( [ 'incoming_links' => [
132                    'order' => 'asc',
133                    'missing' => '_first',
134                ] ] );
135                break;
136            case 'incoming_links_desc':
137                $query->setSort( [ 'incoming_links' => [
138                    'order' => 'desc',
139                    'missing' => '_last',
140                ] ] );
141                break;
142            case 'none':
143                // Return documents in index order
144                $query->setSort( [ '_doc' ] );
145                break;
146            case 'random':
147                $randomSeed = $this->searchContext->getSearchQuery()->getRandomSeed();
148                if ( $randomSeed === null && $this->offset !== 0 ) {
149                    $this->searchContext->addWarning( 'cirrussearch-offset-not-allowed-with-random-sort' );
150                    $this->offset = 0;
151                }
152                // Can't use an empty array, it would JSONify to [] instead of {}.
153                $scoreParams = ( $randomSeed === null ) ? (object)[] : [ 'seed' => $randomSeed, 'field' => '_seq_no' ];
154                // Instead of setting a sort field wrap the whole query in a
155                // bool filter and add a must clause for the random score. This
156                // could alternatively be a rescore over a limited document
157                // set, but in basic testing the filter was more performant
158                // than an 8k rescore window even with 50M total hits.
159                $query->setQuery( ( new Query\BoolQuery() )
160                    ->addFilter( $mainQuery )
161                    ->addMust( ( new Query\FunctionScore() )
162                        ->setQuery( new Query\MatchAll() )
163                        ->addFunction( 'random_score', $scoreParams ) ) );
164
165                break;
166            case 'user_random':
167                // Randomly ordered, but consistent for a single user
168                $query->setQuery( ( new Query\BoolQuery() )
169                    ->addFilter( $mainQuery )
170                    ->addMust( ( new Query\FunctionScore() )
171                        ->setQuery( new Query\MatchAll() )
172                        ->addFunction( 'random_score', [
173                            'seed' => Util::generateIdentToken(),
174                            'field' => '_seq_no',
175                        ] ) ) );
176                break;
177
178            case 'title_natural_asc':
179            case 'title_natural_desc':
180                if ( $this->searchContext->getConfig()->getElement( CirrusConfigNames::NaturalTitleSort, 'use' ) ) {
181                    $dir = explode( '_', $this->sort, 3 )[2];
182                    $query->setSort( [
183                        'namespace_text' => $dir,
184                        'title.natural_sort' => $dir,
185                    ] );
186                    break;
187                }
188                // Intentional fall-through to default error case.
189
190            default:
191                // Same as just_match. No user warning since an invalid sort
192                // getting this far is a bug in the calling code which should
193                // be validating it's input.
194                LoggerFactory::getInstance( LogChannel::DEFAULT )->warning(
195                    "Invalid sort type: {sort}",
196                    [ 'sort' => $this->sort ]
197                );
198        }
199
200        if ( $this->offset ) {
201            $query->setFrom( $this->offset );
202        }
203        if ( $this->limit ) {
204            $query->setSize( $this->limit );
205        }
206
207        // Setup the search
208        $queryOptions = [];
209        if ( $this->isCrossClusterSearch() ) {
210            // Ignore unavailable indices during cross-cluster searches. This feature is currently used only by interwiki searches
211            // for which we purposedly ignore errors. Instead of logging a search failure we let opensearch ignore this problem for us.
212            // Missing indices might be intermittent because ccs_minimize_roundtrips is on by default, this means that the coordinator will
213            // do minimal efforts to check that the target index exists in its view of the remote cluster cluster state.
214            // Disabling ccs_minimize_roundtrips could be an option but at the cost of increased latencies which is probably
215            // not something worth the cost for interwiki searches.
216            $queryOptions[\Elastica\Search::OPTION_SEARCH_IGNORE_UNAVAILABLE] = true;
217        }
218        if ( $this->timeout ) {
219            $queryOptions[\Elastica\Search::OPTION_TIMEOUT] = $this->timeout;
220        }
221        // @todo when switching to multi-search this has to be provided at the top level
222        if ( $this->searchContext->getConfig()->get( CirrusConfigNames::MoreAccurateScoringMode ) ) {
223            $queryOptions[\Elastica\Search::OPTION_SEARCH_TYPE] = \Elastica\Search::OPTION_SEARCH_TYPE_DFS_QUERY_THEN_FETCH;
224        }
225
226        $search = $this->getIndex()->createSearch( $query, $queryOptions );
227        $crossClusterName = $this->connection->getConfig()->getClusterAssignment()->getCrossClusterName();
228        foreach ( $extraIndexes as $i ) {
229            $search->addIndex( $this->connection->getIndex( $i->getSearchIndex( $crossClusterName ) ) );
230        }
231
232        $this->searchContext->getDebugOptions()->applyDebugOptions( $query );
233        return $search;
234    }
235
236    /**
237     * @return int
238     */
239    public function getOffset() {
240        return $this->offset;
241    }
242
243    /**
244     * @param int $offset
245     * @return self
246     */
247    public function setOffset( $offset ) {
248        $this->offset = $offset;
249
250        return $this;
251    }
252
253    /**
254     * @return int
255     */
256    public function getLimit() {
257        return $this->limit;
258    }
259
260    /**
261     * @param int $limit
262     * @return self
263     */
264    public function setLimit( $limit ) {
265        $this->limit = $limit;
266
267        return $this;
268    }
269
270    /**
271     * @return string
272     */
273    public function getTimeout() {
274        return $this->timeout;
275    }
276
277    /**
278     * @param string $timeout
279     * @return self
280     */
281    public function setTimeout( $timeout ) {
282        $this->timeout = $timeout;
283
284        return $this;
285    }
286
287    /**
288     * @return \Elastica\Index An elastica type suitable for searching against
289     *  the configured wiki over the host wiki's default connection.
290     */
291    public function getIndex(): \Elastica\Index {
292        if ( $this->index ) {
293            return $this->index;
294        } else {
295            $indexBaseName = $this->indexBaseName;
296            $config = $this->searchContext->getConfig();
297            $hostConfig = $config->getHostWikiConfig();
298
299            $indexName = $this->inferIndexFromConcreteNamespaceMap( $config );
300            if ( $indexName === null ) {
301                $indexSuffix = $this->connection->pickIndexSuffixForNamespaces(
302                    $this->searchContext->getNamespaces() );
303                $indexName = $this->connection->getIndexName( $indexBaseName, $indexSuffix );
304            }
305
306            if ( $this->isCrossClusterSearch() ) {
307                $local = $hostConfig->getClusterAssignment()->getCrossClusterName();
308                $current = $config->getClusterAssignment()->getCrossClusterName();
309                Assert::invariant( $local !== $current, 'clusters are different' );
310                $indexName = $current . ':' . $indexName;
311            }
312            return $this->connection->getIndex( $indexName );
313        }
314    }
315
316    private function isCrossClusterSearch(): bool {
317        $config = $this->searchContext->getConfig();
318        $hostConfig = $config->getHostWikiConfig();
319        if ( $hostConfig->get( CirrusConfigNames::CrossClusterSearch ) ) {
320            $local = $hostConfig->getClusterAssignment()->getCrossClusterName();
321            $current = $config->getClusterAssignment()->getCrossClusterName();
322            return $local !== $current;
323        }
324        return false;
325    }
326
327    /**
328     * Attempt to infer the index from the concrete namespace map.
329     * This is used mainly during crossproject searches where the concrete namespace map
330     * is provided by the config dump API.
331     * Since we might want to query namespaces that are unknown to the host wiki, we
332     * can't use the connection to pick the index suffix.
333     * Instead, we use the concrete namespace map to infer the index suffix.
334     * Returns null if the concrete namespace map is not available or if multiple index types
335     * might be required, in which case we rely on the host wiki connection to pick up the
336     * right index.
337     *
338     * @param SearchConfig $config
339     * @return string|null
340     */
341    private function inferIndexFromConcreteNamespaceMap( SearchConfig $config ): ?string {
342        if ( $this->searchContext->getNamespaces() && $config->has( CirrusConfigNames::ConcreteNamespaceMap ) ) {
343            // Attempt to skip Connection::pickIndexSuffixForNamespaces() and use the
344            // concrete namespace map.
345            // Reason is that the connection is built against the host wiki config but
346            // the concrete namespace map is likely obtained for the target wiki.
347            $concreteNamespaceMap = $config->get( CirrusConfigNames::ConcreteNamespaceMap );
348            $indices = [];
349            $inconsistentNamespaces = false;
350            foreach ( $this->searchContext->getNamespaces() as $ns ) {
351                if ( !isset( $concreteNamespaceMap[$ns] ) ) {
352                    // Something's odd here we can trust the target wiki config
353                    $inconsistentNamespaces = true;
354                    continue;
355                }
356                $indices[$concreteNamespaceMap[$ns]] = $concreteNamespaceMap[$ns];
357            }
358            // the concrete namespace map contains the full index name $basename_$suffix
359            // if there's only one index requested we target this one
360            // otherwise we target the main alias.
361            if ( !$inconsistentNamespaces && count( $indices ) === 1 ) {
362                $indexBaseName = reset( $indices );
363                return $this->connection->getIndexName( $indexBaseName );
364            }
365        }
366        return null;
367    }
368
369    /**
370     * @param ?Index $index
371     * @return $this
372     */
373    public function setIndex( ?Index $index ): self {
374        $this->index = $index;
375        return $this;
376    }
377
378    /**
379     * @return string
380     */
381    public function getSort() {
382        return $this->sort;
383    }
384
385    /**
386     * @param string $sort
387     * @return self
388     */
389    public function setSort( $sort ) {
390        $this->sort = $sort;
391
392        return $this;
393    }
394
395    /**
396     * @return SearchContext
397     */
398    public function getSearchContext() {
399        return $this->searchContext;
400    }
401}