Include completion search into SearchEngine
authorStanislav Malyshev <smalyshev@gmail.com>
Tue, 26 Jan 2016 21:18:27 +0000 (13:18 -0800)
committerEBernhardson <ebernhardson@wikimedia.org>
Wed, 3 Feb 2016 23:41:49 +0000 (23:41 +0000)
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

19 files changed:
autoload.php
includes/PrefixSearch.php
includes/api/ApiOpenSearch.php
includes/api/ApiQueryPrefixSearch.php
includes/search/SearchEngine.php
includes/search/SearchSuggestion.php [new file with mode: 0644]
includes/search/SearchSuggestionSet.php [new file with mode: 0644]
includes/specialpage/SpecialPage.php
includes/specials/SpecialAllPages.php
includes/specials/SpecialChangeContentModel.php
includes/specials/SpecialFileDuplicateSearch.php
includes/specials/SpecialMovepage.php
includes/specials/SpecialPageLanguage.php
includes/specials/SpecialPrefixindex.php
includes/specials/SpecialRecentchangeslinked.php
includes/specials/SpecialUndelete.php
includes/specials/SpecialWhatlinkshere.php
tests/phpunit/includes/search/SearchEnginePrefixTest.php [new file with mode: 0644]
tests/phpunit/includes/search/SearchSuggestionSetTest.php [new file with mode: 0644]

index b055574..03666dc 100644 (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',
index c6f187d..5f36cf5 100644 (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 {
index 5ce43cc..ff5707e 100644 (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;
                }
index 25ff07c..1dac740 100644 (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'] ) {
index 3c8d56e..81b850a 100644 (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 );
+       }
+
 }
 
 /**
diff --git a/includes/search/SearchSuggestion.php b/includes/search/SearchSuggestion.php
new file mode 100644 (file)
index 0000000..cd9062b
--- /dev/null
@@ -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;
+       }
+
+}
diff --git a/includes/search/SearchSuggestionSet.php b/includes/search/SearchSuggestionSet.php
new file mode 100644 (file)
index 0000000..a1f9a04
--- /dev/null
@@ -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() );
+       }
+}
index 0417146..6158df2 100644 (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).
index 9e75522..0bf93be 100644 (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() {
index a9a7f97..1f32e3f 100644 (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() {
index 323903e..9970dfa 100644 (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
index a7e5e02..339c1d9 100644 (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() {
index 69a9d48..38093be 100644 (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() {
index a6c0423..6401063 100644 (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() {
index 8db8f24..dc210db 100644 (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 );
        }
 }
index f99a52d..078f032 100644 (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() {
index 47fd972..45ef9a2 100644 (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() {
diff --git a/tests/phpunit/includes/search/SearchEnginePrefixTest.php b/tests/phpunit/includes/search/SearchEnginePrefixTest.php
new file mode 100644 (file)
index 0000000..2664fa6
--- /dev/null
@@ -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]
+               );
+       }
+}
diff --git a/tests/phpunit/includes/search/SearchSuggestionSetTest.php b/tests/phpunit/includes/search/SearchSuggestionSetTest.php
new file mode 100644 (file)
index 0000000..60559fc
--- /dev/null
@@ -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
+}