-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Integrate existing Elasticsearch support to Symfony, API Platform
- Loading branch information
Showing
2 changed files
with
280 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,149 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
/* | ||
* This file is part of the RollerworksSearch package. | ||
* | ||
* (c) Sebastiaan Stok <s.stok@rollerscapes.net> | ||
* | ||
* This source file is subject to the MIT license that is bundled | ||
* with this source code in the file LICENSE. | ||
*/ | ||
|
||
namespace Rollerworks\Component\Search\ApiPlatform\Elasticsearch\Extension; | ||
|
||
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface; | ||
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; | ||
use Doctrine\Common\Persistence\ManagerRegistry; | ||
use Doctrine\ORM\QueryBuilder; | ||
use Elastica\Client; | ||
use Elastica\Document; | ||
use Elastica\Query; | ||
use Elastica\Search; | ||
use Rollerworks\Component\Search\ApiPlatform\ArrayKeysValidator; | ||
use Rollerworks\Component\Search\Elasticsearch\ElasticsearchFactory; | ||
use Rollerworks\Component\Search\Exception\BadMethodCallException; | ||
use Rollerworks\Component\Search\SearchCondition; | ||
use Symfony\Component\HttpFoundation\RequestStack; | ||
|
||
/** | ||
* Class SearchExtension. | ||
*/ | ||
class SearchExtension implements QueryCollectionExtensionInterface | ||
{ | ||
private $requestStack; | ||
private $registry; | ||
private $elasticsearchFactory; | ||
private $client; | ||
private $identifierNames = []; | ||
|
||
public function __construct(RequestStack $requestStack, ManagerRegistry $registry, ElasticsearchFactory $elasticsearchFactory, Client $client) | ||
{ | ||
$this->requestStack = $requestStack; | ||
$this->registry = $registry; | ||
$this->elasticsearchFactory = $elasticsearchFactory; | ||
$this->client = $client; | ||
} | ||
|
||
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) | ||
{ | ||
$request = $this->requestStack->getCurrentRequest(); | ||
|
||
/** @var SearchCondition $condition */ | ||
if (!$request || null === $condition = $request->attributes->get('_api_search_condition')) { | ||
return; | ||
} | ||
|
||
$context = $request->attributes->get('_api_search_context'); | ||
$configuration = $request->attributes->get('_api_search_config'); | ||
$configPath = "{$resourceClass}#attributes[rollerworks_search][contexts][{$context}][elasticsearch]"; | ||
|
||
if (empty($configuration['elasticsearch'])) { | ||
return; | ||
} | ||
|
||
/** @var String[][] $configuration */ | ||
$configuration = (array) $configuration['elasticsearch']; | ||
ArrayKeysValidator::assertOnlyKeys($configuration, ['mappings', 'identifiers_normalizer'], $configPath); | ||
|
||
// this snippet looks weird, factory should create the proper instance on its own | ||
$conditionGenerator = $this->elasticsearchFactory->createCachedConditionGenerator( | ||
$this->elasticsearchFactory->createConditionGenerator($condition) | ||
); | ||
|
||
foreach ($configuration['mappings'] as $fieldName => $mapping) { | ||
$conditionGenerator->registerField($fieldName, $mapping); | ||
} | ||
|
||
$normalizer = null; | ||
if (array_key_exists('identifiers_normalizer', $configuration)) { | ||
$normalizer = $configuration['identifiers_normalizer']; | ||
if (!\is_callable($normalizer)) { | ||
throw new BadMethodCallException('Parameter "identifiers_normalizer" must be a valid callable'); | ||
} | ||
} | ||
|
||
// TODO: temporary, how to do this better? | ||
$query = new Query($conditionGenerator->getQuery()); | ||
|
||
// move limit/offset from QueryBuilder to Elasticsearch query | ||
if (null !== $firstResult = $queryBuilder->getFirstResult()) { | ||
$query->setFrom($firstResult); | ||
$queryBuilder->setFirstResult(null); | ||
} | ||
if (null !== $maxResults = $queryBuilder->getMaxResults()) { | ||
$query->setSize($maxResults); | ||
$queryBuilder->setMaxResults(null); | ||
} | ||
|
||
$search = new Search($this->client); | ||
$mappings = $conditionGenerator->getMappings(); | ||
foreach ($mappings as $mapping) { | ||
$index = $this->client->getIndex($mapping->indexName); | ||
$type = $index->getType($mapping->typeName); | ||
$search | ||
->addIndex($index) | ||
->addType($type); | ||
} | ||
$response = $search->search($query); | ||
|
||
// NOTE: written like this so we only check if we have a normalizer once | ||
if (null !== $normalizer) { | ||
$callable = function (Document $document) use ($normalizer) { | ||
return \call_user_func($normalizer, $document->getId()); | ||
}; | ||
} else { | ||
$callable = function (Document $document) { | ||
return $document->getId(); | ||
}; | ||
} | ||
$ids = array_map($callable, $response->getDocuments()); | ||
|
||
// straight from FOS Elastica Bundle | ||
$rootAlias = $queryBuilder->getRootAliases()[0]; | ||
$identifier = $this->getIdentifierNames($resourceClass); | ||
|
||
// TODO: hack, only works for non-composite PKs | ||
$identifier = current($identifier); | ||
$queryBuilder | ||
->andWhere( | ||
$queryBuilder | ||
->expr() | ||
->in($rootAlias.'.'.$identifier, ':ids') | ||
) | ||
->setParameter('ids', $ids); | ||
} | ||
|
||
private function getIdentifierNames(string $class): array | ||
{ | ||
if (!array_key_exists($class, $this->identifierNames)) { | ||
$manager = $this->registry->getManagerForClass($class); | ||
$metadata = $manager->getClassMetadata($class); | ||
|
||
$this->identifierNames[$class] = $metadata->getIdentifier(); | ||
} | ||
|
||
return $this->identifierNames[$class]; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
<?php | ||
|
||
declare(strict_types = 1); | ||
|
||
/* | ||
* This file is part of the RollerworksSearch package. | ||
* | ||
* (c) Sebastiaan Stok <s.stok@rollerscapes.net> | ||
* | ||
* This source file is subject to the MIT license that is bundled | ||
* with this source code in the file LICENSE. | ||
*/ | ||
|
||
namespace Rollerworks\Component\Search\ApiPlatform\Tests\Elasticsearch\Extension; | ||
|
||
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGenerator; | ||
use Doctrine\Common\Persistence\ManagerRegistry; | ||
use Doctrine\Common\Persistence\Mapping\ClassMetadata; | ||
use Doctrine\Common\Persistence\ObjectManager; | ||
use Doctrine\ORM\Query\Expr; | ||
use Doctrine\ORM\QueryBuilder; | ||
use Elastica\Client; | ||
use Elastica\Response; | ||
use PHPUnit\Framework\TestCase; | ||
use Rollerworks\Component\Search\ApiPlatform\Elasticsearch\Extension\SearchExtension; | ||
use Rollerworks\Component\Search\ApiPlatform\Tests\Fixtures\Dummy; | ||
use Rollerworks\Component\Search\Elasticsearch\CachedConditionGenerator; | ||
use Rollerworks\Component\Search\Elasticsearch\ElasticsearchFactory; | ||
use Rollerworks\Component\Search\Elasticsearch\QueryConditionGenerator; | ||
use Rollerworks\Component\Search\FieldSet; | ||
use Rollerworks\Component\Search\SearchCondition; | ||
use Rollerworks\Component\Search\Value\ValuesGroup; | ||
use Symfony\Component\HttpFoundation\Request; | ||
use Symfony\Component\HttpFoundation\RequestStack; | ||
|
||
/** @internal */ | ||
class SearchExtensionTest extends TestCase | ||
{ | ||
public function testApplyToCollectionWithValidCondition() | ||
{ | ||
$query = ['query' => ['bool' => ['must' => 'foo']]]; | ||
$ids = [3, 1, 5]; | ||
|
||
$elasticaResponse = $this->createResponse($ids); | ||
$searchCondition = $this->createCondition(); | ||
|
||
$queryFunctionProphecy = $this->prophesize(Expr\Func::class); | ||
$queryFunction = $queryFunctionProphecy->reveal(); | ||
|
||
$queryExpressionProphecy = $this->prophesize(Expr::class); | ||
$queryExpressionProphecy->in('o.id', ':ids')->willReturn($queryFunction); | ||
$queryExpression = $queryExpressionProphecy->reveal(); | ||
|
||
$queryBuilderProphecy = $this->prophesize(QueryBuilder::class); | ||
$queryBuilderProphecy->andWhere($queryFunction)->willReturn($queryBuilderProphecy); | ||
$queryBuilderProphecy->expr()->willReturn($queryExpression); | ||
$queryBuilderProphecy->setParameter('ids', $ids)->shouldBeCalled(); | ||
$queryBuilderProphecy->getFirstResult()->shouldBeCalled(); | ||
$queryBuilderProphecy->getMaxResults()->shouldBeCalled(); | ||
$queryBuilderProphecy->getRootAliases()->willReturn(['o']); | ||
$queryBuilder = $queryBuilderProphecy->reveal(); | ||
|
||
$classMetadataProphecy = $this->prophesize(ClassMetadata::class); | ||
$classMetadataProphecy->getIdentifier()->willReturn(['id']); | ||
$classMetadata = $classMetadataProphecy->reveal(); | ||
|
||
$managerProphecy = $this->prophesize(ObjectManager::class); | ||
$managerProphecy->getClassMetadata(Dummy::class)->willReturn($classMetadata); | ||
$manager = $managerProphecy->reveal(); | ||
|
||
$managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); | ||
$managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($manager); | ||
$managerRegistry = $managerRegistryProphecy->reveal(); | ||
|
||
$elasticaClientProphecy = $this->prophesize(Client::class); | ||
$elasticaClientProphecy->request('/_search', 'GET', $query, [])->willReturn($elasticaResponse); | ||
$elasticaClient = $elasticaClientProphecy->reveal(); | ||
|
||
$conditionGeneratorProphecy = $this->prophesize(QueryConditionGenerator::class); | ||
$conditionGenerator = $conditionGeneratorProphecy->reveal(); | ||
|
||
$cachedConditionGeneratorProphecy = $this->prophesize(CachedConditionGenerator::class); | ||
$cachedConditionGeneratorProphecy->registerField('dummy-id', 'id')->shouldBeCalled(); | ||
$cachedConditionGeneratorProphecy->registerField('dummy-name', 'name')->shouldBeCalled(); | ||
$cachedConditionGeneratorProphecy->getQuery()->willReturn($query); | ||
$cachedConditionGeneratorProphecy->getMappings()->shouldBeCalled(); | ||
$cachedConditionGenerator = $cachedConditionGeneratorProphecy->reveal(); | ||
|
||
$elasticsearchFactoryProphecy = $this->prophesize(ElasticsearchFactory::class); | ||
$elasticsearchFactoryProphecy->createConditionGenerator($searchCondition)->willReturn($conditionGenerator); | ||
$elasticsearchFactoryProphecy->createCachedConditionGenerator($conditionGenerator)->willReturn($cachedConditionGenerator); | ||
$elasticsearchFactory = $elasticsearchFactoryProphecy->reveal(); | ||
|
||
$request = new Request([], [], [ | ||
'_api_search_condition' => $searchCondition, | ||
'_api_search_context' => 'dummy', | ||
'_api_search_config' => [ | ||
'elasticsearch' => [ | ||
'mappings' => [ | ||
'dummy-id' => 'id', | ||
'dummy-name' => 'name', | ||
], | ||
], | ||
], | ||
]); | ||
|
||
$requestStack = new RequestStack(); | ||
$requestStack->push($request); | ||
|
||
$orderExtensionTest = new SearchExtension($requestStack, $managerRegistry, $elasticsearchFactory, $elasticaClient); | ||
$orderExtensionTest->applyToCollection($queryBuilder, new QueryNameGenerator(), Dummy::class, 'get'); | ||
} | ||
|
||
private function createCondition(?string $setName = 'dummy_fieldset'): SearchCondition | ||
{ | ||
$fieldSetProphecy = $this->prophesize(FieldSet::class); | ||
$fieldSetProphecy->getSetName()->willReturn($setName); | ||
|
||
return new SearchCondition($fieldSetProphecy->reveal(), new ValuesGroup()); | ||
} | ||
|
||
private function createResponse($ids): Response | ||
{ | ||
$response = []; | ||
foreach ($ids as $id) { | ||
$response['hits']['hits'][]['_id'] = $id; | ||
} | ||
|
||
return new Response($response); | ||
} | ||
} |