Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.62% covered (success)
90.62%
87 / 96
69.23% covered (warning)
69.23%
18 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchProfileService
90.62% covered (success)
90.62%
87 / 96
69.23% covered (warning)
69.23%
18 / 26
56.40
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 hasProfile
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 supportsContext
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 loadProfileByName
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 loadProfile
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 getProfileName
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
8.02
 registerRepository
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
2.26
 registerArrayRepository
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 registerFileRepository
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 listExposedProfiles
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 registerDefaultProfile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 registerProfileOverride
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 registerConfigOverride
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 registerUriParamOverride
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 registerUserPrefOverride
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 registerContextualOverride
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 registerSearchQueryRoute
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 registerSemanticSearchQueryRoute
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 registerFTSearchQueryRoute
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getDispatchService
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 freeze
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkFrozen
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 listProfileTypes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 listProfileContexts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 listProfileRepositories
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 listProfileOverrides
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace CirrusSearch\Profile;
4
5use CirrusSearch\BuildDocument\DocumentSizeLimiter;
6use CirrusSearch\CirrusDebugOptions;
7use CirrusSearch\Dispatch\BasicSearchQueryRoute;
8use CirrusSearch\Dispatch\CirrusDefaultSearchQueryRoute;
9use CirrusSearch\Dispatch\DefaultSearchQueryDispatchService;
10use CirrusSearch\Dispatch\SearchQueryDispatchService;
11use CirrusSearch\Dispatch\SearchQueryRoute;
12use CirrusSearch\Dispatch\SemanticSearchQueryRoute;
13use CirrusSearch\Search\SearchQuery;
14use MediaWiki\Config\Config;
15use MediaWiki\Context\RequestContext;
16use MediaWiki\Request\WebRequest;
17use MediaWiki\User\Options\UserOptionsLookup;
18use MediaWiki\User\UserIdentity;
19use Wikimedia\Assert\Assert;
20
21/**
22 * Service to manage and access search profiles.
23 * Search profiles are arranged by type identified by a string constant:
24 * - COMPLETION: profiles used for autocomplete search when running the completion suggester
25 * - CROSS_PROJECT_BLOCK_SCORER: used when reordering blocks of crossproject search results
26 * - FT_QUERY_BUILDER: used when building fulltext search queries
27 * - PHRASE_SUGGESTER: Controls the behavior of the phrase suggester (did you mean suggestions)
28 * - INDEX_LOOKUP_FALLBACK: Controls the behavior of the index lookup fallback method (did you mean suggestions)
29 * - RESCORE: Controls how elasticsearch rescore queries are built
30 * - RESCORE_FUNCTION_CHAINS: Controls the list of functions used by a rescore profile
31 * - SANEITIZER: Controls the saneitizer
32 * - SIMILARITY: Defines similarity profiles used when building the index
33 *
34 * Multiple repository per type can be declared, in general we have:
35 * - the cirrus_base repository holding the default profiles contained in cirrus code
36 * - the cirrus_config repository holding the profiles customized using $wgCirrusSearch config vars.
37 *
38 * The service is bound to a SearchConfig instance which means that the profiles may vary depending
39 * on the SearchConfig being used. The cirrus_base repository will always hold the same set of
40 * profiles but the cirrus_config may change according to SearchConfig content.
41 *
42 * The service is also responsible for determining the name of the default profile for a given context.
43 * The profile context is a notion introduced to allow using the same profile for multiple purposes.
44 * For example the rescore profiles may be used for different kind of queries (fulltext vs prefixsearch).
45 * While they share the same set of profiles we may prefer to use different defaults depending on the
46 * type of the query. The profile context allows to distinguish between these use cases.
47 *
48 * Then in order to customize the default profile the service allows to define a list of "overriders":
49 * - ConfigSearchProfileOverride: overrides the default profile by reading a config var
50 * - UriParamSearchProfileOverride: overrides the default profile by inspecting the URI params
51 * - UserPrefSearchProfileOverride: overrides the default profile by inspecting the user prefs
52 */
53class SearchProfileService {
54
55    /**
56     * Profile type for ordering crossproject result blocks
57     */
58    public const CROSS_PROJECT_BLOCK_SCORER = 'crossproject_block_scorer';
59
60    /**
61     * Profile type for similarity configuration
62     * Used when building the indices
63     */
64    public const SIMILARITY = 'similarity';
65
66    /**
67     * Profile type for rescoring components
68     * Used at query when building elastic queries
69     * @see \CirrusSearch\Search\Rescore\RescoreBuilder
70     */
71    public const RESCORE = 'rescore';
72
73    /**
74     * Profile type used to build function chains
75     * Used at query time by rescore builders
76     * @see \CirrusSearch\Search\Rescore\RescoreBuilder
77     */
78    public const RESCORE_FUNCTION_CHAINS = 'rescore_function_chains';
79
80    /**
81     * Profile type used by the completion suggester
82     * @see \CirrusSearch\CompletionSuggester
83     */
84    public const COMPLETION = 'completion';
85
86    /**
87     * Profile type used by the phrase suggester (fulltext search only)
88     * @see \CirrusSearch\Fallbacks\PhraseSuggestFallbackMethod
89     */
90    public const PHRASE_SUGGESTER = 'phrase_suggester';
91
92    /**
93     * Profile type used by the index lookup fallback method method
94     * @see \CirrusSearch\Fallbacks\IndexLookupFallbackMethod
95     */
96    public const INDEX_LOOKUP_FALLBACK = 'index_lookup_fallback';
97
98    /**
99     * Profile type used by saneitizer
100     * @see \CirrusSearch\Maintenance\SaneitizeJobs
101     */
102    public const SANEITIZER = 'saneitizer';
103
104    /**
105     * Profile type used by the document size limiter
106     * @see DocumentSizeLimiter
107     */
108    public const DOCUMENT_SIZE_LIMITER = 'document_size_limiter';
109
110    /**
111     * Profiles used for building fulltext search queries
112     * @see \CirrusSearch\Search\SearchContext::getFulltextQueryBuilderProfile()
113     */
114    public const FT_QUERY_BUILDER = 'ft_query_builder';
115
116    /**
117     * Profile type used by FallbackRunner.
118     * @see \CirrusSearch\Fallbacks\FallbackRunner::create()
119     */
120    public const FALLBACKS = 'fallbacks';
121
122    /**
123     * Profile type used to build second try searches.
124     * @see \CirrusSearch\SecondTry\SecondTrySearch
125     */
126    public const SECOND_TRY = 'second_try';
127
128    /**
129     * Profile type used to build second try searches.
130     * @see \CirrusSearch\NamespaceMatcher
131     */
132    public const NAMESPACE_MATCHER = 'namespace_matcher';
133
134    /**
135     * Profile context used for prefix search queries
136     */
137    public const CONTEXT_PREFIXSEARCH = 'prefixsearch';
138
139    /**
140     * Default profile context (used by fulltext queries)
141     */
142    public const CONTEXT_DEFAULT = 'default';
143
144    /**
145     * Profile context used for completion (fuzzy autocomplete) queries.
146     */
147    public const CONTEXT_COMPLETION = 'completion';
148
149    /**
150     * Profile context used for semantic queries
151     */
152    public const CONTEXT_SEMANTIC = 'semantic';
153
154    /**
155     * List of profile repositories, grouped by type and then by repository name.
156     * @var SearchProfileRepository[][]
157     */
158    private $repositories = [];
159
160    /**
161     * List of default profile names to use for a given type in a given context
162     * Key path is [type][context]
163     * @var string[][]
164     */
165    private $defaultProfiles = [];
166
167    /**
168     * list of overriders, $this->overriders[$type][$context] is an array of SearchProfileOverride
169     * Key path is [type][context]
170     * @var SearchProfileOverride[][][]
171     */
172    private $overriders = [];
173
174    /**
175     * @var UserIdentity
176     */
177    private $user;
178
179    /**
180     * @var WebRequest
181     */
182    private $request;
183
184    /**
185     * @var bool
186     */
187    private $frozen;
188
189    /**
190     * @var SearchQueryDispatchService|null (lazy loaded)
191     */
192    private $dispatchService;
193
194    /**
195     * @var SearchQueryRoute[][]
196     */
197    private $routes;
198
199    /**
200     * @var UserOptionsLookup
201     */
202    private $userOptionsLookup;
203
204    /**
205     * @param UserOptionsLookup $userOptionsLookup
206     * @param WebRequest|null $request obtained from \RequestContext::getMain()->getRequest() if null
207     * @param UserIdentity|null $user obtained from \RequestContext::getMain()->getUser() if null
208     */
209    public function __construct(
210        UserOptionsLookup $userOptionsLookup,
211        ?WebRequest $request = null,
212        ?UserIdentity $user = null
213    ) {
214        $this->userOptionsLookup = $userOptionsLookup;
215        $this->request = $request ?? RequestContext::getMain()->getRequest();
216        $this->user = $user ?? RequestContext::getMain()->getUser();
217        $this->routes = [
218            'searchText' => [ CirrusDefaultSearchQueryRoute::searchTextDefaultRoute() ]
219        ];
220    }
221
222    /**
223     * @param string $type
224     * @param string $name
225     * @return bool
226     */
227    public function hasProfile( $type, $name ) {
228        if ( isset( $this->repositories[$type] ) ) {
229            foreach ( $this->repositories[$type] as $repo ) {
230                if ( $repo->hasProfile( $name ) ) {
231                    return true;
232                }
233            }
234        }
235        return false;
236    }
237
238    /**
239     * @param string $type
240     * @param string $context
241     * @return bool
242     */
243    public function supportsContext( $type, $context ) {
244        return isset( $this->defaultProfiles[$type][$context] );
245    }
246
247    /**
248     * Load a profile by its name.
249     * It's better to use self::loadProfile and let the service
250     * determine the proper profile to use in a given context.
251     *
252     * @param string $type the type of the profile (see class doc)
253     * @param string $name
254     * @param bool $failIfMissing when true will throw SearchProfileException
255     * @return array|null
256     */
257    public function loadProfileByName( $type, $name, $failIfMissing = true ) {
258        if ( isset( $this->repositories[$type] ) ) {
259            $repos = $this->repositories[$type];
260            foreach ( $repos as $repo ) {
261                $prof = $repo->getProfile( $name );
262                if ( $prof !== null ) {
263                    return $prof;
264                }
265            }
266        }
267        if ( $failIfMissing ) {
268            throw new SearchProfileException( "Cannot load a profile type $type$name not found" );
269        }
270        return null;
271    }
272
273    /**
274     * Load a profile for the context or by its name if name is provided
275     *
276     * @param string $type
277     * @param string $context used to determine the name of the profile if $name is not provided
278     * @param string|null $name force the name of the profile to use
279     * @param string[] $contextParams Parameters of the context, for determining the profile
280     *  name. Some overriders use these to decide if an override is appropriate.
281     * @return array
282     * @see self::getProfileName()
283     */
284    public function loadProfile( $type, $context = self::CONTEXT_DEFAULT, $name = null, $contextParams = [] ) {
285        if ( $name === null && $context === null ) {
286            throw new SearchProfileException( '$name and $context cannot be both null' );
287        }
288        $name ??= $this->getProfileName( $type, $context, $contextParams );
289        return $this->loadProfileByName( $type, $name );
290    }
291
292    /**
293     * @param string $type the type of the profile (see class doc)
294     * @param string $context
295     * @param string[] $contextParams Parameters of the context, for determining the profile
296     *  name. Some overriders use these to decide if an override is appropriate.
297     * @return string
298     */
299    public function getProfileName( $type, $context = self::CONTEXT_DEFAULT, array $contextParams = [] ) {
300        $minPrio = PHP_INT_MAX;
301        if ( !isset( $this->defaultProfiles[$type][$context] ) ) {
302            throw new SearchProfileException( "No default profile found for $type in context $context" );
303        }
304        $profile = $this->defaultProfiles[$type][$context];
305        if ( !$this->hasProfile( $type, $profile ) ) {
306            throw new SearchProfileException( "The default profile $profile does not exist in profile repositories of type $type" );
307        }
308
309        if ( !isset( $this->overriders[$type][$context] ) ) {
310            return $profile;
311        }
312
313        foreach ( $this->overriders[$type][$context] as $overrider ) {
314            if ( $overrider->priority() < $minPrio ) {
315                $name = $overrider->getOverriddenName( $contextParams );
316                if ( $name !== null && $this->hasProfile( $type, $name ) ) {
317                    $minPrio = $overrider->priority();
318                    $profile = $name;
319                }
320            }
321        }
322        return $profile;
323    }
324
325    /**
326     * Register a new profile repository
327     */
328    public function registerRepository( SearchProfileRepository $repository ) {
329        $this->checkFrozen();
330        if ( isset( $this->repositories[$repository->repositoryType()][$repository->repositoryName()] ) ) {
331            throw new SearchProfileException( "A profile repository type {$repository->repositoryType()} " .
332                "named {$repository->repositoryName()} is already registered." );
333        }
334        $this->repositories[$repository->repositoryType()][$repository->repositoryName()] = $repository;
335    }
336
337    /**
338     * Register a new repository backed by a simple array
339     * @param string $repoType
340     * @param string $repoName
341     * @param array $profiles
342     */
343    public function registerArrayRepository( $repoType, $repoName, array $profiles ) {
344        $this->registerRepository( ArrayProfileRepository::fromArray( $repoType, $repoName, $profiles ) );
345    }
346
347    /**
348     * Register a new repository backed by a PHP file returning an array.
349     *
350     * <b>NOTE:</b> $phpFile is loaded with PHP's require keyword.
351     *
352     * @param string $type
353     * @param string $name
354     * @param string $phpFile
355     * @see FileProfileRepository
356     */
357    public function registerFileRepository( $type, $name, $phpFile ) {
358        $this->registerRepository( ArrayProfileRepository::fromFile( $type, $name, $phpFile ) );
359    }
360
361    /**
362     * List profiles under type $type that are suited
363     * to be exposed to the users.
364     *
365     * This method is provided for convenience and to help
366     * users to discover existing profile.
367     * It's possible that an existing profile may not be listed here
368     * so this method must not be used to verify the existence of a given
369     * profile. Use hasProfile instead.
370     *
371     * @param string $type
372     * @return array
373     */
374    public function listExposedProfiles( $type ) {
375        $profiles = [];
376        if ( isset( $this->repositories[$type] ) ) {
377            foreach ( $this->repositories[$type] as $repo ) {
378                foreach ( $repo->listExposedProfiles() as $name => $profile ) {
379                    if ( $profile['undocumented'] ?? false ) {
380                        // Undocumented profiles are selected either internally or
381                        // via CirrusDebugOptions.
382                        continue;
383                    }
384                    if ( !isset( $profiles[$name] ) ) {
385                        $profiles[$name] = $profile;
386                    }
387                }
388            }
389        }
390        return $profiles;
391    }
392
393    /**
394     * Register a default profile named $profileName for $type in context $profileContext
395     * It must be an existing profile otherwise it will always fail when trying to determine
396     * the profile name.
397     * @param string $type
398     * @param string $profileContext
399     * @param string $profileName
400     */
401    public function registerDefaultProfile( $type, $profileContext, $profileName ) {
402        if ( isset( $this->defaultProfiles[$type][$profileContext] ) ) {
403            throw new SearchProfileException( "A default profile already exists for $type in context $profileContext" );
404        }
405        $this->defaultProfiles[$type][$profileContext] = $profileName;
406    }
407
408    /**
409     * Register a new profile overrider.
410     * It allows to override the default profile based on the implementation of SearchProfileOverride.
411     * @param string $type
412     * @param string|string[] $profileContext one or multiple contexts
413     * @param SearchProfileOverride $override
414     */
415    public function registerProfileOverride( $type, $profileContext, SearchProfileOverride $override ) {
416        $this->checkFrozen();
417        if ( !is_array( $profileContext ) ) {
418            $profileContext = [ $profileContext ];
419        }
420        foreach ( $profileContext as $context ) {
421            $this->overriders[$type][$context][] = $override;
422        }
423    }
424
425    /**
426     * Register a new overrider using the ConfigSearchProfileOverride implementation
427     * @param string $type
428     * @param string|string[] $profileContext one or multiple contexts
429     * @param Config $config
430     * @param string $configEntry
431     * @see ConfigSearchProfileOverride
432     */
433    public function registerConfigOverride( $type, $profileContext, Config $config, $configEntry ) {
434        $this->registerProfileOverride( $type, $profileContext, new ConfigSearchProfileOverride( $config, $configEntry ) );
435    }
436
437    /**
438     * @param string $type
439     * @param string|string[] $profileContext one or multiple contexts
440     * @param string $uriParam
441     */
442    public function registerUriParamOverride( $type, $profileContext, $uriParam ) {
443        $this->registerProfileOverride( $type, $profileContext, new UriParamSearchProfileOverride( $this->request, $uriParam ) );
444    }
445
446    /**
447     * @param string $type
448     * @param string|string[] $profileContext one or multiple contexts
449     * @param string $userPref the name of the key used to store this user preference
450     */
451    public function registerUserPrefOverride( $type, $profileContext, $userPref ) {
452        $this->registerProfileOverride(
453            $type,
454            $profileContext,
455            new UserPrefSearchProfileOverride( $this->user, $this->userOptionsLookup, $userPref ) );
456    }
457
458    /**
459     * @param string $type
460     * @param string|string[] $profileContext one or multiple contexts
461     * @param string $template A templated profile name
462     * @param string[] $params Map from string in $template to context parameter to
463     *  replace with. All params must be available in the context parameters or
464     *  no override will be applied.
465     */
466    public function registerContextualOverride( $type, $profileContext, $template, array $params ) {
467        $this->registerProfileOverride( $type, $profileContext, new ContextualProfileOverride( $template, $params ) );
468    }
469
470    /**
471     * Register a new route to be used by the SearchQueryDispatchService
472     *
473     * @param SearchQueryRoute $route
474     * @see SearchQueryDispatchService
475     * @see SearchProfileService::getDispatchService()
476     */
477    public function registerSearchQueryRoute( SearchQueryRoute $route ) {
478        $this->checkFrozen();
479        if ( !isset( $this->routes[$route->getSearchEngineEntryPoint()] ) ) {
480            throw new SearchProfileException( "Unsupported search engine entry point {$route->getSearchEngineEntryPoint()}" );
481        }
482        $this->routes[$route->getSearchEngineEntryPoint()][] = $route;
483    }
484
485    /**
486     * Register a new static route for semantic search queries
487     *
488     * @param int[] $supportedNamespaces
489     * @param float $score score of the route
490     * @see SearchProfileService::getDispatchService()
491     * @see SearchQueryDispatchService::CIRRUS_DEFAULTS_SCORE
492     */
493    public function registerSemanticSearchQueryRoute( array $supportedNamespaces, float $score ) {
494        $debugOptions = CirrusDebugOptions::fromRequest( $this->request );
495        $this->registerSearchQueryRoute( new SemanticSearchQueryRoute(
496            SearchQuery::SEARCH_TEXT, $debugOptions, $supportedNamespaces, $score ) );
497    }
498
499    /**
500     * Register a new static route for fulltext search queries.
501     *
502     * @param string $profileContext
503     * @param float $score score of the route
504     * @param int[] $supportedNamespaces
505     * @param string[] $acceptableQueryClasses
506     * @see SearchProfileService::getDispatchService()
507     * @see SearchQueryDispatchService::CIRRUS_DEFAULTS_SCORE
508     */
509    public function registerFTSearchQueryRoute(
510        $profileContext,
511        $score,
512        array $supportedNamespaces,
513        array $acceptableQueryClasses = []
514    ) {
515        Assert::parameter( $score > SearchQueryDispatchService::CIRRUS_DEFAULTS_SCORE, '$score',
516            "This route will never be selected it must " .
517            "be greater than " . SearchQueryDispatchService::CIRRUS_DEFAULTS_SCORE
518        );
519        $this->registerSearchQueryRoute( new BasicSearchQueryRoute( SearchQuery::SEARCH_TEXT,
520            $supportedNamespaces, $acceptableQueryClasses, $profileContext, $score ) );
521    }
522
523    /**
524     * Return the service responsible for dispatching a SearchQuery
525     * to its preferred profile context.
526     */
527    public function getDispatchService(): SearchQueryDispatchService {
528        if ( $this->dispatchService === null ) {
529            Assert::precondition( $this->frozen,
530                "Must be frozen when accessing the SearchQuery dispatch service." );
531            $this->dispatchService = new DefaultSearchQueryDispatchService( $this->routes );
532        }
533        return $this->dispatchService;
534    }
535
536    /**
537     * Freeze the service, any attempt to declare a new repository
538     * will fail.
539     */
540    public function freeze() {
541        $this->frozen = true;
542    }
543
544    private function checkFrozen() {
545        if ( $this->frozen ) {
546            throw new SearchProfileException( self::class . " is frozen, you cannot register new repositories/overriders." );
547        }
548    }
549
550    /**
551     * @return string[]
552     */
553    public function listProfileTypes() {
554        return array_keys( $this->repositories );
555    }
556
557    /**
558     * List default profile per context
559     * @param string $type
560     * @return string[] context is the key, the default profile name
561     */
562    public function listProfileContexts( $type ) {
563        return $this->defaultProfiles[$type] ?? [];
564    }
565
566    /**
567     * List profile repositories
568     * @param string $type
569     * @return SearchProfileRepository[]
570     */
571    public function listProfileRepositories( $type ) {
572        return $this->repositories[$type] ?? [];
573    }
574
575    /**
576     * L
577     * @param string $type
578     * @param string $context
579     * @return SearchProfileOverride[]
580     */
581    public function listProfileOverrides( $type, $context ) {
582        return $this->overriders[$type][$context] ?? [];
583    }
584}