Include completion search into SearchEngine

By default it still uses PrefixSearch and supports PrefixSearchBackend
but it can be deprecated and phased out and SearchEngine extensions used
instead.

New APIs:
- SearchEngine
	public function defaultPrefixSearch( $search );
	public function completionSearch( $search );
	public function completionSearchWithVariants( $search );

Search engines should override:
protected function completionSearchBackend( $search );

Bug: T121430
Change-Id: Ie78649591dff94d21b72fad8e4e5eab010a461df
This commit is contained in:
Stanislav Malyshev 2016-01-26 13:18:27 -08:00 committed by EBernhardson
parent 8aa6c9f43e
commit 027972a20f
19 changed files with 1099 additions and 82 deletions

View file

@ -1120,6 +1120,8 @@ $wgAutoloadLocalClasses = array(
'SearchResult' => __DIR__ . '/includes/search/SearchResult.php',
'SearchResultSet' => __DIR__ . '/includes/search/SearchResultSet.php',
'SearchSqlite' => __DIR__ . '/includes/search/SearchSqlite.php',
'SearchSuggestion' => __DIR__ . '/includes/search/SearchSuggestion.php',
'SearchSuggestionSet' => __DIR__ . '/includes/search/SearchSuggestionSet.php',
'SearchUpdate' => __DIR__ . '/includes/deferred/SearchUpdate.php',
'SectionProfileCallback' => __DIR__ . '/includes/profiler/SectionProfiler.php',
'SectionProfiler' => __DIR__ . '/includes/profiler/SectionProfiler.php',

View file

@ -23,6 +23,7 @@
/**
* Handles searching prefixes of titles and finding any page
* names that match. Used largely by the OpenSearch implementation.
* @deprecated Since 1.27, Use SearchEngine::prefixSearchSubpages or SearchEngine::completionSearch
*
* @ingroup Search
*/
@ -259,14 +260,17 @@ abstract class PrefixSearch {
* @param int $offset Number of items to skip
* @return array Array of Title objects
*/
protected function defaultSearchBackend( $namespaces, $search, $limit, $offset ) {
public function defaultSearchBackend( $namespaces, $search, $limit, $offset ) {
$ns = array_shift( $namespaces ); // support only one namespace
if ( in_array( NS_MAIN, $namespaces ) ) {
if ( is_null( $ns ) || in_array( NS_MAIN, $namespaces ) ) {
$ns = NS_MAIN; // if searching on many always default to main
}
$t = Title::newFromText( $search, $ns );
if ( $ns == NS_SPECIAL ) {
return $this->specialSearch( $search, $limit, $offset );
}
$t = Title::newFromText( $search, $ns );
$prefix = $t ? $t->getDBkey() : '';
$dbr = wfGetDB( DB_SLAVE );
$res = $dbr->select( 'page',
@ -318,6 +322,7 @@ abstract class PrefixSearch {
/**
* Performs prefix search, returning Title objects
* @deprecated Since 1.27, Use SearchEngine::prefixSearchSubpages or SearchEngine::completionSearch
* @ingroup Search
*/
class TitlePrefixSearch extends PrefixSearch {
@ -337,6 +342,7 @@ class TitlePrefixSearch extends PrefixSearch {
/**
* Performs prefix search, returning strings
* @deprecated Since 1.27, Use SearchEngine::prefixSearchSubpages or SearchEngine::completionSearch
* @ingroup Search
*/
class StringPrefixSearch extends PrefixSearch {

View file

@ -123,9 +123,12 @@ class ApiOpenSearch extends ApiBase {
* @param array &$results Put results here. Keys have to be integers.
*/
protected function search( $search, $limit, $namespaces, $resolveRedir, &$results ) {
// Find matching titles as Title objects
$searcher = new TitlePrefixSearch;
$titles = $searcher->searchWithVariants( $search, $limit, $namespaces );
$searchEngine = SearchEngine::create();
$searchEngine->setLimitOffset( $limit );
$searchEngine->setNamespaces( $namespaces );
$titles = $searchEngine->extractTitles( $searchEngine->completionSearchWithVariants( $search ) );
if ( !$titles ) {
return;
}

View file

@ -45,8 +45,11 @@ class ApiQueryPrefixSearch extends ApiQueryGeneratorBase {
$namespaces = $params['namespace'];
$offset = $params['offset'];
$searcher = new TitlePrefixSearch;
$titles = $searcher->searchWithVariants( $search, $limit + 1, $namespaces, $offset );
$searchEngine = SearchEngine::create();
$searchEngine->setLimitOffset( $limit + 1, $offset );
$searchEngine->setNamespaces( $namespaces );
$titles = $searchEngine->extractTitles( $searchEngine->completionSearchWithVariants( $search ) );
if ( $resultPageSet ) {
$resultPageSet->setRedirectMergePolicy( function( array $current, array $new ) {
if ( !isset( $current['index'] ) || $new['index'] < $current['index'] ) {

View file

@ -296,6 +296,15 @@ class SearchEngine {
* @param int[]|null $namespaces
*/
function setNamespaces( $namespaces ) {
if ( $namespaces ) {
// Filter namespaces to only keep valid ones
$validNs = $this->searchableNamespaces();
$namespaces = array_filter( $namespaces, function( $ns ) use( $validNs ) {
return $ns < 0 || isset( $validNs[$ns] );
} );
} else {
$namespaces = array();
}
$this->namespaces = $namespaces;
}
@ -570,6 +579,201 @@ class SearchEngine {
public function textAlreadyUpdatedForIndex() {
return false;
}
/**
* Makes search simple string if it was namespaced.
* Sets namespaces of the search to namespaces extracted from string.
* @param string $search
* @return $string Simplified search string
*/
protected function normalizeNamespaces( $search ) {
// Find a Title which is not an interwiki and is in NS_MAIN
$title = Title::newFromText( $search );
$ns = $this->namespaces;
if ( $title && !$title->isExternal() ) {
$ns = array( $title->getNamespace() );
$search = $title->getText();
if ( $ns[0] == NS_MAIN ) {
$ns = $this->namespaces; // no explicit prefix, use default namespaces
Hooks::run( 'PrefixSearchExtractNamespace', array( &$ns, &$search ) );
}
} else {
$title = Title::newFromText( $search . 'Dummy' );
if ( $title && $title->getText() == 'Dummy'
&& $title->getNamespace() != NS_MAIN
&& !$title->isExternal() )
{
$ns = array( $title->getNamespace() );
$search = '';
} else {
Hooks::run( 'PrefixSearchExtractNamespace', array( &$ns, &$search ) );
}
}
$ns = array_map( function( $space ) {
return $space == NS_MEDIA ? NS_FILE : $space;
}, $ns );
$this->setNamespaces( $ns );
return $search;
}
/**
* Perform a completion search.
* Does not resolve namespaces and does not check variants.
* Search engine implementations may want to override this function.
* @param string $search
* @return SearchSuggestionSet
*/
protected function completionSearchBackend( $search ) {
$results = array();
$search = trim( $search );
if ( !in_array( NS_SPECIAL, $this->namespaces ) && // We do not run hook on Special: search
!Hooks::run( 'PrefixSearchBackend',
array( $this->namespaces, $search, $this->limit, &$results, $this->offset )
) ) {
// False means hook worked.
// FIXME: Yes, the API is weird. That's why it is going to be deprecated.
return SearchSuggestionSet::fromStrings( $results );
} else {
// Hook did not do the job, use default simple search
$results = $this->simplePrefixSearch( $search );
return SearchSuggestionSet::fromTitles( $results );
}
}
/**
* Perform a completion search.
* @param string $search
* @return SearchSuggestionSet
*/
public function completionSearch( $search ) {
if ( trim( $search ) === '' ) {
return SearchSuggestionSet::emptySuggestionSet(); // Return empty result
}
$search = $this->normalizeNamespaces( $search );
return $this->processCompletionResults( $search, $this->completionSearchBackend( $search ) );
}
/**
* Perform a completion search with variants.
* @param string $search
* @return SearchSuggestionSet
*/
public function completionSearchWithVariants( $search ) {
if ( trim( $search ) === '' ) {
return SearchSuggestionSet::emptySuggestionSet(); // Return empty result
}
$search = $this->normalizeNamespaces( $search );
$results = $this->completionSearchBackend( $search );
$fallbackLimit = $this->limit - $results->getSize();
if ( $fallbackLimit > 0 ) {
global $wgContLang;
$fallbackSearches = $wgContLang->autoConvertToAllVariants( $search );
$fallbackSearches = array_diff( array_unique( $fallbackSearches ), array( $search ) );
foreach ( $fallbackSearches as $fbs ) {
$this->setLimitOffset( $fallbackLimit );
$fallbackSearchResult = $this->completionSearch( $fbs );
$results->appendAll( $fallbackSearchResult );
$fallbackLimit -= count( $fallbackSearchResult );
if ( $fallbackLimit <= 0 ) {
break;
}
}
}
return $this->processCompletionResults( $search, $results );
}
/**
* Extract titles from completion results
* @param SearchSuggestionSet $completionResults
* @return Title[]
*/
public function extractTitles( SearchSuggestionSet $completionResults ) {
return $completionResults->map( function( SearchSuggestion $sugg ) {
return $sugg->getSuggestedTitle();
} );
}
/**
* Process completion search results.
* Resolves the titles and rescores.
* @param SearchSuggestionSet $suggestions
* @return SearchSuggestionSet
*/
protected function processCompletionResults( $search, SearchSuggestionSet $suggestions ) {
if ( $suggestions->getSize() == 0 ) {
// If we don't have anything, don't bother
return $suggestions;
}
$search = trim( $search );
// preload the titles with LinkBatch
$titles = $suggestions->map( function( SearchSuggestion $sugg ) {
return $sugg->getSuggestedTitle();
} );
$lb = new LinkBatch( $titles );
$lb->setCaller( __METHOD__ );
$lb->execute();
$results = $suggestions->map( function( SearchSuggestion $sugg ) {
return $sugg->getSuggestedTitle()->getPrefixedText();
} );
// Rescore results with an exact title match
$rescorer = new SearchExactMatchRescorer();
$rescoredResults = $rescorer->rescore( $search, $this->namespaces, $results, $this->limit );
if ( count( $rescoredResults ) > 0 ) {
$found = array_search( $rescoredResults[0], $results );
if ( $found === false ) {
// If the first result is not in the previous array it
// means that we found a new exact match
$exactMatch = SearchSuggestion::fromTitle( 0, Title::newFromText( $rescoredResults[0] ) );
$suggestions->prepend( $exactMatch );
$suggestions->shrink( $this->limit );
} else {
// if the first result is not the same we need to rescore
if ( $found > 0 ) {
$suggestions->rescore( $found );
}
}
}
return $suggestions;
}
/**
* Simple prefix search for subpages.
* @param string $search
* @return Title[]
*/
public function defaultPrefixSearch( $search ) {
if ( trim( $search ) === '' ) {
return array();
}
$search = $this->normalizeNamespaces( $search );
return $this->simplePrefixSearch( $search );
}
/**
* Call out to simple search backend.
* Defaults to TitlePrefixSearch.
* @param string $search
* @return Title[]
*/
protected function simplePrefixSearch( $search ) {
// Use default database prefix search
$backend = new TitlePrefixSearch;
return $backend->defaultSearchBackend( $this->namespaces, $search, $this->limit, $this->offset );
}
}
/**

View file

@ -0,0 +1,187 @@
<?php
/**
* Search suggestion
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
*/
/**
* A search suggestion
*
*/
class SearchSuggestion {
/**
* @var string the suggestion
*/
private $text;
/**
* @var string the suggestion URL
*/
private $url;
/**
* @var Title|null the suggested title
*/
private $suggestedTitle;
/**
* NOTE: even if suggestedTitle is a redirect suggestedTitleID
* is the ID of the target page.
* @var int|null the suggested title ID
*/
private $suggestedTitleID;
/**
* @var float|null The suggestion score
*/
private $score;
/**
* Construct a new suggestion
* @param float $score the suggestion score
* @param string $text|null the suggestion text
* @param Title|null $suggestedTitle the suggested title
* @param int|null $suggestedTitleID the suggested title ID
*/
public function __construct( $score, $text = null, Title $suggestedTitle = null,
$suggestedTitleID = null ) {
$this->score = $score;
$this->text = $text;
if ( $suggestedTitle ) {
$this->setSuggestedTitle( $suggestedTitle );
}
$this->suggestedTitleID = $suggestedTitleID;
}
/**
* The suggestion text
* @return string
*/
public function getText() {
return $this->text;
}
/**
* Set the suggestion text.
* @param string $text
* @param bool $setTitle Should we also update the title?
*/
public function setText( $text, $setTitle = true ) {
$this->text = $text;
if ( $setTitle && $text ) {
$this->setSuggestedTitle( Title::makeTitle( 0, $text ) );
}
}
/**
* Title object in the case this suggestion is based on a title.
* May return null if the suggestion is not a Title.
* @return Title|null
*/
public function getSuggestedTitle() {
return $this->suggestedTitle;
}
/**
* Set the suggested title
* @param Title|null $title
*/
public function setSuggestedTitle( Title $title = null ) {
$this->suggestedTitle = $title;
if ( $title !== null ) {
$this->url = wfExpandUrl( $title->getFullURL(), PROTO_CURRENT );
}
}
/**
* Title ID in the case this suggestion is based on a title.
* May return null if the suggestion is not a Title.
* @return int|null
*/
public function getSuggestedTitleID() {
return $this->suggestedTitleID;
}
/**
* Set the suggested title ID
* @param int|null $suggestedTitleID
*/
public function setSuggestedTitleID( $suggestedTitleID = null ) {
$this->suggestedTitleID = $suggestedTitleID;
}
/**
* Suggestion score
* @return float Suggestion score
*/
public function getScore() {
return $this->score;
}
/**
* Set the suggestion score
* @param float $score
*/
public function setScore( $score ) {
$this->score = $score;
}
/**
* Suggestion URL, can be the link to the Title or maybe in the
* future a link to the search results for this search suggestion.
* @return string Suggestion URL
*/
public function getURL() {
return $this->url;
}
/**
* Set the suggestion URL
* @param string $url
*/
public function setURL( $url ) {
$this->url = $url;
}
/**
* Create suggestion from Title
* @param float $score Suggestions score
* @param Title $title
* @return SearchSuggestion
*/
public static function fromTitle( $score, Title $title ) {
return new self( $score, $title->getPrefixedText(), $title, $title->getArticleID() );
}
/**
* Create suggestion from text
* Will also create a title if text if not empty.
* @param float $score Suggestions score
* @param string $text
* @return SearchSuggestion
*/
public static function fromText( $score, $text ) {
$suggestion = new self( $score, $text );
if ( $text ) {
$suggestion->setSuggestedTitle( Title::makeTitle( 0, $text ) );
}
return $suggestion;
}
}

View file

@ -0,0 +1,213 @@
<?php
/**
* Search suggestion sets
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
*/
/**
* A set of search suggestions.
* The set is always ordered by score, with the best match first.
*/
class SearchSuggestionSet {
/**
* @var SearchSuggestion[]
*/
private $suggestions = array();
/**
*
* @var array
*/
private $pageMap = array();
/**
* Builds a new set of suggestions.
*
* NOTE: the array should be sorted by score (higher is better),
* in descending order.
* SearchSuggestionSet will not try to re-order this input array.
* Providing an unsorted input array is a mistake and will lead to
* unexpected behaviors.
*
* @param SearchSuggestion[] $suggestions (must be sorted by score)
*/
public function __construct( array $suggestions ) {
foreach ( $suggestions as $suggestion ) {
$pageID = $suggestion->getSuggestedTitleID();
if ( $pageID && empty( $this->pageMap[$pageID] ) ) {
$this->pageMap[$pageID] = true;
}
$this->suggestions[] = $suggestion;
}
}
/**
* Get the list of suggestions.
* @return SearchSuggestion[]
*/
public function getSuggestions() {
return $this->suggestions;
}
/**
* Call array_map on the suggestions array
* @param callback $callback
* @return array
*/
public function map( $callback ) {
return array_map( $callback, $this->suggestions );
}
/**
* Add a new suggestion at the end.
* If the score of the new suggestion is greater than the worst one,
* the new suggestion score will be updated (worst - 1).
*
* @param SearchSuggestion $suggestion
*/
public function append( SearchSuggestion $suggestion ) {
$pageID = $suggestion->getSuggestedTitleID();
if ( $pageID && isset( $this->pageMap[$pageID] ) ) {
return;
}
if ( $this->getSize() > 0 && $suggestion->getScore() >= $this->getWorstScore() ) {
$suggestion->setScore( $this->getWorstScore() - 1 );
}
$this->suggestions[] = $suggestion;
if ( $pageID ) {
$this->pageMap[$pageID] = true;
}
}
/**
* Add suggestion set to the end of the current one.
* @param SearchSuggestionSet $set
*/
public function appendAll( SearchSuggestionSet $set ) {
foreach ( $set->getSuggestions() as $sugg ) {
$this->append( $sugg );
}
}
/**
* Move the suggestion at index $key to the first position
*/
public function rescore( $key ) {
$removed = array_splice( $this->suggestions, $key, 1 );
unset( $this->pageMap[$removed[0]->getSuggestedTitleID()] );
$this->prepend( $removed[0] );
}
/**
* Add a new suggestion at the top. If the new suggestion score
* is lower than the best one its score will be updated (best + 1)
* @param SearchSuggestion $suggestion
*/
public function prepend( SearchSuggestion $suggestion ) {
$pageID = $suggestion->getSuggestedTitleID();
if ( $pageID && isset( $this->pageMap[$pageID] ) ) {
return;
}
if ( $this->getSize() > 0 && $suggestion->getScore() <= $this->getBestScore() ) {
$suggestion->setScore( $this->getBestScore() + 1 );
}
array_unshift( $this->suggestions, $suggestion );
if ( $pageID ) {
$this->pageMap[$pageID] = true;
}
}
/**
* @return float the best score in this suggestion set
*/
public function getBestScore() {
if ( empty( $this->suggestions ) ) {
return 0;
}
return $this->suggestions[0]->getScore();
}
/**
* @return float the worst score in this set
*/
public function getWorstScore() {
if ( empty( $this->suggestions ) ) {
return 0;
}
return end( $this->suggestions )->getScore();
}
/**
* @return int the number of suggestion in this set
*/
public function getSize() {
return count( $this->suggestions );
}
/**
* Remove any extra elements in the suggestions set
* @param int $limit the max size of this set.
*/
public function shrink( $limit ) {
if ( count( $this->suggestions ) > $limit ) {
$this->suggestions = array_slice( $this->suggestions, 0, $limit );
}
}
/**
* Builds a new set of suggestion based on a title array.
* Useful when using a backend that supports only Titles.
*
* NOTE: Suggestion scores will be generated.
*
* @param Title[] $titles
* @return SearchSuggestionSet
*/
public static function fromTitles( array $titles ) {
$score = count( $titles );
$suggestions = array_map( function( $title ) use ( &$score ) {
return SearchSuggestion::fromTitle( $score--, $title );
}, $titles );
return new SearchSuggestionSet( $suggestions );
}
/**
* Builds a new set of suggestion based on a string array.
*
* NOTE: Suggestion scores will be generated.
*
* @param string[] $titles
* @return SearchSuggestionSet
*/
public static function fromStrings( array $titles ) {
$score = count( $titles );
$suggestions = array_map( function( $title ) use ( &$score ) {
return SearchSuggestion::fromText( $score--, $title );
}, $titles );
return new SearchSuggestionSet( $suggestions );
}
/**
* @return SearchSuggestionSet an empty suggestion set
*/
public static function emptySuggestionSet() {
return new SearchSuggestionSet( array() );
}
}

View file

@ -328,6 +328,29 @@ class SpecialPage {
return array();
}
/**
* Perform a regular substring search for prefixSearchSubpages
* @param string $search Prefix to search for
* @param int $limit Maximum number of results to return (usually 10)
* @param int $offset Number of results to skip (usually 0)
* @return string[] Matching subpages
*/
protected function prefixSearchString( $search, $limit, $offset ) {
$title = Title::newFromText( $search );
if ( !$title || !$title->canExist() ) {
// No prefix suggestion in special and media namespace
return array();
}
$search = SearchEngine::create();
$search->setLimitOffset( $limit, $offset );
$search->setNamespaces( array() );
$result = $search->defaultPrefixSearch( $search );
return array_map( function( Title $t ) {
return $t->getPrefixedText();
}, $result );
}
/**
* Helper function for implementations of prefixSearchSubpages() that
* filter the values in memory (as opposed to making a query).

View file

@ -365,15 +365,7 @@ class SpecialAllPages extends IncludableSpecialPage {
* @return string[] Matching subpages
*/
public function prefixSearchSubpages( $search, $limit, $offset ) {
$title = Title::newFromText( $search );
if ( !$title || !$title->canExist() ) {
// No prefix suggestion in special and media namespace
return array();
}
// Autocomplete subpage the same as a normal search
$prefixSearcher = new StringPrefixSearch;
$result = $prefixSearcher->search( $search, $limit, array(), $offset );
return $result;
return $this->prefixSearchString( $search, $limit, $offset );
}
protected function getGroupName() {

View file

@ -234,15 +234,7 @@ class SpecialChangeContentModel extends FormSpecialPage {
* @return string[] Matching subpages
*/
public function prefixSearchSubpages( $search, $limit, $offset ) {
$title = Title::newFromText( $search );
if ( !$title || !$title->canExist() ) {
// No prefix suggestion in special and media namespace
return array();
}
// Autocomplete subpage the same as a normal search
$prefixSearcher = new StringPrefixSearch;
$result = $prefixSearcher->search( $search, $limit, array(), $offset );
return $result;
return $this->prefixSearchString( $search, $limit, $offset );
}
protected function getGroupName() {

View file

@ -246,9 +246,11 @@ class FileDuplicateSearchPage extends QueryPage {
// No prefix suggestion outside of file namespace
return array();
}
$search = SearchEngine::create();
$search->setLimitOffset( $limit, $offset );
// Autocomplete subpage the same as a normal search, but just for files
$prefixSearcher = new TitlePrefixSearch;
$result = $prefixSearcher->search( $search, $limit, array( NS_FILE ), $offset );
$search->setNamespaces( array( NS_FILE ) );
$result = $search->defaultPrefixSearch( $search );
return array_map( function ( Title $t ) {
// Remove namespace in search suggestion

View file

@ -820,15 +820,7 @@ class MovePageForm extends UnlistedSpecialPage {
* @return string[] Matching subpages
*/
public function prefixSearchSubpages( $search, $limit, $offset ) {
$title = Title::newFromText( $search );
if ( !$title || !$title->canExist() ) {
// No prefix suggestion in special and media namespace
return array();
}
// Autocomplete subpage the same as a normal search
$prefixSearcher = new StringPrefixSearch;
$result = $prefixSearcher->search( $search, $limit, array(), $offset );
return $result;
return $this->prefixSearchString( $search, $limit, $offset );
}
protected function getGroupName() {

View file

@ -214,15 +214,7 @@ class SpecialPageLanguage extends FormSpecialPage {
* @return string[] Matching subpages
*/
public function prefixSearchSubpages( $search, $limit, $offset ) {
$title = Title::newFromText( $search );
if ( !$title || !$title->canExist() ) {
// No prefix suggestion in special and media namespace
return array();
}
// Autocomplete subpage the same as a normal search
$prefixSearcher = new StringPrefixSearch;
$result = $prefixSearcher->search( $search, $limit, array(), $offset );
return $result;
return $this->prefixSearchString( $search, $limit, $offset );
}
protected function getGroupName() {

View file

@ -303,15 +303,7 @@ class SpecialPrefixindex extends SpecialAllPages {
* @return string[] Matching subpages
*/
public function prefixSearchSubpages( $search, $limit, $offset ) {
$title = Title::newFromText( $search );
if ( !$title || !$title->canExist() ) {
// No prefix suggestion in special and media namespace
return array();
}
// Autocomplete subpage the same as a normal search
$prefixSearcher = new StringPrefixSearch;
$result = $prefixSearcher->search( $search, $limit, array(), $offset );
return $result;
return $this->prefixSearchString( $search, $limit, $offset );
}
protected function getGroupName() {

View file

@ -273,14 +273,6 @@ class SpecialRecentChangesLinked extends SpecialRecentChanges {
* @return string[] Matching subpages
*/
public function prefixSearchSubpages( $search, $limit, $offset ) {
$title = Title::newFromText( $search );
if ( !$title || !$title->canExist() ) {
// No prefix suggestion in special and media namespace
return array();
}
// Autocomplete subpage the same as a normal search
$prefixSearcher = new StringPrefixSearch;
$result = $prefixSearcher->search( $search, $limit, array(), $offset );
return $result;
return $this->prefixSearchString( $search, $limit, $offset );
}
}

View file

@ -1709,15 +1709,7 @@ class SpecialUndelete extends SpecialPage {
* @return string[] Matching subpages
*/
public function prefixSearchSubpages( $search, $limit, $offset ) {
$title = Title::newFromText( $search );
if ( !$title || !$title->canExist() ) {
// No prefix suggestion in special and media namespace
return array();
}
// Autocomplete subpage the same as a normal search
$prefixSearcher = new StringPrefixSearch;
$result = $prefixSearcher->search( $search, $limit, array(), $offset );
return $result;
return $this->prefixSearchString( $search, $limit, $offset );
}
protected function getGroupName() {

View file

@ -548,15 +548,7 @@ class SpecialWhatLinksHere extends IncludableSpecialPage {
* @return string[] Matching subpages
*/
public function prefixSearchSubpages( $search, $limit, $offset ) {
$title = Title::newFromText( $search );
if ( !$title || !$title->canExist() ) {
// No prefix suggestion in special and media namespace
return array();
}
// Autocomplete subpage the same as a normal search
$prefixSearcher = new StringPrefixSearch;
$result = $prefixSearcher->search( $search, $limit, array(), $offset );
return $result;
return $this->prefixSearchString( $search, $limit, $offset );
}
protected function getGroupName() {

View file

@ -0,0 +1,334 @@
<?php
/**
* @group Search
* @group Database
*/
class SearchEnginePrefixTest extends MediaWikiLangTestCase {
/**
* @var SearchEngine
*/
private $search;
public function addDBData() {
if ( !$this->isWikitextNS( NS_MAIN ) ) {
// tests are skipped if NS_MAIN is not wikitext
return;
}
$this->insertPage( 'Sandbox' );
$this->insertPage( 'Bar' );
$this->insertPage( 'Example' );
$this->insertPage( 'Example Bar' );
$this->insertPage( 'Example Foo' );
$this->insertPage( 'Example Foo/Bar' );
$this->insertPage( 'Example/Baz' );
$this->insertPage( 'Redirect test', '#REDIRECT [[Redirect Test]]' );
$this->insertPage( 'Redirect Test' );
$this->insertPage( 'Redirect Test Worse Result' );
$this->insertPage( 'Redirect test2', '#REDIRECT [[Redirect Test2]]' );
$this->insertPage( 'Redirect TEST2', '#REDIRECT [[Redirect Test2]]' );
$this->insertPage( 'Redirect Test2' );
$this->insertPage( 'Redirect Test2 Worse Result' );
$this->insertPage( 'Talk:Sandbox' );
$this->insertPage( 'Talk:Example' );
$this->insertPage( 'User:Example' );
}
protected function setUp() {
parent::setUp();
if ( !$this->isWikitextNS( NS_MAIN ) ) {
$this->markTestSkipped( 'Main namespace does not support wikitext.' );
}
// Avoid special pages from extensions interferring with the tests
$this->setMwGlobals( 'wgSpecialPages', array() );
$this->search = SearchEngine::create();
$this->search->setNamespaces( array() );
}
protected function searchProvision( Array $results = null ) {
if ( $results === null ) {
$this->setMwGlobals( 'wgHooks', array() );
} else {
$this->setMwGlobals( 'wgHooks', array(
'PrefixSearchBackend' => array(
function ( $namespaces, $search, $limit, &$srchres ) use ( $results ) {
$srchres = $results;
return false;
}
),
) );
}
}
public static function provideSearch() {
return array(
array( array(
'Empty string',
'query' => '',
'results' => array(),
) ),
array( array(
'Main namespace with title prefix',
'query' => 'Ex',
'results' => array(
'Example',
'Example/Baz',
'Example Bar',
),
// Third result when testing offset
'offsetresult' => array(
'Example Foo',
),
) ),
array( array(
'Talk namespace prefix',
'query' => 'Talk:',
'results' => array(
'Talk:Example',
'Talk:Sandbox',
),
) ),
array( array(
'User namespace prefix',
'query' => 'User:',
'results' => array(
'User:Example',
),
) ),
array( array(
'Special namespace prefix',
'query' => 'Special:',
'results' => array(
'Special:ActiveUsers',
'Special:AllMessages',
'Special:AllMyFiles',
),
// Third result when testing offset
'offsetresult' => array(
'Special:AllMyUploads',
),
) ),
array( array(
'Special namespace with prefix',
'query' => 'Special:Un',
'results' => array(
'Special:Unblock',
'Special:UncategorizedCategories',
'Special:UncategorizedFiles',
),
// Third result when testing offset
'offsetresult' => array(
'Special:UncategorizedImages',
),
) ),
array( array(
'Special page name',
'query' => 'Special:EditWatchlist',
'results' => array(
'Special:EditWatchlist',
),
) ),
array( array(
'Special page subpages',
'query' => 'Special:EditWatchlist/',
'results' => array(
'Special:EditWatchlist/clear',
'Special:EditWatchlist/raw',
),
) ),
array( array(
'Special page subpages with prefix',
'query' => 'Special:EditWatchlist/cl',
'results' => array(
'Special:EditWatchlist/clear',
),
) ),
);
}
/**
* @dataProvider provideSearch
* @covers SearchEngine::defaultPrefixSearch
*/
public function testSearch( Array $case ) {
$this->search->setLimitOffset( 3 );
$results = $this->search->defaultPrefixSearch( $case['query'] );
$results = array_map( function( Title $t ) {
return $t->getPrefixedText();
}, $results );
$this->assertEquals(
$case['results'],
$results,
$case[0]
);
}
/**
* @dataProvider provideSearch
* @covers SearchEngine::defaultPrefixSearch
*/
public function testSearchWithOffset( Array $case ) {
$this->search->setLimitOffset( 3, 1 );
$results = $this->search->defaultPrefixSearch( $case['query'] );
$results = array_map( function( Title $t ) {
return $t->getPrefixedText();
}, $results );
// We don't expect the first result when offsetting
array_shift( $case['results'] );
// And sometimes we expect a different last result
$expected = isset( $case['offsetresult'] ) ?
array_merge( $case['results'], $case['offsetresult'] ) :
$case['results'];
$this->assertEquals(
$expected,
$results,
$case[0]
);
}
public static function provideSearchBackend() {
return array(
array( array(
'Simple case',
'provision' => array(
'Bar',
'Barcelona',
'Barbara',
),
'query' => 'Bar',
'results' => array(
'Bar',
'Barcelona',
'Barbara',
),
) ),
array( array(
'Exact match not on top (bug 70958)',
'provision' => array(
'Barcelona',
'Bar',
'Barbara',
),
'query' => 'Bar',
'results' => array(
'Bar',
'Barcelona',
'Barbara',
),
) ),
array( array(
'Exact match missing (bug 70958)',
'provision' => array(
'Barcelona',
'Barbara',
'Bart',
),
'query' => 'Bar',
'results' => array(
'Bar',
'Barcelona',
'Barbara',
),
) ),
array( array(
'Exact match missing and not existing',
'provision' => array(
'Exile',
'Exist',
'External',
),
'query' => 'Ex',
'results' => array(
'Exile',
'Exist',
'External',
),
) ),
array( array(
"Exact match shouldn't override already found match if " .
"exact is redirect and found isn't",
'provision' => array(
// Target of the exact match is low in the list
'Redirect Test Worse Result',
'Redirect Test',
),
'query' => 'redirect test',
'results' => array(
// Redirect target is pulled up and exact match isn't added
'Redirect Test',
'Redirect Test Worse Result',
),
) ),
array( array(
"Exact match shouldn't override already found match if " .
"both exact match and found match are redirect",
'provision' => array(
// Another redirect to the same target as the exact match
// is low in the list
'Redirect Test2 Worse Result',
'Redirect test2',
),
'query' => 'redirect TEST2',
'results' => array(
// Found redirect is pulled to the top and exact match isn't
// added
'Redirect test2',
'Redirect Test2 Worse Result',
),
) ),
array( array(
"Exact match should override any already found matches that " .
"are redirects to it",
'provision' => array(
// Another redirect to the same target as the exact match
// is low in the list
'Redirect Test Worse Result',
'Redirect test',
),
'query' => 'Redirect Test',
'results' => array(
// Found redirect is pulled to the top and exact match isn't
// added
'Redirect Test',
'Redirect Test Worse Result',
'Redirect test',
),
) ),
);
}
/**
* @dataProvider provideSearchBackend
* @covers PrefixSearch::searchBackend
*/
public function testSearchBackend( Array $case ) {
$search = $stub = $this->getMockBuilder( 'SearchEngine' )
->setMethods( array( 'completionSearchBackend' ) )->getMock();
$return = SearchSuggestionSet::fromStrings( $case['provision'] );
$search->expects( $this->any() )
->method( 'completionSearchBackend' )
->will( $this->returnValue( $return ) );
$search->setLimitOffset( 3 );
$results = $search->completionSearch( $case['query'] );
$results = $results->map( function( SearchSuggestion $s ) {
return $s->getText();
} );
$this->assertEquals(
$case['results'],
$results,
$case[0]
);
}
}

View file

@ -0,0 +1,104 @@
<?php
/**
* Test for filter utilities.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*/
class SearchSuggestionSetTest extends \PHPUnit_Framework_TestCase {
/**
* Test that adding a new suggestion at the end
* will keep proper score ordering
*/
public function testAppend() {
$set = SearchSuggestionSet::emptySuggestionSet();
$this->assertEquals( 0, $set->getSize() );
$set->append( new SearchSuggestion( 3 ) );
$this->assertEquals( 3, $set->getWorstScore() );
$this->assertEquals( 3, $set->getBestScore() );
$suggestion = new SearchSuggestion( 4 );
$set->append( $suggestion );
$this->assertEquals( 2, $set->getWorstScore() );
$this->assertEquals( 3, $set->getBestScore() );
$this->assertEquals( 2, $suggestion->getScore() );
$suggestion = new SearchSuggestion( 2 );
$set->append( $suggestion );
$this->assertEquals( 1, $set->getWorstScore() );
$this->assertEquals( 3, $set->getBestScore() );
$this->assertEquals( 1, $suggestion->getScore() );
$scores = $set->map( function( $s ) {
return $s->getScore();
} );
$sorted = $scores;
asort( $sorted );
$this->assertEquals( $sorted, $scores );
}
/**
* Test that adding a new best suggestion will keep proper score
* ordering
*/
public function testInsertBest() {
$set = SearchSuggestionSet::emptySuggestionSet();
$this->assertEquals( 0, $set->getSize() );
$set->prepend( new SearchSuggestion( 3 ) );
$this->assertEquals( 3, $set->getWorstScore() );
$this->assertEquals( 3, $set->getBestScore() );
$suggestion = new SearchSuggestion( 4 );
$set->prepend( $suggestion );
$this->assertEquals( 3, $set->getWorstScore() );
$this->assertEquals( 4, $set->getBestScore() );
$this->assertEquals( 4, $suggestion->getScore() );
$suggestion = new SearchSuggestion( 0 );
$set->prepend( $suggestion );
$this->assertEquals( 3, $set->getWorstScore() );
$this->assertEquals( 5, $set->getBestScore() );
$this->assertEquals( 5, $suggestion->getScore() );
$suggestion = new SearchSuggestion( 2 );
$set->prepend( $suggestion );
$this->assertEquals( 3, $set->getWorstScore() );
$this->assertEquals( 6, $set->getBestScore() );
$this->assertEquals( 6, $suggestion->getScore() );
$scores = $set->map( function( $s ) {
return $s->getScore();
} );
$sorted = $scores;
asort( $sorted );
$this->assertEquals( $sorted, $scores );
}
public function testShrink() {
$set = SearchSuggestionSet::emptySuggestionSet();
for ( $i = 0; $i < 100; $i++ ) {
$set->append( new SearchSuggestion( 0 ) );
}
$set->shrink( 10 );
$this->assertEquals( 10, $set->getSize() );
$set->shrink( 0 );
$this->assertEquals( 0, $set->getSize() );
}
// TODO: test for fromTitles
}