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