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