Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
19.07% covered (danger)
19.07%
41 / 215
9.09% covered (danger)
9.09%
2 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
CirrusSearch
19.07% covered (danger)
19.07%
41 / 215
9.09% covered (danger)
9.09%
2 / 22
3302.94
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
12
 setConnection
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getConnection
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getConfig
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 supports
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
6.10
 doSearchText
11.43% covered (danger)
11.43%
4 / 35
0.00% covered (danger)
0.00%
0 / 1
41.05
 isFeatureEnabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 searchTextReal
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
210
 getSuggestions
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 getValidSorts
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 getLastSearchMetrics
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 completionSearchBackend
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 completionSearchWithVariants
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 prefixSearch
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 getProfiles
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
8.02
 extractProfileFromFeatureData
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 makeSearchFieldMapping
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 searchArchiveTitle
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 getUpdater
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 countContentWords
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 makeSearcher
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getCirrusSearchHookRunner
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace CirrusSearch;
4
5use CirrusSearch\Parser\NamespacePrefixParser;
6use CirrusSearch\Parser\QueryStringRegex\SearchQueryParseException;
7use CirrusSearch\Profile\ContextualProfileOverride;
8use CirrusSearch\Profile\SearchProfileService;
9use CirrusSearch\Search\ArrayCirrusSearchResult;
10use CirrusSearch\Search\CirrusSearchIndexFieldFactory;
11use CirrusSearch\Search\CirrusSearchResultSet;
12use CirrusSearch\Search\FancyTitleResultsType;
13use CirrusSearch\Search\SearchMetricsProvider;
14use CirrusSearch\Search\SearchQuery;
15use CirrusSearch\Search\SearchQueryBuilder;
16use CirrusSearch\Search\TitleHelper;
17use CirrusSearch\Search\TitleResultsType;
18use CirrusSearch\SecondTry\SecondTryRunnerFactory;
19use CirrusSearch\SecondTry\SecondTrySearchFactory;
20use MediaWiki\Context\RequestContext;
21use MediaWiki\MediaWikiServices;
22use MediaWiki\Parser\Sanitizer;
23use MediaWiki\Request\WebRequest;
24use MediaWiki\Search\ISearchResultSet;
25use MediaWiki\Search\SearchEngine;
26use MediaWiki\Search\SearchIndexField;
27use MediaWiki\Search\SearchSuggestionSet;
28use MediaWiki\Status\Status;
29use MediaWiki\Title\Title;
30use MediaWiki\User\User;
31use MediaWiki\WikiMap\WikiMap;
32use Wikimedia\UUID\GlobalIdGenerator;
33
34/**
35 * SearchEngine implementation for CirrusSearch.  Delegates to
36 * CirrusSearchSearcher for searches and CirrusSearchUpdater for updates.  Note
37 * that lots of search behavior is hooked in CirrusSearchHooks rather than
38 * overridden here.
39 *
40 * @license GPL-2.0-or-later
41 */
42class CirrusSearch extends SearchEngine {
43
44    /**
45     * Canonical name of the extension.
46     */
47    public const NAME = 'CirrusSearch';
48
49    /**
50     * Service container key for the search engine. The engine is registered
51     * under the extension name.
52     */
53    public const SERVICE = self::NAME;
54
55    /**
56     * Special profile to instruct this class to use profile
57     * selection mechanism.
58     * This allows to defer profile selection to when we actually perform
59     * the search. The reason is that the list of possible profiles
60     * is returned by self::getProfiles so instead of assigning a default
61     * profile at this point we use this special profile.
62     */
63    public const AUTOSELECT_PROFILE = 'engine_autoselect';
64
65    /** @const string name of the prefixsearch fallback profile */
66    public const COMPLETION_PREFIX_FALLBACK_PROFILE = 'classic';
67
68    /**
69     * @const int Maximum title length that we'll check in prefix and keyword searches.
70     * Since titles can be 255 bytes in length we're setting this to 255
71     * characters.
72     */
73    public const MAX_TITLE_SEARCH = 255;
74
75    /**
76     * Name of the feature to extract more fields during a fulltext search request.
77     * Expected value is a list of strings identifying the fields to extract out
78     * of the source document.
79     * @see SearchEngine::supports() and SearchEngine::setFeatureData()
80     */
81    public const EXTRA_FIELDS_TO_EXTRACT = 'extra-fields-to-extract';
82
83    /**
84     * Name of the entry in the extension data array holding the extracted field
85     * requested using the EXTRA_FIELDS_TO_EXTRACT feature.
86     * @see \SearchResult::getExtensionData()
87     */
88    private const EXTRA_FIELDS = ArrayCirrusSearchResult::EXTRA_FIELDS;
89
90    /**
91     * @var array metrics about the last thing we searched sourced from the
92     *  Searcher instance
93     */
94    private $lastSearchMetrics = [];
95
96    /**
97     * @var array additional metrics about the search sourced within this class
98     */
99    private $extraSearchMetrics = [];
100
101    /**
102     * @var Connection
103     */
104    private $connection;
105
106    /**
107     * Search configuration.
108     * @var SearchConfig immutable
109     */
110    private $config;
111
112    /**
113     * Current request.
114     * @var WebRequest
115     */
116    private $request;
117
118    /**
119     * @var RequestContext
120     */
121    private $requestContext;
122
123    /**
124     * @var CirrusSearchIndexFieldFactory
125     */
126    private $searchIndexFieldFactory;
127
128    /**
129     * @var CirrusDebugOptions
130     */
131    private $debugOptions;
132
133    /**
134     * @var NamespacePrefixParser
135     */
136    private $namespacePrefixParser;
137
138    /**
139     * @var InterwikiResolver
140     */
141    private $interwikiResolver;
142
143    /**
144     * @var TitleHelper
145     */
146    private $titleHelper;
147
148    /**
149     * @var CirrusSearchHookRunner|null
150     */
151    private $cirrusSearchHookRunner;
152
153    private SecondTryRunnerFactory $secondTryRunnerFactory;
154
155    /**
156     * @param SearchConfig|null $config
157     * @param CirrusDebugOptions|null $debugOptions
158     * @param NamespacePrefixParser|null $namespacePrefixParser
159     * @param InterwikiResolver|null $interwikiResolver
160     * @param TitleHelper|null $titleHelper
161     */
162    public function __construct( ?SearchConfig $config = null,
163        ?CirrusDebugOptions $debugOptions = null,
164        ?NamespacePrefixParser $namespacePrefixParser = null,
165        ?InterwikiResolver $interwikiResolver = null,
166        ?TitleHelper $titleHelper = null,
167        ?SecondTryRunnerFactory $secondTryRunnerFactory = null,
168        ?GlobalIdGenerator $globalIdGenerator = null
169    ) {
170        // Initialize UserTesting before we create a Connection
171        // This is useful to do tests across multiple clusters
172        UserTestingStatus::getInstance();
173        $this->setFeatureData( SearchEngine::SEARCH_ID, Util::getRequestSetToken( $globalIdGenerator ) );
174        $this->config = $config ?? MediaWikiServices::getInstance()
175            ->getConfigFactory()
176            ->makeConfig( self::NAME );
177        $this->connection = new Connection( $this->config );
178        $this->requestContext = RequestContext::getMain();
179        $this->request = $this->requestContext->getRequest();
180        $this->searchIndexFieldFactory = new CirrusSearchIndexFieldFactory( $this->config );
181        if ( $secondTryRunnerFactory === null ) {
182            $this->secondTryRunnerFactory = new SecondTryRunnerFactory(
183                MediaWikiServices::getInstance()->getService( SecondTrySearchFactory::SERVICE ),
184                $this->config
185            );
186        } else {
187            $this->secondTryRunnerFactory = $secondTryRunnerFactory;
188        }
189        $this->namespacePrefixParser = $namespacePrefixParser ?? new class() implements NamespacePrefixParser {
190            /** @inheritDoc */
191            public function parse( $query ) {
192                return CirrusSearch::parseNamespacePrefixes( $query, true, true );
193            }
194        };
195        $this->interwikiResolver = $interwikiResolver ?? MediaWikiServices::getInstance()->getService( InterwikiResolver::SERVICE );
196
197        // enable interwiki by default
198        $this->features['interwiki'] = true;
199        $this->features['show-multimedia-search-results'] =
200            $this->config->get( CirrusConfigNames::CrossProjectShowMultimedia ) == true;
201        $this->debugOptions = $debugOptions ?? CirrusDebugOptions::fromRequest( $this->request );
202        $this->titleHelper = $titleHelper ?? new TitleHelper( WikiMap::getCurrentWikiId(), $interwikiResolver,
203            Sanitizer::escapeIdForLink( ... )
204        );
205        $extraFieldsInSearchResults = $this->config->get( CirrusConfigNames::ExtraFieldsInSearchResults );
206        if ( $extraFieldsInSearchResults ) {
207            $this->features[ self::EXTRA_FIELDS_TO_EXTRACT ] = $extraFieldsInSearchResults;
208        }
209    }
210
211    public function setConnection( Connection $connection ) {
212        $this->connection = $connection;
213    }
214
215    /**
216     * @return Connection
217     */
218    public function getConnection() {
219        return $this->connection;
220    }
221
222    /**
223     * Get search config
224     * @return SearchConfig
225     */
226    public function getConfig() {
227        return $this->config;
228    }
229
230    /**
231     * Override supports to shut off updates to Cirrus via the SearchEngine infrastructure.  Page
232     * updates and additions are chained on the end of the links update job.  Deletes are noticed
233     * via the ArticleDeleteComplete hook.
234     * @param string $feature feature name
235     * @return bool is this feature supported?
236     */
237    public function supports( $feature ) {
238        switch ( $feature ) {
239            case 'search-update':
240            case 'list-redirects':
241                return false;
242            case self::FT_QUERY_INDEP_PROFILE_TYPE:
243            case self::EXTRA_FIELDS_TO_EXTRACT:
244                return true;
245            default:
246                return parent::supports( $feature );
247        }
248    }
249
250    /**
251     * Overridden to delegate prefix searching to Searcher.
252     * @param string $term text to search
253     * @return Status Value is either SearchResultSet, or null on error.
254     */
255    protected function doSearchText( $term ) {
256        try {
257            $builder = SearchQueryBuilder::newFTSearchQueryBuilder( $this->config,
258                $term, $this->namespacePrefixParser, $this->getCirrusSearchHookRunner() );
259        } catch ( SearchQueryParseException $e ) {
260            return $e->asStatus();
261        }
262
263        $builder->setDebugOptions( $this->debugOptions )
264            ->setInitialNamespaces( $this->namespaces )
265            ->setLimit( $this->limit )
266            ->setOffset( $this->offset )
267            ->setSort( $this->getSort() )
268            ->setRandomSeed( $this->getFeatureData( 'random_seed' ) )
269            ->setExtraIndicesSearch( true )
270            ->setCrossProjectSearch( $this->isFeatureEnabled( 'interwiki' ) )
271            ->setWithDYMSuggestion( $this->showSuggestion )
272            ->setAllowRewrite( $this->isFeatureEnabled( 'rewrite' ) )
273            ->addProfileContextParameter( ContextualProfileOverride::LANGUAGE,
274                $this->requestContext->getLanguage()->getCode() )
275            ->setExtraFieldsToExtract( $this->features[self::EXTRA_FIELDS_TO_EXTRACT] ?? [] )
276            ->setProvideAllSnippets( !empty( $this->features['snippets'] ) );
277
278        if ( $this->prefix !== '' ) {
279            $builder->addContextualFilter( 'prefix',
280                \CirrusSearch\Query\PrefixFeature::asContextualFilter( $this->prefix ) );
281        }
282
283        $ftProfile = $this->extractProfileFromFeatureData( SearchEngine::FT_QUERY_DEP_PROFILE_TYPE );
284        if ( $ftProfile !== null ) {
285            $builder->addForcedProfile( SearchProfileService::FT_QUERY_BUILDER, $ftProfile );
286        }
287        $rescoreProfile = $this->extractProfileFromFeatureData( SearchEngine::FT_QUERY_INDEP_PROFILE_TYPE );
288        if ( $rescoreProfile !== null ) {
289            $builder->addForcedProfile( SearchProfileService::RESCORE, $rescoreProfile );
290        }
291
292        $query = $builder->build();
293
294        $status = $this->searchTextReal( $query );
295        $matches = $status->getValue();
296        if ( $matches instanceof CirrusSearchResultSet ) {
297            ElasticsearchIntermediary::setResultPages( [ $matches ] );
298        }
299        if ( $matches instanceof SearchMetricsProvider ) {
300            $this->extraSearchMetrics += $status->getValue()->getMetrics();
301        }
302
303        return $status;
304    }
305
306    /**
307     * @param string $feature
308     * @return bool
309     */
310    private function isFeatureEnabled( $feature ) {
311        return isset( $this->features[$feature] ) && $this->features[$feature];
312    }
313
314    /**
315     * Do the hard part of the searching - actual Searcher invocation
316     * @param SearchQuery $query
317     * @return Status
318     */
319    protected function searchTextReal( SearchQuery $query ) {
320        $searcher = $this->makeSearcher( $query->getSearchConfig() );
321        $status = $searcher->search( $query );
322        $this->lastSearchMetrics = $searcher->getSearchMetrics();
323        if ( !$status->isOK() ) {
324            return $status;
325        }
326
327        $result = $status->getValue();
328
329        // Add interwiki results, if we have a sane result
330        // Note that we have no way of sending warning back to the user.  In this case all warnings
331        // are logged when they are added to the status object so we just ignore them here....
332        // TODO: move this to the Searcher class and get rid of InterwikiSearcher
333        // there are no reasons we can't do this in a single msearch request.
334        if ( $query->getCrossSearchStrategy()->isCrossProjectSearchSupported() &&
335            $searcher->getSearchContext()->areResultsPossible() &&
336            $this->debugOptions->getCirrusExplainPage() === null &&
337            ( $this->debugOptions->isReturnRaw() || method_exists( $result, 'addInterwikiResults' ) )
338        ) {
339            $iwSearch = new InterwikiSearcher( $this->connection, $query->getSearchConfig(), $this->namespaces, null,
340                $this->debugOptions, $this->namespacePrefixParser, $this->interwikiResolver, $this->titleHelper,
341                $this->getCirrusSearchHookRunner() );
342            $interwikiResults = $iwSearch->getInterwikiResults( $query );
343            if ( $interwikiResults->isOK() && $interwikiResults->getValue() !== [] ) {
344                foreach ( $interwikiResults->getValue() as $interwiki => $interwikiResult ) {
345                    if ( $this->debugOptions->isReturnRaw() ) {
346                        $result[$interwiki] = $interwikiResult;
347                    } elseif ( $interwikiResult && $interwikiResult->numRows() > 0 ) {
348                        $result->addInterwikiResults(
349                            $interwikiResult, ISearchResultSet::SECONDARY_RESULTS, $interwiki
350                        );
351                    }
352                }
353            }
354        }
355
356        if ( $this->debugOptions->isReturnRaw() ) {
357            $status->setResult( true,
358                $searcher->processRawReturn( $result, $this->request ) );
359        }
360
361        return $status;
362    }
363
364    /**
365     * Look for suggestions using ES completion suggester.
366     * @param string $search Search string
367     * @param SearchConfig $config search configuration
368     * @return SearchSuggestionSet Set of suggested names
369     */
370    protected function getSuggestions( $search, SearchConfig $config ) {
371        // Inspect features to check if the user selected a specific profile
372        $profile = $this->extractProfileFromFeatureData( SearchEngine::COMPLETION_PROFILE_TYPE );
373
374        $clusterOverride = $config->getElement( CirrusConfigNames::ClusterOverrides, 'completion' );
375        if ( $clusterOverride !== null ) {
376            $connection = Connection::getPool( $config, $clusterOverride );
377        } else {
378            $connection = $this->connection;
379        }
380        $suggester = new CompletionSuggester( $connection, $this->limit,
381                $this->offset, $config, $this->namespaces, null,
382                false, $profile, $this->debugOptions );
383
384        $response = $suggester->suggest( $search );
385
386        if ( !$response->isOK() ) {
387            return SearchSuggestionSet::emptySuggestionSet();
388        }
389
390        $result = $response->getValue();
391
392        if ( $this->debugOptions->isReturnRaw() ) {
393            Util::processSearchRawReturn( $result, $this->request, $this->debugOptions );
394        }
395
396        // Errors will be logged, let's try the exact db match
397        return $result;
398    }
399
400    /**
401     * Get the sort of sorts we allow
402     * @return string[]
403     */
404    public function getValidSorts() {
405        $sorts = [
406            'relevance', 'just_match', 'none',
407            'incoming_links_asc', 'incoming_links_desc',
408            'last_edit_asc', 'last_edit_desc',
409            'create_timestamp_asc', 'create_timestamp_desc',
410            'random', 'user_random',
411        ];
412
413        if ( $this->config->getElement( CirrusConfigNames::NaturalTitleSort, 'use' ) ) {
414            $sorts[] = 'title_natural_asc';
415            $sorts[] = 'title_natural_desc';
416        }
417
418        return $sorts;
419    }
420
421    /**
422     * Get the metrics for the last search we performed. Null if we haven't done any.
423     * @return array
424     */
425    public function getLastSearchMetrics() {
426        return $this->lastSearchMetrics + $this->extraSearchMetrics;
427    }
428
429    /**
430     * Perform a completion search.
431     * Does not resolve namespaces and does not check variants.
432     * We use parent search for:
433     * - Special: namespace
434     * We use old prefix search for:
435     * - Suggester not enabled
436     * -
437     * @param string $search
438     * @return SearchSuggestionSet
439     */
440    protected function completionSearchBackend( $search ) {
441        if ( in_array( NS_SPECIAL, $this->namespaces ) ) {
442            // delegate special search to parent
443            return parent::completionSearchBackend( $search );
444        }
445
446        // fallback to classic prefix search if the completion suggester is not enabled or if NS_MAIN
447        // is not requested
448        if ( !$this->config->isCompletionSuggesterEnabled() || !in_array( NS_MAIN, $this->namespaces ) ) {
449            $profile = self::COMPLETION_PREFIX_FALLBACK_PROFILE;
450        } else {
451            $profile = $this->extractProfileFromFeatureData( SearchEngine::COMPLETION_PROFILE_TYPE );
452            if ( $profile === null ) {
453                // Need to fetch the name to fallback to prefix (not ideal)
454                // We should probably refactor this to have a single code path for prefix and completion suggester.
455                $profile = $this->config->getProfileService()
456                    ->getProfileName( SearchProfileService::COMPLETION, SearchProfileService::CONTEXT_COMPLETION );
457            }
458        }
459
460        if ( $profile === self::COMPLETION_PREFIX_FALLBACK_PROFILE ) {
461            // Fallback to prefixsearch if the classic profile was selected.
462            return $this->prefixSearch( $search );
463        }
464
465        return $this->getSuggestions( $search, $this->config );
466    }
467
468    /**
469     * Override variants function because we always do variants
470     * in the backend.
471     * @see SearchEngine::completionSearchWithVariants()
472     * @param string $search
473     * @return SearchSuggestionSet
474     */
475    public function completionSearchWithVariants( $search ) {
476        return $this->completionSearch( $search );
477    }
478
479    /**
480     * Older prefix search.
481     * @param string $search search text
482     * @return SearchSuggestionSet
483     */
484    protected function prefixSearch( $search ) {
485        $searcher = $this->makeSearcher();
486        $secondTryRunner = $this->secondTryRunnerFactory->create( SearchProfileService::CONTEXT_COMPLETION );
487
488        if ( $search ) {
489            $searcher->setResultsType( new FancyTitleResultsType( 'prefix' ) );
490        } else {
491            // Empty searches always find the title.
492            $searcher->setResultsType( new TitleResultsType() );
493        }
494
495        $status = $searcher->prefixSearch( $search, $secondTryRunner );
496
497        // There is no way to send errors or warnings back to the caller here so we have to make do with
498        // only sending results back if there are results and relying on the logging done at the status
499        // construction site to log errors.
500        if ( $status->isOK() ) {
501            if ( $this->debugOptions->isReturnRaw() ) {
502                Util::processSearchRawReturn( $status->getValue(), $this->request,
503                    $this->debugOptions );
504            }
505            if ( !$search ) {
506                // No need to unpack the simple title matches from non-fancy TitleResultsType
507                return SearchSuggestionSet::fromTitles( $status->getValue() );
508            }
509            $results = array_filter( array_map(
510                FancyTitleResultsType::chooseBestTitleOrRedirect( ... ),
511                $status->getValue() ) );
512            return SearchSuggestionSet::fromTitles( $results );
513        }
514
515        return SearchSuggestionSet::emptySuggestionSet();
516    }
517
518    /**
519     * @param string $profileType
520     * @param User|null $user
521     * @return array|null
522     * @see SearchEngine::getProfiles()
523     */
524    public function getProfiles( $profileType, ?User $user = null ) {
525        $profileService = $this->config->getProfileService();
526        $serviceProfileType = null;
527        switch ( $profileType ) {
528            case SearchEngine::COMPLETION_PROFILE_TYPE:
529                if ( $this->config->isCompletionSuggesterEnabled() ) {
530                    $serviceProfileType = SearchProfileService::COMPLETION;
531                }
532                break;
533            case SearchEngine::FT_QUERY_INDEP_PROFILE_TYPE:
534                $serviceProfileType = SearchProfileService::RESCORE;
535                break;
536            case SearchEngine::FT_QUERY_DEP_PROFILE_TYPE:
537                $serviceProfileType = SearchProfileService::FT_QUERY_BUILDER;
538                break;
539        }
540
541        if ( $serviceProfileType === null ) {
542            return null;
543        }
544
545        $allowedProfiles = $profileService->listExposedProfiles( $serviceProfileType );
546
547        $profiles = [];
548        foreach ( $allowedProfiles as $name => $profile ) {
549            // @todo: decide what to with profiles we declare
550            // in wmf-config with no i18n messages.
551            // Do we want to expose them anyway, or simply
552            // hide them but still allow Api to pass them to us.
553            // It may require a change in core since ApiBase is
554            // strict and won't allow unknown values to be set
555            // here.
556            $profiles[] = [
557                'name' => $name,
558                'desc-message' => $profile['i18n_msg'] ?? null,
559            ];
560        }
561        if ( $profiles !== [] ) {
562            $profiles[] = [
563                'name' => self::AUTOSELECT_PROFILE,
564                'desc-message' => 'cirrussearch-autoselect-profile',
565                'default' => true,
566            ];
567        }
568        return $profiles;
569    }
570
571    /**
572     * (public for testing purposes)
573     * @param string $profileType
574     * @return string|null the profile name set in SearchEngine::features
575     * null if none present or equal to self::AUTOSELECT_PROFILE
576     */
577    public function extractProfileFromFeatureData( $profileType ) {
578        if ( isset( $this->features[$profileType] )
579            && $this->features[$profileType] !== self::AUTOSELECT_PROFILE
580        ) {
581            return $this->features[$profileType];
582        }
583        return null;
584    }
585
586    /**
587     * Create a search field definition
588     * @param string $name
589     * @param string $type
590     * @return SearchIndexField
591     */
592    public function makeSearchFieldMapping( $name, $type ): SearchIndexField {
593        return $this->searchIndexFieldFactory->makeSearchFieldMapping( $name, $type );
594    }
595
596    /**
597     * Perform a title search in the article archive.
598     *
599     * @param string $term Raw search term
600     * @return Status<Title[]>
601     */
602    public function searchArchiveTitle( $term ) {
603        if ( !$this->config->get( CirrusConfigNames::EnableArchive ) ) {
604            return Status::newGood( [] );
605        }
606
607        $term = trim( $term );
608
609        if ( $term === '' ) {
610            return Status::newGood( [] );
611        }
612
613        $searcher = $this->makeSearcher();
614        $status = $searcher->searchArchive( $term );
615        if ( $status->isOK() && $searcher->isReturnRaw() ) {
616            $status->setResult( true,
617                $searcher->processRawReturn( $status->getValue(), $this->request ) );
618        }
619        return $status;
620    }
621
622    /**
623     * Helper method to facilitate mocking during tests.
624     * @return Updater
625     */
626    protected function getUpdater(): Updater {
627        return new Updater( $this->connection );
628    }
629
630    /**
631     * @return Status Contains a single integer indicating the number
632     *  of content words in the wiki
633     */
634    public function countContentWords() {
635        $this->limit = 1;
636        $searcher = $this->makeSearcher();
637        $status = $searcher->countContentWords();
638
639        if ( $status->isOK() && $searcher->isReturnRaw() ) {
640            $status->setResult( true,
641                $searcher->processRawReturn( $status->getValue(), $this->request ) );
642        }
643        return $status;
644    }
645
646    /**
647     * @param SearchConfig|null $config
648     * @return Searcher
649     */
650    private function makeSearcher( ?SearchConfig $config = null ) {
651        return new Searcher( $this->connection, $this->offset, $this->limit, $config ?? $this->config, $this->namespaces,
652                null, false, $this->debugOptions, $this->namespacePrefixParser, $this->interwikiResolver, $this->titleHelper,
653                $this->getCirrusSearchHookRunner() );
654    }
655
656    private function getCirrusSearchHookRunner(): CirrusSearchHookRunner {
657        if ( $this->cirrusSearchHookRunner == null ) {
658            $this->cirrusSearchHookRunner = new CirrusSearchHookRunner( $this->getHookContainer() );
659        }
660        return $this->cirrusSearchHookRunner;
661    }
662}