Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
56.20% covered (warning)
56.20%
263 / 468
38.89% covered (danger)
38.89%
14 / 36
CRAP
0.00% covered (danger)
0.00%
0 / 1
Searcher
56.20% covered (warning)
56.20%
263 / 468
38.89% covered (danger)
38.89%
14 / 36
1267.52
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 search
58.06% covered (warning)
58.06%
18 / 31
0.00% covered (danger)
0.00%
0 / 1
6.84
 setResultsType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isReturnRaw
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSort
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 limitSearchToLocalWiki
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 nearMatchTitleSearch
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 countContentWords
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 prefixSearch
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 buildFullTextSearch
84.21% covered (warning)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
5.10
 searchTextInternal
71.15% covered (warning)
71.15%
37 / 52
0.00% covered (danger)
0.00%
0 / 1
20.40
 explainPage
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 get
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 getSuggest
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 buildPoolWorkForGet
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 buildSearch
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 searchOne
38.46% covered (danger)
38.46%
5 / 13
0.00% covered (danger)
0.00%
0 / 1
10.83
 searchMulti
38.89% covered (danger)
38.89%
42 / 108
0.00% covered (danger)
0.00%
0 / 1
121.65
 updateNamespacesFromQuery
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getSearchContext
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPoolCounterType
76.47% covered (warning)
76.47%
13 / 17
0.00% covered (danger)
0.00%
0 / 1
6.47
 isAutomatedRequest
41.67% covered (danger)
41.67%
5 / 12
0.00% covered (danger)
0.00%
0 / 1
7.18
 isDeepWebScrapingRequest
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getOverriddenConnection
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 recordQueryCacheMetrics
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 newLog
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 processRawReturn
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 searchArchive
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
1
 areSearchesTheSame
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
6.35
 buildInterleaveSearcher
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 emptyResultSet
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 applyDebugOptionsToQuery
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 makeSearcher
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 setOffsetLimit
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getOffsetLimit
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 buildFullTextBuilder
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
4.01
1<?php
2
3namespace CirrusSearch;
4
5use CirrusSearch\Fallbacks\FallbackRunner;
6use CirrusSearch\Fallbacks\SearcherFactory;
7use CirrusSearch\Parser\BasicQueryClassifier;
8use CirrusSearch\Parser\FullTextKeywordRegistry;
9use CirrusSearch\Parser\NamespacePrefixParser;
10use CirrusSearch\Profile\SearchProfileService;
11use CirrusSearch\Query\CountContentWordsBuilder;
12use CirrusSearch\Query\FullTextQueryBuilder;
13use CirrusSearch\Query\KeywordFeature;
14use CirrusSearch\Query\NearMatchQueryBuilder;
15use CirrusSearch\Query\PrefixSearchQueryBuilder;
16use CirrusSearch\Search\BaseCirrusSearchResultSet;
17use CirrusSearch\Search\FullTextResultsType;
18use CirrusSearch\Search\MSearchRequests;
19use CirrusSearch\Search\MSearchResponses;
20use CirrusSearch\Search\ResultsType;
21use CirrusSearch\Search\SearchContext;
22use CirrusSearch\Search\SearchQuery;
23use CirrusSearch\Search\SearchRequestBuilder;
24use CirrusSearch\Search\SemanticResultsType;
25use CirrusSearch\Search\TeamDraftInterleaver;
26use CirrusSearch\Search\TitleHelper;
27use CirrusSearch\Search\TitleResultsType;
28use CirrusSearch\SecondTry\SecondTryRunner;
29use Elastica\Exception\RuntimeException;
30use Elastica\Multi\Search as MultiSearch;
31use Elastica\Query;
32use Elastica\Query\BoolQuery;
33use Elastica\Query\MultiMatch;
34use Elastica\Search;
35use MediaWiki\Context\RequestContext;
36use MediaWiki\Exception\MWException;
37use MediaWiki\Logger\LoggerFactory;
38use MediaWiki\MediaWikiServices;
39use MediaWiki\Request\WebRequest;
40use MediaWiki\Status\Status;
41use MediaWiki\Title\Title;
42use MediaWiki\User\User;
43use MediaWiki\WikiMap\WikiMap;
44use Wikimedia\Assert\Assert;
45use Wikimedia\ObjectFactory\ObjectFactory;
46use Wikimedia\Stats\StatsFactory;
47
48/**
49 * Performs searches using Elasticsearch.  Note that each instance of this class
50 * is single use only.
51 *
52 * @license GPL-2.0-or-later
53 */
54class Searcher extends ElasticsearchIntermediary implements SearcherFactory {
55    public const SUGGESTION_HIGHLIGHT_PRE = '<em>';
56    public const SUGGESTION_HIGHLIGHT_POST = '</em>';
57    public const HIGHLIGHT_PRE_MARKER = ''; // \uE000. Can't be a unicode literal until php7
58    public const HIGHLIGHT_PRE = '<span class="searchmatch">';
59    public const HIGHLIGHT_POST_MARKER = ''; // \uE001
60    public const HIGHLIGHT_POST = '</span>';
61
62    /**
63     * Maximum offset + limit depth allowed. As in the deepest possible result
64     * to return. Too deep will cause very slow queries. 10,000 feels plenty
65     * deep. This should be <= index.max_result_window in elasticsearch.
66     */
67    private const MAX_OFFSET_LIMIT = 10000;
68
69    /**
70     * Queries with offset + limit greater than this value are considered
71     * potentially automated and may, after considering other related signals,
72     * be placed into the automated pool counter bucket.
73     */
74    private const AUTOMATED_RESULT_DEPTH_THRESHOLD_SMALL = 100;
75
76    /**
77     * Queries with offset + limit greater than this value are considered
78     * expensive and will use the expensive pool counter
79     */
80    private const AUTOMATED_RESULT_DEPTH_THRESHOLD_LARGE = 1000;
81
82    /**
83     * Identifies the main search in MSearchRequests/MSearchResponses
84     */
85    public const MAINSEARCH_MSEARCH_KEY = '__main__';
86
87    /**
88     * Identifies the "tested" search request in MSearchRequests/MSearchResponses
89     */
90    private const INTERLEAVED_MSEARCH_KEY = '__interleaved__';
91
92    /**
93     * @var int search offset
94     */
95    protected $offset;
96
97    /**
98     * @var int maximum number of result
99     */
100    protected $limit;
101
102    /**
103     * @var string sort type
104     */
105    private $sort = 'relevance';
106
107    /**
108     * @var string index base name to use
109     */
110    protected $indexBaseName;
111
112    /**
113     * Search environment configuration
114     * @var SearchConfig
115     */
116    protected $config;
117
118    /**
119     * @var SearchContext
120     */
121    protected $searchContext;
122
123    /**
124     * Indexing type we'll be using.
125     * @var string|\Elastica\Index
126     */
127    private $index;
128
129    /**
130     * @var NamespacePrefixParser|null
131     */
132    private $namespacePrefixParser;
133    /**
134     * @var InterwikiResolver
135     */
136    protected $interwikiResolver;
137
138    /** @var TitleHelper */
139    protected $titleHelper;
140    /**
141     * @var CirrusSearchHookRunner
142     */
143    protected $cirrusSearchHookRunner;
144
145    private NamespaceMatcher $namespaceMatcher;
146
147    /**
148     * @param Connection $conn
149     * @param int $offset Offset the results by this much
150     * @param int $limit Limit the results to this many
151     * @param SearchConfig $config Configuration settings
152     * @param int[]|null $namespaces Array of namespace numbers to search or null to search all namespaces.
153     * @param User|null $user user for which this search is being performed.  Attached to slow request logs.
154     * @param string|bool $index Base name for index to search from, defaults to $wgCirrusSearchIndexBaseName
155     * @param CirrusDebugOptions|null $options the debugging options to use or null to use defaults
156     * @param NamespacePrefixParser|null $namespacePrefixParser
157     * @param InterwikiResolver|null $interwikiResolver
158     * @param TitleHelper|null $titleHelper
159     * @param CirrusSearchHookRunner|null $cirrusSearchHookRunner
160     * @param NamespaceMatcher|null $namespaceMatcher
161     * @see CirrusDebugOptions::defaultOptions()
162     */
163    public function __construct(
164        Connection $conn, $offset,
165        $limit,
166        SearchConfig $config,
167        ?array $namespaces = null,
168        ?User $user = null,
169        $index = false,
170        ?CirrusDebugOptions $options = null,
171        ?NamespacePrefixParser $namespacePrefixParser = null,
172        ?InterwikiResolver $interwikiResolver = null,
173        ?TitleHelper $titleHelper = null,
174        ?CirrusSearchHookRunner $cirrusSearchHookRunner = null,
175        ?NamespaceMatcher $namespaceMatcher = null,
176    ) {
177        parent::__construct(
178            $conn,
179            $user,
180            $config->get( CirrusConfigNames::SlowSearch ),
181            $config->get( CirrusConfigNames::ExtraBackendLatency )
182        );
183        $this->config = $config;
184        $this->setOffsetLimit( $offset, $limit );
185        $this->indexBaseName = $index ?: $config->get( SearchConfig::INDEX_BASE_NAME );
186        // TODO: Make these params mandatory once WBCS stops extending this class
187        $this->namespacePrefixParser = $namespacePrefixParser;
188        $this->interwikiResolver = $interwikiResolver ?? MediaWikiServices::getInstance()->getService( InterwikiResolver::SERVICE );
189        $this->titleHelper = $titleHelper ?? new TitleHelper( WikiMap::getCurrentWikiId(), $this->interwikiResolver );
190        $this->cirrusSearchHookRunner = $cirrusSearchHookRunner ?? new CirrusSearchHookRunner(
191            MediaWikiServices::getInstance()->getHookContainer() );
192        $this->namespaceMatcher = $namespaceMatcher ?? MediaWikiServices::getInstance()->getService( NamespaceMatcher::SERVICE );
193        $this->searchContext = new SearchContext( $this->config, $namespaces, $options, null, null, $this->cirrusSearchHookRunner );
194    }
195
196    /**
197     * Unified search public entry-point.
198     *
199     * NOTE: only fulltext search supported for now.
200     * @param SearchQuery $query
201     * @return Status
202     */
203    public function search( SearchQuery $query ) {
204        if ( $query->getDebugOptions()->isCirrusDumpQueryAST() ) {
205            return Status::newGood( [ 'ast' => $query->getParsedQuery()->toArray() ] );
206        }
207        // TODO: properly pass the profile context name and its params once we have a dispatch service.
208        $this->searchContext = SearchContext::fromSearchQuery( $query, FallbackRunner::create( $query, $this->interwikiResolver ),
209            $this->cirrusSearchHookRunner );
210        $this->setOffsetLimit( $query->getOffset(), $query->getLimit() );
211        $this->config = $query->getSearchConfig();
212        $this->sort = $query->getSort();
213
214        if ( $query->getSearchEngineEntryPoint() === SearchQuery::SEARCH_TEXT ) {
215            switch ( $this->searchContext->getProfileContext() ) {
216                case SearchProfileService::CONTEXT_SEMANTIC:
217                    $profileSettings = $this->config->getProfileService()
218                        ->loadProfileByName( SearchProfileService::FT_QUERY_BUILDER,
219                            $this->searchContext->getFulltextQueryBuilderProfile() );
220                    $this->searchContext->setResultsType(
221                        new SemanticResultsType(
222                            $this->titleHelper,
223                            $query->getExtraFieldsToExtract(),
224                            $profileSettings,
225                        )
226                    );
227                    break;
228                default:
229                    $this->searchContext->setResultsType(
230                        new FullTextResultsType(
231                            $this->searchContext->getFetchPhaseBuilder(),
232                            $query->getParsedQuery()->isQueryOfClass( BasicQueryClassifier::COMPLEX_QUERY ),
233                            $this->titleHelper,
234                            $query->getExtraFieldsToExtract(),
235                            $this->searchContext->getConfig()->getElement( CirrusConfigNames::DeduplicateInMemory ) === true
236                        )
237                    );
238            }
239            return $this->searchTextInternal( $query->getParsedQuery()->getQueryWithoutNsHeader() );
240        } else {
241            throw new \RuntimeException( 'Only ' . SearchQuery::SEARCH_TEXT . ' is supported for now' );
242        }
243    }
244
245    /**
246     * @param ResultsType $resultsType results type to return
247     */
248    public function setResultsType( $resultsType ) {
249        $this->searchContext->setResultsType( $resultsType );
250    }
251
252    /**
253     * Is this searcher used to return debugging info?
254     * @return bool true if the search will return raw output
255     */
256    public function isReturnRaw() {
257        return $this->searchContext->getDebugOptions()->isReturnRaw();
258    }
259
260    /**
261     * Set the type of sort to perform.  Must be 'relevance', 'title_asc', 'title_desc'.
262     * @param string $sort sort type
263     */
264    public function setSort( $sort ) {
265        $this->sort = $sort;
266    }
267
268    /**
269     * Should this search limit results to the local wiki?  If not called the default is false.
270     * @param bool $limitSearchToLocalWiki should the results be limited?
271     */
272    public function limitSearchToLocalWiki( $limitSearchToLocalWiki ) {
273        $this->searchContext->setLimitSearchToLocalWiki( $limitSearchToLocalWiki );
274    }
275
276    /**
277     * Perform a "near match" title search which is pretty much a prefix match without the prefixes.
278     * @param string $term text by which to search
279     * @return Status status containing results defined by resultsType on success
280     */
281    public function nearMatchTitleSearch( $term ) {
282        ( new NearMatchQueryBuilder() )->build( $this->searchContext, $term );
283        return $this->searchOne();
284    }
285
286    /**
287     * Perform a sum over the number of words in the content index
288     * @return Status status containing a single integer
289     */
290    public function countContentWords() {
291        ( new CountContentWordsBuilder() )->build( $this->searchContext );
292        $this->limit = 1;
293        return $this->searchOne();
294    }
295
296    /**
297     * Perform a prefix search.
298     * @param string $term text by which to search
299     * @return Status status containing results defined by resultsType on success
300     */
301    public function prefixSearch( string $term, SecondTryRunner $secondTryRunner ): Status {
302        ( new PrefixSearchQueryBuilder( $secondTryRunner ) )->build( $this->searchContext, $term );
303        return $this->searchOne();
304    }
305
306    /**
307     * Build full text search for articles with provided term. All the
308     * state is applied to $this->searchContext. The returned query
309     * builder can be used to build a degraded query if necessary.
310     *
311     * @param string $term term to search
312     * @return FullTextQueryBuilder
313     */
314    protected function buildFullTextSearch( $term ) {
315        // Convert the unicode character 'ideographic whitespace' into standard
316        // whitespace. Cirrussearch treats them both as normal whitespace, but
317        // the preceding isn't appropriately trimmed.
318        // No searching for nothing! That takes forever!
319        $term = trim( str_replace( "\xE3\x80\x80", " ", $term ) );
320        if ( $term === '' ) {
321            $this->searchContext->setResultsPossible( false );
322        }
323
324        $builderSettings = $this->config->getProfileService()
325            ->loadProfileByName( SearchProfileService::FT_QUERY_BUILDER,
326                $this->searchContext->getFulltextQueryBuilderProfile() );
327        $features = ( new FullTextKeywordRegistry( $this->config ) )->getKeywords();
328        $qb = self::buildFullTextBuilder( $builderSettings, $this->config, $features );
329
330        $qb->build( $this->searchContext, $term );
331
332        if ( $this->searchContext->getSearchQuery() !== null ) {
333            $degradeOnParseWarnings = [
334                // && test, test AND && test
335                'cirrussearch-parse-error-unexpected-token',
336                // test AND
337                'cirrussearch-parse-error-unexpected-end'
338            ];
339            // Quick hack to avoid sending bad queries to the backend
340            foreach ( $this->searchContext->getSearchQuery()->getParsedQuery()->getParseWarnings() as $warning ) {
341                if ( in_array( $warning->getMessage(), $degradeOnParseWarnings ) ) {
342                    $qb->buildDegraded( $this->searchContext );
343                    return $qb;
344                }
345            }
346        }
347
348        return $qb;
349    }
350
351    /**
352     * @param string $term
353     * @return Status
354     */
355    private function searchTextInternal( $term ) {
356        // Searcher needs to be cloned before any actual query building is done.
357        $interleaveSearcher = $this->buildInterleaveSearcher();
358
359        $qb = $this->buildFullTextSearch( $term );
360        $mainSearch = $this->buildSearch();
361
362        $explainPage = $this->searchContext->getDebugOptions()->getCirrusExplainPage();
363        if ( $explainPage !== null ) {
364            return $this->explainPage( $explainPage, $mainSearch );
365        }
366
367        $searches = MSearchRequests::build( self::MAINSEARCH_MSEARCH_KEY, $mainSearch );
368        $description = "{$this->searchContext->getSearchType()} search for '{$this->searchContext->getOriginalSearchTerm()}'";
369
370        if ( !$this->searchContext->areResultsPossible() ) {
371            if ( $this->searchContext->getDebugOptions()->isCirrusDumpQuery() ) {
372                // return the empty array to suggest that no query will be run
373                return Status::newGood( [] );
374            }
375            $status = $this->emptyResultSet();
376            if ( $this->searchContext->getDebugOptions()->isCirrusDumpResult() ) {
377                return Status::newGood(
378                    ( new MSearchResponses( [ $status->getValue() ], [] ) )->dumpResults( $description )
379                );
380            }
381            return $status;
382        }
383
384        if ( $interleaveSearcher !== null ) {
385            $interleaveSearcher->buildFullTextSearch( $term );
386            $interleaveSearch = $interleaveSearcher->buildSearch();
387            if ( $this->areSearchesTheSame( $mainSearch, $interleaveSearch ) ) {
388                $interleaveSearcher = null;
389            } else {
390                $searches->addRequest( self::INTERLEAVED_MSEARCH_KEY, $interleaveSearch );
391            }
392        }
393
394        $fallbackRunner = $this->searchContext->getFallbackRunner();
395        $fallbackRunner->attachSearchRequests( $searches, $this->connection->getClient() );
396
397        if ( $this->searchContext->getDebugOptions()->isCirrusDumpQuery() ) {
398            return $searches->dumpQuery( $description );
399        }
400
401        $responses = $this->searchMulti( $searches );
402        if ( $responses->hasFailure() ) {
403            $status = $responses->getFailure();
404            if ( ElasticaErrorHandler::isParseError( $status ) ) {
405                // Rebuild the search context because we need a fresh fetchPhaseBuilder
406                $this->searchContext = $this->searchContext->withConfig( $this->config );
407                if ( $qb->buildDegraded( $this->searchContext ) ) {
408                    // If that doesn't work we're out of luck but it should.
409                    // There no guarantee it'll work properly with the syntax
410                    // we've built above but it'll do _something_ and we'll
411                    // still work on fixing all the parse errors that come in.
412                    $status = $this->searchOne();
413                }
414            }
415            return $status;
416        }
417
418        if ( $this->searchContext->getDebugOptions()->isCirrusDumpResult() ) {
419            return $responses->dumpResults( $description );
420        }
421
422        $rType = $this->getSearchContext()->getResultsType();
423        $mainSet = $responses->transformAsResultSet( $rType, self::MAINSEARCH_MSEARCH_KEY );
424        if ( $interleaveSearcher !== null ) {
425            $interleaver = new TeamDraftInterleaver( $this->searchContext->getOriginalSearchTerm() );
426            $testedSet = $responses->transformAsResultSet( $rType, self::INTERLEAVED_MSEARCH_KEY );
427            $response = $interleaver->interleave( $mainSet, $testedSet, $this->limit );
428        } else {
429            $response = $mainSet;
430        }
431
432        $status = Status::newGood();
433        if ( $this->namespacePrefixParser !== null ) {
434            $status = Status::newGood( $fallbackRunner->run( $this, $response, $responses,
435                $this->namespacePrefixParser, $this->cirrusSearchHookRunner ) );
436            $this->appendMetrics( $fallbackRunner );
437        }
438
439        foreach ( $this->searchContext->getWarnings() as $warning ) {
440            $status->warning( ...$warning );
441        }
442        return $status;
443    }
444
445    /**
446     * Run an OpenSearch single-document explain
447     *
448     * Distinguished from query-level explain by the ability to explain
449     * why a page did not match the query.
450     *
451     * @param string $pageId raw, unvalidated local mediawiki page id from the request
452     * @param Search $search the built search request to explain
453     * @return Status<array> the explain blob: { found, matched, explanation, query, index, docId }
454     */
455    private function explainPage( string $pageId, Search $search ): Status {
456        $explainer = new PageExplainer(
457            $this->getOverriddenConnection(),
458            $this->config,
459            MediaWikiServices::getInstance()->getTitleFactory(),
460            $this->indexBaseName
461        );
462        // The query clause only; _explain accepts nothing else (no rescore,
463        // highlight or aggs), and this mirrors what cirrusDumpQuery returns.
464        $queryClause = $search->getQuery()->toArray()['query'] ?? [];
465
466        $work = static function () use ( $explainer, $pageId, $queryClause ) {
467            return Status::newGood( $explainer->explain( (int)$pageId, $queryClause ) );
468        };
469        return Util::doPoolCounterWork( $this->getPoolCounterType(), $this->user, $work );
470    }
471
472    /**
473     * Get the page with $docId.  Note that the result is a status containing _all_ pages found.
474     * It is possible to find more then one page if the page is in multiple indexes.
475     * @param string[] $docIds array of document ids
476     * @param string[]|bool $sourceFiltering source filtering to apply
477     * @param bool $usePoolCounter false to disable the pool counter
478     * @return Status containing pages found, containing an empty array if not found,
479     *    or an error if there was an error
480     */
481    public function get( array $docIds, $sourceFiltering, $usePoolCounter = true ) {
482        $connection = $this->getOverriddenConnection();
483        $indexSuffix = $connection->pickIndexSuffixForNamespaces(
484            $this->searchContext->getNamespaces()
485        );
486
487        // The worst case would be to have all ids duplicated in all available indices.
488        // We set the limit accordingly
489        $size = count( $connection->getAllIndexSuffixesForNamespaces(
490            $this->searchContext->getNamespaces()
491        ) );
492        $size *= count( $docIds );
493
494        $query = new \Elastica\Query( new \Elastica\Query\Ids( $docIds ) );
495        if ( is_array( $sourceFiltering ) ) {
496            // The title is a required field in the ApiTrait. Maybe the API should be injecting
497            // that requirement?
498            if ( !in_array( "title", $sourceFiltering ) ) {
499                array_push( $sourceFiltering, "title" );
500            }
501            $query->setParam( '_source', $sourceFiltering );
502        }
503
504        $logMeta = [ 'docIds' => $docIds ];
505
506        $work = $this->buildPoolWorkForGet( $query, $logMeta, $indexSuffix, $size, $connection );
507
508        if ( $usePoolCounter ) {
509            return Util::doPoolCounterWork( $this->getPoolCounterType(), $this->user, $work );
510        } else {
511            return $work();
512        }
513    }
514
515    /**
516     * Get the completion documents associated with $docIds.
517     * @param int[] $docIds
518     * @return Status containing pages found, containing an empty array if not found,
519     *    or an error if there was an error
520     */
521    public function getSuggest( array $docIds ) {
522        $connection = $this->getOverriddenConnection();
523        $query = new \Elastica\Query( new \Elastica\Query\Terms( 'source_doc_id', $docIds ) );
524        $indexSuffix = Connection::TITLE_SUGGEST_INDEX_SUFFIX;
525        $logMeta = [ 'docIds' => $docIds ];
526
527        // Should be two docs per source doc id
528        $size = count( $docIds ) * 3;
529
530        $work = $this->buildPoolWorkForGet( $query, $logMeta, $indexSuffix, $size, $connection );
531        return Util::doPoolCounterWork( $this->getPoolCounterType(), $this->user, $work );
532    }
533
534    private function buildPoolWorkForGet(
535        \Elastica\Query $query,
536        array $logMeta,
537        string $indexSuffix,
538        int $size,
539        Connection $connection
540    ): \Closure {
541        return function () use ( $query, $logMeta, $indexSuffix, $size, $connection ) {
542            try {
543                $this->startNewLog( 'get of {indexSuffix}.{docIds}', 'get', [
544                    'indexSuffix' => $indexSuffix,
545                ] + $logMeta );
546                // Shard timeout not supported on get requests so we just use the client side timeout
547                $connection->setTimeout( $this->getClientTimeout( 'get' ) );
548                // We use a search query instead of _get/_mget, these methods are
549                // theorically well suited for this kind of job but they are not
550                // supported on aliases with multiple indices (content/general)
551                $index = $connection->getIndex( $this->indexBaseName, $indexSuffix );
552                $query->addParam( 'stats', 'get' );
553                // We ignore limits provided to the searcher
554                // otherwize we could return fewer results than
555                // the ids requested.
556                $query->setFrom( 0 );
557                $query->setSize( $size );
558                $resultSet = $index->search( $query, [ 'search_type' => 'query_then_fetch' ] );
559                self::throwIfNotOk( $connection, $resultSet->getResponse() );
560                return $this->success( $resultSet->getResults(), $connection );
561            } catch ( \Elastica\Exception\NotFoundException ) {
562                // NotFoundException just means the field didn't exist.
563                // It is up to the caller to decide if that is an error.
564                return $this->success( [], $connection );
565            } catch ( \Elastica\Exception\ExceptionInterface $e ) {
566                return $this->failure( $e, $connection );
567            }
568        };
569    }
570
571    /**
572     * @return \Elastica\Search
573     */
574    protected function buildSearch() {
575        $builder = new SearchRequestBuilder(
576            $this->searchContext, $this->getOverriddenConnection(), $this->indexBaseName );
577        return $builder->setLimit( $this->limit )
578            ->setOffset( $this->offset )
579            ->setIndex( $this->index )
580            ->setSort( $this->sort )
581            ->setTimeout( $this->getTimeout( $this->searchContext->getSearchType() ) )
582            ->build();
583    }
584
585    /**
586     * Perform a single-query search.
587     * @return Status
588     */
589    protected function searchOne() {
590        $search = $this->buildSearch();
591        $description = "{$this->searchContext->getSearchType()} search for '{$this->searchContext->getOriginalSearchTerm()}'";
592        $msearch = MSearchRequests::build( self::MAINSEARCH_MSEARCH_KEY, $search );
593        if ( $this->searchContext->getDebugOptions()->isCirrusDumpQuery() ) {
594            return $msearch->dumpQuery( $description );
595        }
596        if ( !$this->searchContext->areResultsPossible() ) {
597            return $this->emptyResultSet();
598        }
599
600        $mresults = $this->searchMulti( $msearch );
601
602        if ( $mresults->hasFailure() ) {
603            return $mresults->getFailure();
604        }
605
606        if ( $this->searchContext->getDebugOptions()->isReturnRaw() ) {
607            return $mresults->dumpResults( $description );
608        }
609        return $mresults->transformAndGetSingle( $this->searchContext->getResultsType(), self::MAINSEARCH_MSEARCH_KEY );
610    }
611
612    /**
613     * Powers full-text-like searches including prefix search.
614     *
615     * @param MSearchRequests $msearches
616     * @return MSearchResponses search responses
617     */
618    protected function searchMulti( MSearchRequests $msearches ) {
619        $searches = $msearches->getRequests();
620        $contextResultsType = $this->searchContext->getResultsType();
621        $cirrusDebugOptions = $this->searchContext->getDebugOptions();
622        Assert::precondition( !$cirrusDebugOptions->isCirrusDumpQuery(), 'Must not reach this method when dumping the query' );
623
624        // TODO: should this be moved upper in the stack?
625        if ( $this->limit <= 0 ) {
626            return $msearches->failure( Status::newFatal( 'cirrussearch-offset-too-large',
627                self::MAX_OFFSET_LIMIT, $this->offset ) );
628        }
629
630        $connection = $this->getOverriddenConnection();
631        $log = new MultiSearchRequestLog(
632            $connection->getClient(),
633            "{queryType} search for '{query}'",
634            $this->searchContext->getSearchType(),
635            [
636                'query' => $this->searchContext->getOriginalSearchTerm(),
637                'limit' => $this->limit ?: null,
638                // Used syntax
639                'syntax' => $this->searchContext->getSyntaxUsed(),
640            ],
641            $this->searchContext->getNamespaces() ?? []
642        );
643
644        // Similar to indexing support only the bulk code path, rather than
645        // single and bulk. The extra overhead should be minimal, and the
646        // reduced complexity is welcomed.
647        $search = new MultiSearch( $connection->getClient() );
648        $search->addSearches( $searches );
649
650        $connection->setTimeout( $this->getClientTimeout( $this->searchContext->getSearchType() ) );
651
652        if ( $this->config->get( CirrusConfigNames::MoreAccurateScoringMode ) ) {
653            $search->setSearchType( \Elastica\Search::OPTION_SEARCH_TYPE_DFS_QUERY_THEN_FETCH );
654        }
655
656        // Perform the search
657        $work = function () use ( $search, $log, $connection ) {
658            return Util::doPoolCounterWork(
659                $this->getPoolCounterType(),
660                $this->user,
661                function () use ( $search, $log, $connection ) {
662                    // @todo only reports the first error, also turns
663                    // a partial (single search) error into a complete
664                    // failure across the board. Should be addressed
665                    // at some point.
666                    return $this->runMSearch( $search, $log, $connection );
667                },
668                $this->searchContext->isSyntaxUsed( 'regex' ) ?
669                    'cirrussearch-regex-too-busy-error' : null
670            );
671        };
672
673        // Wrap with caching if needed, but don't cache debugging queries
674        $skipCache = $cirrusDebugOptions->mustNeverBeCached();
675        if ( $this->searchContext->getCacheTtl() > 0 && !$skipCache ) {
676            $work = function () use ( $work, $searches, $log, $contextResultsType ) {
677                $services = MediaWikiServices::getInstance();
678                $requestStats = Util::getStatsFactory();
679                $cache = $services->getMainWANObjectCache();
680                $keyParts = [];
681                foreach ( $searches as $key => $search ) {
682                    $keyParts[] = $search->getPath() .
683                        serialize( $search->getOptions() ) .
684                        serialize( $search->getQuery()->toArray() ) .
685                        ( $contextResultsType !== null ? get_class( $contextResultsType ) : "NONE" );
686                }
687                $key = $cache->makeKey( 'cirrussearch', 'search', 'v2', md5(
688                    implode( '|', $keyParts )
689                ) );
690                $cacheResult = $cache->get( $key );
691                if ( $cacheResult ) {
692                    [ $logVariables, $multiResultSet ] = $cacheResult;
693                    $this->recordQueryCacheMetrics( $requestStats, "hit" );
694                    $log->setCachedResult( $logVariables );
695                    $this->successViaCache( $log );
696
697                    if ( $multiResultSet->isOK() ) {
698                        /** @var \Elastica\Multi\ResultSet $cachedMResultSet */
699                        $cachedMResultSet = $multiResultSet->getValue();
700                        if ( count( $cachedMResultSet->getResultSets() ) !== count( $searches ) ) {
701                            LoggerFactory::getInstance( LogChannel::DEFAULT )
702                                ->warning( 'Ignoring a cached Multi/ResultSet wanted {nb_queries} response(s) but received {nb_responses}',
703                                    [
704                                        'nb_queries' => count( $searches ),
705                                        'nb_responses' => count( $cachedMResultSet->getResultSets() )
706                                    ] );
707                            $this->recordQueryCacheMetrics( $requestStats, "incoherent" );
708                        } else {
709                            return $multiResultSet;
710                        }
711                    } else {
712                        LoggerFactory::getInstance( LogChannel::DEFAULT )
713                            ->warning( 'Cached a Status value that is not OK' );
714                        $this->recordQueryCacheMetrics( $requestStats, "nok" );
715                    }
716                } else {
717                    $this->recordQueryCacheMetrics( $requestStats, "miss" );
718                }
719
720                $multiResultSet = $work();
721
722                if ( $multiResultSet->isOK() ) {
723                    $isPartialResult = false;
724                    foreach ( $multiResultSet->getValue()->getResultSets() as $resultSet ) {
725                        $responseData = $resultSet->getResponse()->getData();
726                        if ( isset( $responseData['timed_out'] ) && $responseData['timed_out'] ) {
727                            $isPartialResult = true;
728                            break;
729                        }
730                    }
731                    if ( !$isPartialResult ) {
732                        $this->recordQueryCacheMetrics( $requestStats, "set" );
733                        $cache->set(
734                            $key,
735                            [ $log->getLogVariables(), $multiResultSet ],
736                            $this->searchContext->getCacheTtl()
737                        );
738                    }
739                }
740
741                return $multiResultSet;
742            };
743        }
744
745        $status = $work();
746
747        // @todo Does this need anything special for multi-search changes?
748        if ( !$status->isOK() ) {
749            return $msearches->failure( $status );
750        }
751
752        /** @var \Elastica\Multi\ResultSet $response */
753        $response = $status->getValue();
754        if ( count( $response->getResultSets() ) !== count( $msearches->getRequests() ) ) {
755            // Temp hack to investigate T231023 (use php serialize just in case it has some invalid
756            // UTF8 sequences that would prevent this message from being sent to logstash
757            LoggerFactory::getInstance( LogChannel::DEFAULT )
758                ->warning( "Incoherent response received (#searches != #responses) for {query}: {response}",
759                    [ 'query' => $this->searchContext->getOriginalSearchTerm(), 'response' => serialize( $response->getResponse() ) ] );
760            return $msearches->failure( Status::newFatal( 'cirrussearch-backend-error' ) );
761        }
762        $mreponses = $msearches->toMSearchResponses( $response->getResultSets() );
763        if ( $mreponses->hasTimeout() ) {
764            LoggerFactory::getInstance( LogChannel::DEFAULT )->warning(
765                $log->getDescription() . " timed out and only returned partial results!",
766                $log->getLogVariables()
767            );
768            $this->searchContext->addWarning( $this->searchContext->isSyntaxUsed( 'regex' )
769                ? 'cirrussearch-regex-timed-out'
770                : 'cirrussearch-timed-out'
771            );
772        }
773        return $mreponses;
774    }
775
776    /**
777     * Attempt to suck a leading namespace followed by a colon from the query string.
778     * Reaches out to Elasticsearch to perform normalized lookup against the namespaces.
779     * Should be fast but for the network hop.
780     *
781     * @param string &$query
782     */
783    public function updateNamespacesFromQuery( &$query ) {
784        $colon = strpos( $query, ':' );
785        if ( $colon === false ) {
786            return;
787        }
788        $namespaceName = substr( $query, 0, $colon );
789        $namespaceId = $this->namespaceMatcher->identifyNamespace( $namespaceName );
790        if ( $namespaceId === null ) {
791            return;
792        }
793        $query = substr( $query, $colon + 1 );
794        $this->searchContext->setNamespaces( [ $namespaceId ] );
795    }
796
797    /**
798     * @return SearchContext
799     */
800    public function getSearchContext() {
801        return $this->searchContext;
802    }
803
804    private function getPoolCounterType(): string {
805        // Default pool counter for all search requests. Note that not all
806        // possible requests go through Searcher, so this isn't globally
807        // definitive.
808        $pool = PoolCounterKey::SEARCH;
809        // Pool counter overrides based on query syntax. Goal is to
810        // separate expensive or high-volume traffic into dedicated
811        // pools with specific limits. Prefix is only high volume
812        // when completion is disabled.
813        // TODO: Should this be configuration?
814        $poolCounterTypes = [
815            'deepcat' => PoolCounterKey::EXPENSIVE_FULL_TEXT,
816            'regex' => PoolCounterKey::EXPENSIVE_FULL_TEXT,
817            'prefix' => PoolCounterKey::PREFIX,
818            'more_like' => PoolCounterKey::MORE_LIKE,
819            'semantic' => PoolCounterKey::SEMANTIC,
820        ];
821        foreach ( $poolCounterTypes as $type => $counter ) {
822            if ( $this->searchContext->isSyntaxUsed( $type ) ) {
823                $pool = $counter;
824                break;
825            }
826        }
827        // Put external automated requests into their own bucket The main idea
828        // here is to allow automated access, but prevent that automation from
829        // capping out the pools used by interactive queries.
830        // It's not clear when the automation bucket should not override other
831        // bucketing decisions, for now override everything except Regex since
832        // those can be very expensive and usually use a small pool. If both
833        // the automation and regex pools filled with regexes it would be
834        // significantly more load than expected.
835        if ( $pool !== PoolCounterKey::EXPENSIVE_FULL_TEXT && $this->isAutomatedRequest() ) {
836            $pool = PoolCounterKey::AUTOMATED;
837        } elseif ( $this->offset + $this->limit >= self::AUTOMATED_RESULT_DEPTH_THRESHOLD_LARGE ) {
838            // Deep pagination is always expensive, but if the request was
839            // already flagged as automated leave it in the automated bucket.
840            // We don't want to accidently split a bot's requests into both
841            // buckets, allowing them to use all the capacity of both.
842            $pool = PoolCounterKey::EXPENSIVE_FULL_TEXT;
843        }
844        return $pool;
845    }
846
847    private function isAutomatedRequest(): bool {
848        $req = RequestContext::getMain()->getRequest();
849
850        if ( $this->isDeepWebScrapingRequest( $req ) ) {
851            return true;
852        }
853
854        try {
855            $ip = $req->getIP();
856        } catch ( MWException ) {
857            // No IP, typically this means a CLI invocation. We are attempting
858            // to segregate external automation, internal automation has its
859            // own ability to control configuration and shouldn't be flagged
860            if ( MW_ENTRY_POINT === 'cli' ) {
861                return false;
862            }
863            // When can we get here? Is this ever run?
864            LoggerFactory::getInstance( LogChannel::DEFAULT )->info(
865                'No IP available during automated request check' );
866            return false;
867        }
868        return Util::looksLikeAutomation(
869            $this->config, $ip, $req->getAllHeaders() );
870    }
871
872    /**
873     * Flags requests that look potentially automated
874     *
875     * See https://phabricator.wikimedia.org/T405482
876     */
877    private function isDeepWebScrapingRequest( WebRequest $req ): bool {
878        // Request issued via index.php (web). We could consider api requests,
879        // but the no cookie signal is only useful in the web context.
880        return MW_ENTRY_POINT === 'index'
881            // Request has no cookies (signal for simple bots).
882            && !$req->getHeader( 'Cookie' )
883            // Asks for results well beyond the typical
884            && $this->offset + $this->limit >= self::AUTOMATED_RESULT_DEPTH_THRESHOLD_SMALL;
885    }
886
887    /**
888     * Some queries, like more like this, are quite expensive and can cause
889     * latency spikes. This allows redirecting queries using particular
890     * features to specific clusters.
891     * @return Connection
892     */
893    private function getOverriddenConnection() {
894        $overrides = $this->config->get( CirrusConfigNames::ClusterOverrides );
895        foreach ( $overrides as $feature => $cluster ) {
896            if ( $this->searchContext->isSyntaxUsed( $feature ) ) {
897                return Connection::getPool( $this->config, $cluster );
898            }
899        }
900        return $this->connection;
901    }
902
903    protected function recordQueryCacheMetrics( StatsFactory $requestStats, string $cacheStatus, ?string $type = null ): void {
904        $type = $type ?: $this->getSearchContext()->getSearchType();
905        $requestStats->getCounter( "query_cache_total" )
906            ->setLabel( "type", $type )
907            ->setLabel( "status", $cacheStatus )
908            ->increment();
909    }
910
911    /**
912     * @param string $description
913     * @param string $queryType
914     * @param string[] $extra
915     * @return SearchRequestLog
916     */
917    protected function newLog( $description, $queryType, array $extra = [] ) {
918        return new SearchRequestLog(
919            $this->getOverriddenConnection()->getClient(),
920            $description,
921            $queryType,
922            $extra
923        );
924    }
925
926    /**
927     * If we're supposed to create raw result, create and return it,
928     * or output it and finish.
929     *
930     * @template T the type of the result passed and the return value of this function
931     * @param T $result Search result data
932     * @param WebRequest $request Request context
933     * @return T
934     */
935    public function processRawReturn( $result, WebRequest $request ) {
936        return Util::processSearchRawReturn( $result, $request,
937            $this->searchContext->getDebugOptions() );
938    }
939
940    /**
941     * Search titles in archive
942     * @param string $term
943     * @return Status<Title[]>
944     */
945    public function searchArchive( $term ) {
946        $this->searchContext->setOriginalSearchTerm( $term );
947        $term = $this->searchContext->escaper()->fixupWholeQueryString( $term );
948        $this->setResultsType( new TitleResultsType() );
949
950        // This does not support cross-cluster search, but there is also no use case
951        // for cross-wiki archive search.
952        $this->index = $this->getOverriddenConnection()->getArchiveIndex( $this->indexBaseName );
953        // The archive index has no page_type field; deleted redirects exist only as untyped
954        // title stubs, so the redirect-exclusion filter must not be applied.
955        $this->searchContext->setSupportsFirstClassRedirects( false );
956
957        // Setup the search query
958        $query = new BoolQuery();
959
960        $multi = new MultiMatch();
961        $multi->setType( 'best_fields' );
962        $multi->setTieBreaker( 0 );
963        $multi->setQuery( $term );
964        $multi->setFields( [
965            'title.near_match^100',
966            'title.near_match_asciifolding^75',
967            'title.plain^50',
968            'title^25'
969        ] );
970        $multi->setOperator( 'AND' );
971
972        $fuzzy = new \Elastica\Query\MatchQuery();
973        $fuzzy->setFieldQuery( 'title.plain', $term );
974        $fuzzy->setFieldFuzziness( 'title.plain', 'AUTO' );
975        $fuzzy->setFieldOperator( 'title.plain', 'AND' );
976
977        $query->addShould( $multi );
978        $query->addShould( $fuzzy );
979        $query->setMinimumShouldMatch( 1 );
980
981        $this->sort = 'just_match';
982
983        $this->searchContext->setMainQuery( $query );
984        $this->searchContext->addSyntaxUsed( 'archive' );
985        $this->searchContext->setRescoreProfile( 'empty' );
986
987        return $this->searchOne();
988    }
989
990    /**
991     * Tests if two search objects are equivalent
992     *
993     * @param Search $a
994     * @param Search $b
995     * @return bool
996     */
997    private function areSearchesTheSame( Search $a, Search $b ) {
998        // same object.
999        if ( $a === $b ) {
1000            return true;
1001        }
1002
1003        // Check values not included in toArray()
1004        if ( $a->getPath() !== $b->getPath()
1005            || $a->getOptions() != $b->getOptions()
1006        ) {
1007            return false;
1008        }
1009
1010        $aArray = $a->getQuery()->toArray();
1011        $bArray = $b->getQuery()->toArray();
1012
1013        // normalize the 'now' value which contains a timestamp that
1014        // may vary.
1015        $fixNow = static function ( &$value, $key ) {
1016            if ( $key === 'now' && is_int( $value ) ) {
1017                $value = 12345678;
1018            }
1019        };
1020        array_walk_recursive( $aArray, $fixNow );
1021        array_walk_recursive( $bArray, $fixNow );
1022
1023        // Simplest form, requires both arrays to have exact same ordering,
1024        // types, keys, etc. We could try much harder to remove edge cases,
1025        // but they probably don't matter too much. The main thing we are
1026        // looking for is if configuration used for interleaved search didn't
1027        // have an effect query building. If we get it wrong in some rare
1028        // cases it should have minimal effects on the interleaved search test.
1029        return $aArray === $bArray;
1030    }
1031
1032    private function buildInterleaveSearcher(): ?self {
1033        // If we aren't on the first page, or the user has specified
1034        // some custom magic query options (override rescore profile,
1035        // etc) then don't interleave.
1036        if ( $this->offset > 0 || $this->searchContext->isDirty() ) {
1037            return null;
1038        }
1039
1040        // Is interleaving configured?
1041        $overrides = $this->config->get( CirrusConfigNames::InterleaveConfig );
1042        if ( $overrides === null ) {
1043            return null;
1044        }
1045
1046        $config = new HashSearchConfig( $overrides, [ HashSearchConfig::FLAG_INHERIT ] );
1047        $other = clone $this;
1048        $other->config = $config;
1049        $other->searchContext = $other->searchContext->withConfig( $config );
1050
1051        return $other;
1052    }
1053
1054    /**
1055     * @return Status
1056     */
1057    private function emptyResultSet() {
1058        $results = $this->searchContext->getResultsType()->createEmptyResult();
1059        if ( $results instanceof BaseCirrusSearchResultSet ) {
1060            // TODO: Keywords are very specific to full-text search, while
1061            // ResultsType and this method are much more general.
1062            // While awkward, this maintains BC until we decide what to do.
1063            $results = BaseCirrusSearchResultSet::emptyResultSet(
1064                $this->searchContext->isSpecialKeywordUsed()
1065            );
1066        }
1067        $status = Status::newGood( $results );
1068        foreach ( $this->searchContext->getWarnings() as $warning ) {
1069            $status->warning( ...$warning );
1070        }
1071        return $status;
1072    }
1073
1074    /**
1075     * Apply debug options to the elastica query
1076     * @param Query $query
1077     * @return Query
1078     */
1079    public function applyDebugOptionsToQuery( Query $query ) {
1080        return $this->searchContext->getDebugOptions()->applyDebugOptions( $query );
1081    }
1082
1083    public function makeSearcher( SearchQuery $query ): self {
1084        return new self( $this->connection, $query->getOffset(), $query->getLimit(),
1085            $query->getSearchConfig(), $query->getNamespaces(), $this->user,
1086            false, $query->getDebugOptions(), $this->namespacePrefixParser, $this->interwikiResolver,
1087            $this->titleHelper, $this->cirrusSearchHookRunner );
1088    }
1089
1090    /**
1091     * @param int $offset
1092     * @param int $limit
1093     */
1094    private function setOffsetLimit( $offset, $limit ) {
1095        $this->offset = $offset;
1096        if ( $offset + $limit > self::MAX_OFFSET_LIMIT ) {
1097            $this->limit = self::MAX_OFFSET_LIMIT - $offset;
1098        } else {
1099            $this->limit = $limit;
1100        }
1101    }
1102
1103    /**
1104     * Visible for testing
1105     * @return int[] 2 elements array
1106     */
1107    public function getOffsetLimit() {
1108        Assert::precondition( defined( 'MW_PHPUNIT_TEST' ),
1109            'getOffsetLimit must only be called for testing purposes' );
1110        return [ $this->offset, $this->limit ];
1111    }
1112
1113    /**
1114     * Build a FullTextQueryBuilder defined in the $builderSettings:
1115     * format is:
1116     * [
1117     *     'builder_factory' => callback
1118     *     'settings' => ...
1119     * ]
1120     * where callback must be function that accepts the settings array and returns a FullTextQueryBuilder
1121     *
1122     * Legacy version:
1123     * [
1124     *     'builder_class' => ClassName
1125     *     'settings' => ...
1126     * ]
1127     * where ClassName must declare a constructor with these arguments:
1128     *   SearchConfig $config, KeywordFeature[] $features, $settings
1129     *
1130     * Visible for testing only
1131     * @param array $builderSettings
1132     * @param SearchConfig $config
1133     * @param KeywordFeature[] $features
1134     * @return FullTextQueryBuilder
1135     * @throws \ReflectionException
1136     */
1137    final public static function buildFullTextBuilder(
1138        array $builderSettings,
1139        SearchConfig $config,
1140        array $features
1141    ): FullTextQueryBuilder {
1142        if ( isset( $builderSettings['builder_class'] ) ) {
1143            $objectFactorySpecs = [
1144                'class' => $builderSettings['builder_class'],
1145                'args' => [
1146                    $config,
1147                    $features,
1148                    $builderSettings['settings']
1149                ]
1150            ];
1151        } elseif ( $builderSettings['builder_factory'] ) {
1152            $objectFactorySpecs = [
1153                'factory' => $builderSettings['builder_factory'],
1154                'args' => [
1155                    $builderSettings['settings']
1156                ]
1157            ];
1158        } else {
1159            throw new \InvalidArgumentException( 'Missing builder_class or builder_factory in the builderSettings' );
1160        }
1161
1162        /** @var FullTextQueryBuilder $qb */
1163        $qb = ObjectFactory::getObjectFromSpec( $objectFactorySpecs );
1164        if ( !( $qb instanceof FullTextQueryBuilder ) ) {
1165            throw new RuntimeException( 'Bad builder class configured.' );
1166        }
1167
1168        return $qb;
1169    }
1170}