diff --git a/Adapter/SolariumResultPromisePagerfantaAdapter.php b/Adapter/SolariumResultPromisePagerfantaAdapter.php new file mode 100644 index 00000000..801df990 --- /dev/null +++ b/Adapter/SolariumResultPromisePagerfantaAdapter.php @@ -0,0 +1,52 @@ +resultPromise = $resultPromise; + } + + /** + * Returns the number of results. + * + * @return integer The number of results. + */ + public function getNbResults() + { + return $this->getResult()->getNumFound(); + } + + /** + * Returns an slice of the results. + * + * @param integer $offset The offset. + * @param integer $length The length. + * + * @return array|\Traversable The slice. + */ + public function getSlice($offset, $length) + { + // ignore offset/length as that should have been predetermined + return $this->getResult(); + } + + /** + * @return Result + */ + private function getResult() + { + return $this->resultPromise->wait(); + } +} diff --git a/Query/FallbackAsyncQueryTrait.php b/Query/FallbackAsyncQueryTrait.php new file mode 100644 index 00000000..e96ef18e --- /dev/null +++ b/Query/FallbackAsyncQueryTrait.php @@ -0,0 +1,13 @@ +getResult()); + } +} diff --git a/Query/ResolvedSelectQuery.php b/Query/ResolvedSelectQuery.php index 74c87f0e..7a96c958 100644 --- a/Query/ResolvedSelectQuery.php +++ b/Query/ResolvedSelectQuery.php @@ -135,6 +135,14 @@ public function getResult() return $this->getSelectQuery()->getResult(); } + /** + * {@inheritdoc} + */ + public function getResultAsync() + { + return $this->getSelectQuery()->getResultAsync(); + } + /** * {@inheritdoc} */ diff --git a/Query/SelectQueryInterface.php b/Query/SelectQueryInterface.php index c413fa05..fb1d78d7 100644 --- a/Query/SelectQueryInterface.php +++ b/Query/SelectQueryInterface.php @@ -2,6 +2,7 @@ namespace Markup\NeedleBundle\Query; +use GuzzleHttp\Promise\PromiseInterface; use Markup\NeedleBundle\Filter\FilterQueryInterface; use Markup\NeedleBundle\Service\SearchServiceInterface as SearchService; use Markup\NeedleBundle\Spellcheck\SpellcheckInterface; @@ -73,6 +74,13 @@ public function getFacetNamesToExclude(); **/ public function getResult(); + /** + * Gets the result of the query as a promise. + * + * @return PromiseInterface + */ + public function getResultAsync(); + /** * @param SearchService $service **/ diff --git a/Service/AsyncSearchServiceInterface.php b/Service/AsyncSearchServiceInterface.php new file mode 100644 index 00000000..3ff2504b --- /dev/null +++ b/Service/AsyncSearchServiceInterface.php @@ -0,0 +1,17 @@ +getSolariumQueryBuilder(); - - $query = new ResolvedSelectQuery($query, $this->hasContext() ? $this->getContext() : null); - - foreach ($this->decorators as $decorator) { - $query = $decorator->decorate($query); - } - - $solariumQuery = $solariumQueryBuilder->buildSolariumQueryFromGeneric($query); - - if ($query->getGroupingField()) { - $pagerfantaAdapter = new SolariumGroupedQueryPagerfantaAdapter($this->getSolariumClient(), $solariumQuery); - } else { - $pagerfantaAdapter = new SolariumAdapter($this->getSolariumClient(), $solariumQuery); - } - - $pagerfanta = new Pagerfanta($pagerfantaAdapter); - $maxPerPage = $query->getMaxPerPage(); - if (null === $maxPerPage && $this->hasContext() && $query->getPageNumber() !== null) { - $maxPerPage = $this->getContext()->getItemsPerPage() ?: null; - } - $pagerfanta->setMaxPerPage($maxPerPage ?: self::INFINITY); - $page = $query->getPageNumber(); - if ($page) { - $pagerfanta->setCurrentPage($page, false, true); - } - - $result = new PagerfantaResultAdapter($pagerfanta); - $resultClosure = function () use ($pagerfantaAdapter) { - return $pagerfantaAdapter->getResultSet(); - }; - - //set the strategy to fetch facet sets, as these are not handled by pagerfanta - if ($this->hasContext()) { - $result->setFacetSetStrategy( - new SolariumFacetSetsStrategy($resultClosure, $this->getContext(), $query->getRecord()) - ); - } - - //set any spellcheck result - $result->setSpellcheckResultStrategy(new SolariumSpellcheckResultStrategy($resultClosure, $query)); - - //set strategy for debug information output as this is not available through pagerfanta - only if templating service was available - if (null !== $this->templating) { - $result->setDebugOutputStrategy(new SolariumDebugOutputStrategy($resultClosure, $this->templating)); - } + return $this->executeQueryAsync($query)->wait(); + } - return $result; + /** + * Provides a promise for a executing a select query on a service, returning a result. + * + * @param SelectQueryInterface + * @return PromiseInterface + **/ + public function executeQueryAsync(SelectQueryInterface $query) + { + return coroutine( + function () use ($query) { + $solariumQueryBuilder = $this->getSolariumQueryBuilder(); + + $query = new ResolvedSelectQuery($query, $this->hasContext() ? $this->getContext() : null); + + foreach ($this->decorators as $decorator) { + $query = $decorator->decorate($query); + } + $solariumQuery = $solariumQueryBuilder->buildSolariumQueryFromGeneric($query); + + //apply offset/limit + $maxPerPage = $query->getMaxPerPage(); + if (null === $maxPerPage && $this->hasContext() && $query->getPageNumber() !== null) { + $maxPerPage = $this->getContext()->getItemsPerPage() ?: null; + } + $solariumQuery->setRows($maxPerPage ?: self::INFINITY); + + + $page = $query->getPageNumber(); + if ($page && $maxPerPage) { + $solariumQuery->setStart($maxPerPage * ($page-1)); + } + + $pluginIndex = 'async'; + /** @var SolariumAsyncPlugin $asyncPlugin */ + $asyncPlugin = $this->solarium + ->registerPlugin($pluginIndex, new SolariumAsyncPlugin()) + ->getPlugin($pluginIndex); + + $solariumResult = $this->solarium->createResult( + $solariumQuery, + (yield promise_for($asyncPlugin->queryAsync($solariumQuery))) + ); + if ($query->getGroupingField()) { + $solariumResult = new GroupedResultAdapter($solariumResult); + } + + $pagerfanta = new Pagerfanta(new SolariumResultPromisePagerfantaAdapter(promise_for($solariumResult))); + $pagerfanta->setCurrentPage($page ?: 1); + $pagerfanta->setMaxPerPage($maxPerPage ?: self::INFINITY); + + $result = new PagerfantaResultAdapter($pagerfanta); + + $resultClosure = function () use ($solariumResult) { + return $solariumResult; + }; + + //set the strategy to fetch facet sets, as these are not handled by pagerfanta + if ($this->hasContext()) { + $result->setFacetSetStrategy( + new SolariumFacetSetsStrategy($resultClosure, $this->getContext(), $query->getRecord()) + ); + } + + //set any spellcheck result + $result->setSpellcheckResultStrategy(new SolariumSpellcheckResultStrategy($resultClosure, $query)); + + //set strategy for debug information output as this is not available through pagerfanta - only if templating service was available + if (null !== $this->templating) { + $result->setDebugOutputStrategy(new SolariumDebugOutputStrategy($resultClosure, $this->templating)); + } + + yield $result; + } + ); } /** diff --git a/Tests/Adapter/SolariumResultPromisePagerfantaAdapterTest.php b/Tests/Adapter/SolariumResultPromisePagerfantaAdapterTest.php new file mode 100644 index 00000000..1c42e758 --- /dev/null +++ b/Tests/Adapter/SolariumResultPromisePagerfantaAdapterTest.php @@ -0,0 +1,49 @@ +result = m::mock(ResultInterface::class); + $this->adapter = new SolariumResultPromisePagerfantaAdapter(promise_for($this->result)); + } + + public function testIsPagerfantaAdapter() + { + $this->assertInstanceOf(AdapterInterface::class, $this->adapter); + } + + public function testGetSliceReturnsResultRegardlessOfInputs() + { + $this->assertSame($this->result, $this->adapter->getSlice(21, 20)); + } + + public function testGetNbResultsUsesNumFound() + { + $count = 42; + $this->result + ->shouldReceive('getNumFound') + ->andReturn($count); + $this->assertEquals($count, $this->adapter->getNbResults()); + } +} diff --git a/Tests/Query/RecordableSelectQueryInterfaceTest.php b/Tests/Query/RecordableSelectQueryInterfaceTest.php index c92d3a9a..4dce1801 100644 --- a/Tests/Query/RecordableSelectQueryInterfaceTest.php +++ b/Tests/Query/RecordableSelectQueryInterfaceTest.php @@ -2,6 +2,8 @@ namespace Markup\NeedleBundle\Tests\Query; +use Markup\NeedleBundle\Query\RecordableSelectQueryInterface; + /** * A test for a recordable select query interface. */ @@ -20,6 +22,7 @@ public function testHasCorrectPublicMethods() 'hasSortCollection', 'getFacetNamesToExclude', 'getResult', + 'getResultAsync', 'setSearchService', 'record', 'getRecord', @@ -32,7 +35,7 @@ public function testHasCorrectPublicMethods() 'getGroupingField', 'getGroupingSortCollection' ]; - $query = new \ReflectionClass('Markup\NeedleBundle\Query\RecordableSelectQueryInterface'); + $query = new \ReflectionClass(RecordableSelectQueryInterface::class); $actual_public_methods = []; foreach ($query->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { $actual_public_methods[] = $method->name; diff --git a/Tests/Query/ResolvedSelectQueryInterfaceTest.php b/Tests/Query/ResolvedSelectQueryInterfaceTest.php index 6b18d859..ab59eb7e 100644 --- a/Tests/Query/ResolvedSelectQueryInterfaceTest.php +++ b/Tests/Query/ResolvedSelectQueryInterfaceTest.php @@ -2,6 +2,8 @@ namespace Markup\NeedleBundle\Tests\Query; +use Markup\NeedleBundle\Query\ResolvedSelectQueryInterface; + /** * A test for a select query interface. */ @@ -20,6 +22,7 @@ public function testHasCorrectPublicMethods() 'hasSortCollection', 'getFacetNamesToExclude', 'getResult', + 'getResultAsync', 'setSearchService', 'getFilterQueryWithKey', 'doesValueExistInFilterQueries', @@ -37,7 +40,7 @@ public function testHasCorrectPublicMethods() 'getGroupingField', 'getGroupingSortCollection' ]; - $query = new \ReflectionClass('Markup\NeedleBundle\Query\ResolvedSelectQueryInterface'); + $query = new \ReflectionClass(ResolvedSelectQueryInterface::class); $actual_public_methods = []; foreach ($query->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { $actual_public_methods[] = $method->name; diff --git a/Tests/Query/SelectQueryInterfaceTest.php b/Tests/Query/SelectQueryInterfaceTest.php index ffae6b55..92544626 100644 --- a/Tests/Query/SelectQueryInterfaceTest.php +++ b/Tests/Query/SelectQueryInterfaceTest.php @@ -2,6 +2,8 @@ namespace Markup\NeedleBundle\Tests\Query; +use Markup\NeedleBundle\Query\SelectQueryInterface; + /** * A test for a select query interface. */ @@ -20,6 +22,7 @@ public function testHasCorrectPublicMethods() 'hasSortCollection', 'getFacetNamesToExclude', 'getResult', + 'getResultAsync', 'setSearchService', 'getFilterQueryWithKey', 'doesValueExistInFilterQueries', @@ -29,7 +32,7 @@ public function testHasCorrectPublicMethods() 'getGroupingField', 'getGroupingSortCollection', ]; - $query = new \ReflectionClass('Markup\NeedleBundle\Query\SelectQueryInterface'); + $query = new \ReflectionClass(SelectQueryInterface::class); $actual_public_methods = []; foreach ($query->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { $actual_public_methods[] = $method->name; diff --git a/Tests/Query/SettableSelectQuery.php b/Tests/Query/SettableSelectQuery.php index b44c4903..1380b548 100644 --- a/Tests/Query/SettableSelectQuery.php +++ b/Tests/Query/SettableSelectQuery.php @@ -3,6 +3,7 @@ namespace Markup\NeedleBundle\Tests\Query; use Markup\NeedleBundle\Filter\FilterQueryInterface; +use Markup\NeedleBundle\Query\FallbackAsyncQueryTrait; use Markup\NeedleBundle\Query\SelectQueryInterface; use Markup\NeedleBundle\Service\SearchServiceInterface as SearchService; use Markup\NeedleBundle\Sort\SortCollection; @@ -10,6 +11,8 @@ class SettableSelectQuery implements SelectQueryInterface { + use FallbackAsyncQueryTrait; + /** * @var string */ diff --git a/Tests/Service/SolrSearchServiceTest.php b/Tests/Service/SolrSearchServiceTest.php index 726a92f8..fe006ae0 100644 --- a/Tests/Service/SolrSearchServiceTest.php +++ b/Tests/Service/SolrSearchServiceTest.php @@ -2,53 +2,92 @@ namespace Markup\NeedleBundle\Tests\Service; +use function GuzzleHttp\Promise\promise_for; use Markup\NeedleBundle\Builder\SolariumSelectQueryBuilder; use Markup\NeedleBundle\Service\SolrSearchService; use Mockery as m; +use Mockery\Adapter\Phpunit\MockeryTestCase; +use Shieldo\SolariumAsyncPlugin; use Solarium\Client; +use Markup\NeedleBundle\Query\ResolvedSelectQueryDecoratorInterface; +use Markup\NeedleBundle\Query\ResolvedSelectQueryInterface; +use Markup\NeedleBundle\Query\SelectQueryInterface; +use Markup\NeedleBundle\Result\ResultInterface; +use Markup\NeedleBundle\Service\AsyncSearchServiceInterface; +use Markup\NeedleBundle\Service\SearchServiceInterface; +use Solarium\QueryType\Select\Query\Query; +use Solarium\QueryType\Select\Result\Result; /** * A test for a search service using Solr/ Solarium. */ -class SolrSearchServiceTest extends \PHPUnit_Framework_TestCase +class SolrSearchServiceTest extends MockeryTestCase { - public function setUp() + /** + * @var Client|m\MockInterface + */ + private $solarium; + + /** + * @var SolariumSelectQueryBuilder|m\MockInterface + */ + private $solariumQueryBuilder; + + /** + * @var SolariumAsyncPlugin|m\MockInterface + */ + private $promisePlugin; + + /** + * @var SolrSearchService + */ + private $service; + + protected function setUp() { - $this->solarium = $this->createMock(Client::class); - $this->solariumQueryBuilder = $this->createMock(SolariumSelectQueryBuilder::class); + $this->solarium = $this->getMockSolariumClient(); + $this->solariumQueryBuilder = m::mock(SolariumSelectQueryBuilder::class); + $this->promisePlugin = m::mock(SolariumAsyncPlugin::class); + $this->promisePlugin + ->shouldReceive('queryAsync') + ->andReturn(promise_for(m::mock(Result::class))); + $this->solarium + ->shouldReceive('getPlugin') + ->with('async') + ->andReturn($this->promisePlugin); $this->service = new SolrSearchService($this->solarium, $this->solariumQueryBuilder); } - public function tearDown() + public function testIsSearchService() { - m::close(); + $this->assertInstanceOf(SearchServiceInterface::class, $this->service); } - public function testIsSearchService() + public function testIsAsync() { - $this->assertTrue($this->service instanceof \Markup\NeedleBundle\Service\SearchServiceInterface); + $this->assertInstanceOf(AsyncSearchServiceInterface::class, $this->service); } public function testExecuteQuery() { - $genericQuery = $this->createMock('Markup\NeedleBundle\Query\SelectQueryInterface'); - $solariumQuery = $this->createMock('Solarium\QueryType\Select\Query\Query'); + $genericQuery = m::mock(SelectQueryInterface::class)->shouldIgnoreMissing(); + $solariumQuery = m::mock(Query::class)->shouldIgnoreMissing(); $this->solariumQueryBuilder - ->expects($this->atLeastOnce()) - ->method('buildSolariumQueryFromGeneric') - ->will($this->returnValue($solariumQuery)); - $solariumResult = $this->createMock('Solarium\QueryType\Select\Result\Result'); + ->shouldReceive('buildSolariumQueryFromGeneric') + ->andReturn($solariumQuery); + $solariumResult = m::mock(Result::class); $this->solarium - ->expects($this->any()) - ->method('select') - ->will($this->returnValue($solariumResult)); - $this->assertInstanceOf('Markup\NeedleBundle\Result\ResultInterface', $this->service->executeQuery($genericQuery)); + ->shouldReceive('createResult') + ->andReturn($solariumResult); + $this->assertInstanceOf(ResultInterface::class, $this->service->executeQuery($genericQuery)); } public function testCanAddDecorator() { - $decorator = m::mock('Markup\NeedleBundle\Query\ResolvedSelectQueryDecoratorInterface'); - $decorated = m::mock('Markup\NeedleBundle\Query\ResolvedSelectQueryInterface'); + /** @var ResolvedSelectQueryDecoratorInterface|m\MockInterface $decorator */ + $decorator = m::mock(ResolvedSelectQueryDecoratorInterface::class); + /** @var ResolvedSelectQueryInterface|m\MockInterface $decorated */ + $decorated = m::mock(ResolvedSelectQueryInterface::class); $decorated->shouldReceive('getSearchTerm')->andReturn('I have been decorated'); $decorated->shouldReceive('getMaxPerPage')->andReturn(10); @@ -59,21 +98,29 @@ public function testCanAddDecorator() $this->service->addDecorator($decorator); - $genericQuery = $this->createMock('Markup\NeedleBundle\Query\SelectQueryInterface'); + $genericQuery = m::mock(SelectQueryInterface::class); + $solariumQuery = m::mock(Query::class)->shouldIgnoreMissing(); - $solariumQuery = $this->createMock('Solarium\QueryType\Select\Query\Query'); $this->solariumQueryBuilder - ->expects($this->atLeastOnce()) - ->method('buildSolariumQueryFromGeneric') - ->with($this->callback(function ($query) { + ->shouldReceive('buildSolariumQueryFromGeneric') + ->with(m::on(function ($query) { return $query->getSearchTerm() === 'I have been decorated'; })) - ->will($this->returnValue($solariumQuery)); - $solariumResult = $this->createMock('Solarium\QueryType\Select\Result\Result'); + ->andReturn($solariumQuery); + $solariumResult = m::mock(Result::class); $this->solarium - ->expects($this->any()) - ->method('select') - ->will($this->returnValue($solariumResult)); - $this->assertInstanceOf('Markup\NeedleBundle\Result\ResultInterface', $this->service->executeQuery($genericQuery)); + ->shouldReceive('createResult') + ->andReturn($solariumResult); + $this->assertInstanceOf(ResultInterface::class, $this->service->executeQuery($genericQuery)); + } + + private function getMockSolariumClient() + { + $solarium = m::mock(Client::class); + $solarium + ->shouldReceive('registerPlugin') + ->andReturnSelf(); + + return $solarium; } } diff --git a/composer.json b/composer.json index 080d10c1..bcc60b55 100644 --- a/composer.json +++ b/composer.json @@ -19,18 +19,19 @@ "nelmio/solarium-bundle": "^2.0.4", "solarium/solarium": "^3.1.0", "pagerfanta/pagerfanta": "~1.0.1", - "symfony/framework-bundle": "~2.3|~3.0", + "symfony/framework-bundle": "~2.7|~3.0", "twig/twig": "~1.12|~2.0", "doctrine/collections": "~1.1", - "guzzlehttp/guzzle": "~6.0" + "guzzlehttp/guzzle": "~6.1", + "shieldo/solarium-async-plugin": "^0.2" }, "require-dev": { "phpunit/phpunit": "5.7.*", "mockery/mockery": "~0.9.9", "zendframework/zenddiagnostics": "~1.0.2", "doctrine/orm": "~2.4", - "symfony/templating": "*", - "symfony/translation": "*" + "symfony/templating": "^2.7|^3", + "symfony/translation": "^2.7|^3" }, "autoload": { "psr-0": { "Markup\\NeedleBundle": "" }