'PageProps' => __DIR__ . '/includes/PageProps.php',
'PageQueryPage' => __DIR__ . '/includes/specialpage/PageQueryPage.php',
'Pager' => __DIR__ . '/includes/pager/Pager.php',
+ 'PaginatingSearchEngine' => __DIR__ . '/includes/search/PaginatingSearchEngine.php',
'ParameterizedPassword' => __DIR__ . '/includes/password/ParameterizedPassword.php',
'Parser' => __DIR__ . '/includes/parser/Parser.php',
'ParserCache' => __DIR__ . '/includes/parser/ParserCache.php',
$offset = $params['offset'];
$searchEngine = $this->buildSearchEngine( $params );
- $titles = $searchEngine->extractTitles( $searchEngine->completionSearchWithVariants( $search ) );
+ $suggestions = $searchEngine->completionSearchWithVariants( $search );
+ $titles = $searchEngine->extractTitles( $suggestions );
+
+ if ( $suggestions->hasMoreResults() ) {
+ $this->setContinueEnumParameter( 'offset', $offset + $limit );
+ }
if ( $resultPageSet ) {
$resultPageSet->setRedirectMergePolicy( function ( array $current, array $new ) {
}
return $current;
} );
- if ( count( $titles ) > $limit ) {
- $this->setContinueEnumParameter( 'offset', $offset + $limit );
- array_pop( $titles );
- }
$resultPageSet->populateFromTitles( $titles );
foreach ( $titles as $index => $title ) {
$resultPageSet->setGeneratorData( $title, [ 'index' => $index + $offset + 1 ] );
$result = $this->getResult();
$count = 0;
foreach ( $titles as $title ) {
- if ( ++$count > $limit ) {
- $this->setContinueEnumParameter( 'offset', $offset + $limit );
- break;
- }
$vals = [
'ns' => intval( $title->getNamespace() ),
'title' => $title->getPrefixedText(),
$vals['pageid'] = intval( $title->getArticleID() );
}
$fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $vals );
+ ++$count;
if ( !$fit ) {
- $this->setContinueEnumParameter( 'offset', $offset + $count - 1 );
+ $this->setContinueEnumParameter( 'offset', $offset + $count );
break;
}
}
$count = 0;
$limit = $params['limit'];
- foreach ( $matches as $result ) {
- if ( ++$count > $limit ) {
- // We've reached the one extra which shows that there are
- // additional items to be had. Stop here...
- $this->setContinueEnumParameter( 'offset', $params['offset'] + $params['limit'] );
- break;
- }
+ if ( $matches->hasMoreResults() ) {
+ $this->setContinueEnumParameter( 'offset', $params['offset'] + $params['limit'] );
+ }
+ foreach ( $matches as $result ) {
+ $count++;
// Silently skip broken and missing titles
if ( $result->isBrokenTitle() || $result->isMissingRevision() ) {
continue;
if ( $alternatives[0] === null ) {
$alternatives[0] = self::$BACKEND_NULL_PARAM;
}
- $this->allowedParams['backend'] = [
+ $params['backend'] = [
ApiBase::PARAM_DFLT => $searchConfig->getSearchType(),
ApiBase::PARAM_TYPE => $alternatives,
];
* will be set:
* - backend: which search backend to use
* - limit: mandatory
- * - offset: optional, if set limit will be incremented by
- * one ( to support the continue parameter )
+ * - offset: optional
* - namespace: mandatory
* - search engine profiles defined by SearchApi::getSearchProfileParams()
* @param string[]|null $params API request params (must be sanitized by
$searchEngine = MediaWikiServices::getInstance()->getSearchEngineFactory()->create( $type );
$limit = $params['limit'];
$searchEngine->setNamespaces( $params['namespace'] );
- $offset = null;
- if ( isset( $params['offset'] ) ) {
- // If the API supports offset then it probably
- // wants to fetch limit+1 so it can check if
- // more results are available to properly set
- // the continue param
- $offset = $params['offset'];
- $limit += 1;
- }
+ $offset = isset( $params['offset'] ) ? $params['offset'] : null;
$searchEngine->setLimitOffset( $limit, $offset );
// Initialize requested search profiles.
--- /dev/null
+<?php
+
+/**
+ * Marker class for search engines that can handle their own pagination, by
+ * reporting in their SearchResultSet when a next page is available. This
+ * only applies to search{Title,Text} and not to completion search.
+ *
+ * SearchEngine implementations not implementing this interface will have
+ * an over-fetch performed to determine next page availability.
+ */
+interface PaginatingSearchEngine {
+}
* @return SearchResultSet|Status|null
*/
public function searchText( $term ) {
- return $this->doSearchText( $term );
+ return $this->maybePaginate( function () use ( $term ) {
+ return $this->doSearchText( $term );
+ } );
}
/**
* @return SearchResultSet|null
*/
public function searchTitle( $term ) {
- return $this->doSearchTitle( $term );
+ return $this->maybePaginate( function () use ( $term ) {
+ return $this->doSearchTitle( $term );
+ } );
}
/**
return null;
}
+ /**
+ * Performs an overfetch and shrink operation to determine if
+ * the next page is available for search engines that do not
+ * explicitly implement their own pagination.
+ *
+ * @param Closure $fn Takes no arguments
+ * @return SearchResultSet|Status<SearchResultSet>|null Result of calling $fn
+ */
+ private function maybePaginate( Closure $fn ) {
+ if ( $this instanceof PaginatingSearchEngine ) {
+ return $fn();
+ }
+ $this->limit++;
+ try {
+ $resultSetOrStatus = $fn();
+ } finally {
+ $this->limit--;
+ }
+
+ $resultSet = null;
+ if ( $resultSetOrStatus instanceof SearchResultSet ) {
+ $resultSet = $resultSetOrStatus;
+ } elseif ( $resultSetOrStatus instanceof Status &&
+ $resultSetOrStatus->getValue() instanceof SearchResultSet
+ ) {
+ $resultSet = $resultSetOrStatus->getValue();
+ }
+ if ( $resultSet ) {
+ $resultSet->shrink( $this->limit );
+ }
+
+ return $resultSetOrStatus;
+ }
+
/**
* @since 1.18
* @param string $feature
return $search;
}
+ /**
+ * Perform an overfetch of completion search results. This allows
+ * determining if another page of results is available.
+ *
+ * @param string $search
+ * @return SearchSuggestionSet
+ */
+ protected function completionSearchBackendOverfetch( $search ) {
+ $this->limit++;
+ try {
+ return $this->completionSearchBackend( $search );
+ } finally {
+ $this->limit--;
+ }
+ }
+
/**
* Perform a completion search.
* Does not resolve namespaces and does not check variants.
return SearchSuggestionSet::emptySuggestionSet(); // Return empty result
}
$search = $this->normalizeNamespaces( $search );
- return $this->processCompletionResults( $search, $this->completionSearchBackend( $search ) );
+ $suggestions = $this->completionSearchBackendOverfetch( $search );
+ return $this->processCompletionResults( $search, $suggestions );
}
/**
}
$search = $this->normalizeNamespaces( $search );
- $results = $this->completionSearchBackend( $search );
- $fallbackLimit = $this->limit - $results->getSize();
+ $results = $this->completionSearchBackendOverfetch( $search );
+ $fallbackLimit = 1 + $this->limit - $results->getSize();
if ( $fallbackLimit > 0 ) {
global $wgContLang;
* @return SearchSuggestionSet
*/
protected function processCompletionResults( $search, SearchSuggestionSet $suggestions ) {
+ // We over-fetched to determine pagination. Shrink back down if we have extra results
+ // and mark if pagination is possible
+ $suggestions->shrink( $this->limit );
+
$search = trim( $search );
// preload the titles with LinkBatch
$titles = $suggestions->map( function ( SearchSuggestion $sugg ) {
$setAugmentors = [];
$rowAugmentors = [];
Hooks::run( "SearchResultsAugment", [ &$setAugmentors, &$rowAugmentors ] );
-
if ( !$setAugmentors && !$rowAugmentors ) {
// We're done here
return;
/**
* @ingroup Search
*/
-class SearchResultSet implements IteratorAggregate {
-
+class SearchResultSet implements Countable, IteratorAggregate {
/**
* Types of interwiki results
*/
*/
protected $extraData = [];
- /** @var ArrayIterator|null Iterator supporting BC iteration methods */
+ /**
+ * @var boolean True when there are more pages of search results available.
+ */
+ private $hasMoreResults;
+
+ /**
+ * @var ArrayIterator|null Iterator supporting BC iteration methods
+ */
private $bcIterator;
- public function __construct( $containedSyntax = false ) {
+ /**
+ * @param bool $containedSyntax True when query is not requesting a simple
+ * term match
+ * @param bool $hasMoreResults True when there are more pages of search
+ * results available.
+ */
+ public function __construct( $containedSyntax = false, $hasMoreResults = false ) {
if ( static::class === __CLASS__ ) {
// This class will eventually be abstract. SearchEngine implementations
// already have to extend this class anyways to provide the actual
wfDeprecated( __METHOD__, 1.32 );
}
$this->containedSyntax = $containedSyntax;
+ $this->hasMoreResults = $hasMoreResults;
}
/**
}
function numRows() {
- return 0;
+ return $this->count();
+ }
+
+ final public function count() {
+ return count( $this->extractResults() );
}
/**
return $this->containedSyntax;
}
+ /**
+ * @return bool True when there are more pages of search results available.
+ */
+ public function hasMoreResults() {
+ return $this->hasMoreResults;
+ }
+
+ /**
+ * @param int $limit Shrink result set to $limit and flag
+ * if more results are available.
+ */
+ public function shrink( $limit ) {
+ if ( $this->count() > $limit ) {
+ $this->hasMoreResults = true;
+ // shrinking result set for implementations that
+ // have not implemented extractResults and use
+ // the default cache location. Other implementations
+ // must override this as well.
+ if ( is_array( $this->results ) ) {
+ $this->results = array_slice( $this->results, 0, $limit );
+ } else {
+ throw new \UnexpectedValueException(
+ "When overriding result store extending classes must "
+ . " also override " . __METHOD__ );
+ }
+ }
+ }
+
/**
* Extract all the results in the result set as array.
* @return SearchResult[]
*/
private $pageMap = [];
+ /**
+ * @var bool Are more results available?
+ */
+ private $hasMoreResults;
+
/**
* Builds a new set of suggestions.
*
* unexpected behaviors.
*
* @param SearchSuggestion[] $suggestions (must be sorted by score)
+ * @param bool $hasMoreResults Are more results available?
*/
- public function __construct( array $suggestions ) {
+ public function __construct( array $suggestions, $hasMoreResults = false ) {
+ $this->hasMoreResults = $hasMoreResults;
foreach ( $suggestions as $suggestion ) {
$pageID = $suggestion->getSuggestedTitleID();
if ( $pageID && empty( $this->pageMap[$pageID] ) ) {
}
}
+ /**
+ * @return bool Are more results available?
+ */
+ public function hasMoreResults() {
+ return $this->hasMoreResults;
+ }
+
/**
* Get the list of suggestions.
* @return SearchSuggestion[]
public function shrink( $limit ) {
if ( count( $this->suggestions ) > $limit ) {
$this->suggestions = array_slice( $this->suggestions, 0, $limit );
+ $this->hasMoreResults = true;
}
}
* NOTE: Suggestion scores will be generated.
*
* @param Title[] $titles
+ * @param bool $hasMoreResults Are more results available?
* @return SearchSuggestionSet
*/
- public static function fromTitles( array $titles ) {
+ public static function fromTitles( array $titles, $hasMoreResults = false ) {
$score = count( $titles );
$suggestions = array_map( function ( $title ) use ( &$score ) {
return SearchSuggestion::fromTitle( $score--, $title );
}, $titles );
- return new SearchSuggestionSet( $suggestions );
+ return new SearchSuggestionSet( $suggestions, $hasMoreResults );
}
/**
* NOTE: Suggestion scores will be generated.
*
* @param string[] $titles
+ * @param bool $hasMoreResults Are more results available?
* @return SearchSuggestionSet
*/
- public static function fromStrings( array $titles ) {
+ public static function fromStrings( array $titles, $hasMoreResults = false ) {
$score = count( $titles );
$suggestions = array_map( function ( $title ) use ( &$score ) {
return SearchSuggestion::fromText( $score--, $title );
}, $titles );
- return new SearchSuggestionSet( $suggestions );
+ return new SearchSuggestionSet( $suggestions, $hasMoreResults );
}
/**
=> "$testDir/phpunit/mocks/session/DummySessionBackend.php",
'DummySessionProvider' => "$testDir/phpunit/mocks/session/DummySessionProvider.php",
'MockMessageLocalizer' => "$testDir/phpunit/mocks/MockMessageLocalizer.php",
+ 'MockCompletionSearchEngine' => "$testDir/phpunit/mocks/search/MockCompletionSearchEngine.php",
'MockSearchEngine' => "$testDir/phpunit/mocks/search/MockSearchEngine.php",
'MockSearchResultSet' => "$testDir/phpunit/mocks/search/MockSearchResultSet.php",
'MockSearchResult' => "$testDir/phpunit/mocks/search/MockSearchResult.php",
--- /dev/null
+<?php
+
+/**
+ * @group API
+ * @group medium
+ *
+ * @covers ApiQueryPrefixSearch
+ */
+class ApiQueryPrefixSearchTest extends ApiTestCase {
+ public function offsetContinueProvider() {
+ return [
+ 'no offset' => [ 2, 2, 0, 2 ],
+ 'with offset' => [ 7, 2, 5, 2 ],
+ 'past end, no offset' => [ null, 11, 0, 20 ],
+ 'past end, with offset' => [ null, 5, 6, 10 ],
+ ];
+ }
+
+ /**
+ * @dataProvider offsetContinueProvider
+ */
+ public function testOffsetContinue( $expectedOffset, $expectedResults, $offset, $limit ) {
+ $this->registerMockSearchEngine();
+ $response = $this->doApiRequest( [
+ 'action' => 'query',
+ 'list' => 'prefixsearch',
+ 'pssearch' => 'example query terms',
+ 'psoffset' => $offset,
+ 'pslimit' => $limit,
+ ] );
+ $result = $response[0];
+ $this->assertArrayNotHasKey( 'warnings', $result );
+ $suggestions = $result['query']['prefixsearch'];
+ $this->assertCount( $expectedResults, $suggestions );
+ if ( $expectedOffset == null ) {
+ $this->assertArrayNotHasKey( 'continue', $result );
+ } else {
+ $this->assertArrayHasKey( 'continue', $result );
+ $this->assertEquals( $expectedOffset, $result['continue']['psoffset'] );
+ }
+ }
+
+ private function registerMockSearchEngine() {
+ $this->setMwGlobals( [
+ 'wgSearchType' => MockCompletionSearchEngine::class,
+ ] );
+ }
+}
'Redirect test',
],
] ],
+ [ [
+ "Extra results must not be returned",
+ 'provision' => [
+ 'Example',
+ 'Example Bar',
+ 'Example Foo',
+ 'Example Foo/Bar'
+ ],
+ 'query' => 'foo',
+ 'results' => [
+ 'Example',
+ 'Example Bar',
+ 'Example Foo',
+ ],
+ ] ],
];
}
* @covers PrefixSearch::searchBackend
*/
public function testSearchBackend( array $case ) {
- $search = $stub = $this->getMockBuilder( SearchEngine::class )
- ->setMethods( [ 'completionSearchBackend' ] )->getMock();
-
- $return = SearchSuggestionSet::fromStrings( $case['provision'] );
-
- $search->expects( $this->any() )
- ->method( 'completionSearchBackend' )
- ->will( $this->returnValue( $return ) );
-
- $search->setLimitOffset( 3 );
+ $search = $this->mockSearchWithResults( $case['provision'] );
$results = $search->completionSearch( $case['query'] );
$results = $results->map( function ( SearchSuggestion $s ) {
$case[0]
);
}
+
+ public function paginationProvider() {
+ $res = [ 'Example', 'Example Bar', 'Example Foo', 'Example Foo/Bar' ];
+ return [
+ 'With less than requested results no pagination' => [
+ false, array_slice( $res, 0, 2 ),
+ ],
+ 'With same as requested results no pagination' => [
+ false, array_slice( $res, 0, 3 ),
+ ],
+ 'With extra result returned offer pagination' => [
+ true, $res,
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider paginationProvider
+ */
+ public function testPagination( $hasMoreResults, $provision ) {
+ $search = $this->mockSearchWithResults( $provision );
+ $results = $search->completionSearch( 'irrelevant' );
+
+ $this->assertEquals( $hasMoreResults, $results->hasMoreResults() );
+ }
+
+ private function mockSearchWithResults( $titleStrings, $limit = 3 ) {
+ $search = $stub = $this->getMockBuilder( SearchEngine::class )
+ ->setMethods( [ 'completionSearchBackend' ] )->getMock();
+
+ $return = SearchSuggestionSet::fromStrings( $titleStrings );
+
+ $search->expects( $this->any() )
+ ->method( 'completionSearchBackend' )
+ ->will( $this->returnValue( $return ) );
+
+ $search->setLimitOffset( $limit );
+ return $search;
+ }
}
] );
$this->assertEquals( [ 'foo' => 'bar' ], $result->getExtensionData() );
}
+
+ /**
+ * @covers SearchResultSet::shrink
+ * @covers SearchResultSet::count
+ * @covers SearchResultSet::hasMoreResults
+ */
+ public function testHasMoreResults() {
+ $result = SearchResult::newFromTitle( Title::newMainPage() );
+ $resultSet = new MockSearchResultSet( array_fill( 0, 3, $result ) );
+ $this->assertEquals( 3, count( $resultSet ) );
+ $this->assertFalse( $resultSet->hasMoreResults() );
+ $resultSet->shrink( 3 );
+ $this->assertFalse( $resultSet->hasMoreResults() );
+ $resultSet->shrink( 2 );
+ $this->assertTrue( $resultSet->hasMoreResults() );
+ }
}
--- /dev/null
+<?php
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * SearchEngine implementation for returning mocked completion search results.
+ */
+class MockCompletionSearchEngine extends SearchEngine {
+ private static $completionSearchResult = [];
+
+ public function completionSearchBackend( $search ) {
+ if ( self::$completionSearchResult == null ) {
+ self::$completionSearchResult = [];
+ // TODO: Or does this have to be setup per-test?
+ $lc = MediaWikiServices::getInstance()->getLinkCache();
+ foreach ( range( 0, 10 ) as $i ) {
+ $dbkey = "Search_Result_$i";
+ $lc->addGoodLinkObj( 6543 + $i, new TitleValue( NS_MAIN, $dbkey ) );
+ self::$completionSearchResult[] = "Search Result $i";
+ }
+ }
+ $results = array_slice( self::$completionSearchResult, $this->offset, $this->limit );
+
+ return SearchSuggestionSet::fromStrings( $results );
+ }
+
+}
* to list of results for that type.
*/
public function __construct( array $results, array $interwikiResults = [] ) {
+ parent::__construct( false, false );
$this->results = $results;
$this->interwikiResults = $interwikiResults;
}