Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
26.55% |
60 / 226 |
|
16.67% |
4 / 24 |
CRAP | |
0.00% |
0 / 1 |
CirrusSearch | |
26.55% |
60 / 226 |
|
16.67% |
4 / 24 |
3086.42 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
30 | |||
setConnection | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getConnection | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getConfig | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
supports | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
6.10 | |||
doSearchText | |
12.50% |
4 / 32 |
|
0.00% |
0 / 1 |
30.12 | |||
isFeatureEnabled | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
searchTextReal | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
182 | |||
getSuggestions | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
20 | |||
getValidSorts | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
getLastSearchMetrics | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
completionSearchBackend | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
72 | |||
completionSearchWithVariants | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
prefixSearch | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
30 | |||
getProfiles | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
7 | |||
extractProfileFromFeatureData | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
makeSearchFieldMapping | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
searchArchiveTitle | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
30 | |||
updateWeightedTags | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
8 | |||
resetWeightedTags | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getUpdater | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
countContentWords | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
makeSearcher | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getCirrusSearchHookRunner | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | namespace CirrusSearch; |
4 | |
5 | use CirrusSearch\Parser\NamespacePrefixParser; |
6 | use CirrusSearch\Parser\QueryStringRegex\SearchQueryParseException; |
7 | use CirrusSearch\Profile\ContextualProfileOverride; |
8 | use CirrusSearch\Profile\SearchProfileService; |
9 | use CirrusSearch\Search\ArrayCirrusSearchResult; |
10 | use CirrusSearch\Search\CirrusSearchIndexFieldFactory; |
11 | use CirrusSearch\Search\CirrusSearchResultSet; |
12 | use CirrusSearch\Search\FancyTitleResultsType; |
13 | use CirrusSearch\Search\SearchMetricsProvider; |
14 | use CirrusSearch\Search\SearchQuery; |
15 | use CirrusSearch\Search\SearchQueryBuilder; |
16 | use CirrusSearch\Search\TitleHelper; |
17 | use CirrusSearch\Search\TitleResultsType; |
18 | use CirrusSearch\Wikimedia\WeightedTagsHooks; |
19 | use ISearchResultSet; |
20 | use MediaWiki\MediaWikiServices; |
21 | use MediaWiki\Page\ProperPageIdentity; |
22 | use MediaWiki\Parser\Sanitizer; |
23 | use MediaWiki\Request\WebRequest; |
24 | use MediaWiki\Status\Status; |
25 | use MediaWiki\Title\Title; |
26 | use MediaWiki\User\User; |
27 | use MediaWiki\WikiMap\WikiMap; |
28 | use RequestContext; |
29 | use SearchEngine; |
30 | use SearchIndexField; |
31 | use SearchSuggestionSet; |
32 | use 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 | */ |
55 | class 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 | } |