'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',
/**
* 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
*/
* @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',
/**
* Performs prefix search, returning Title objects
+ * @deprecated Since 1.27, Use SearchEngine::prefixSearchSubpages or SearchEngine::completionSearch
* @ingroup Search
*/
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 {
* @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;
}
$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'] ) {
* @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;
}
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 );
+ }
+
}
/**
--- /dev/null
+<?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;
+ }
+
+}
--- /dev/null
+<?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() );
+ }
+}
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).
* @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() {
* @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() {
// 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
* @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() {
* @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() {
* @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() {
* @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 );
}
}
* @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() {
* @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() {
--- /dev/null
+<?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]
+ );
+ }
+}
--- /dev/null
+<?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
+}