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