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