Skip to content

Commit

Permalink
Integrate existing Elasticsearch support to Symfony, API Platform
Browse files Browse the repository at this point in the history
  • Loading branch information
Dalibor Karlović authored and dkarlovi committed Nov 13, 2017
1 parent ba1c67c commit 5e039c9
Show file tree
Hide file tree
Showing 2 changed files with 280 additions and 0 deletions.
149 changes: 149 additions & 0 deletions Elasticsearch/Extension/SearchExtension.php
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];
}
}
131 changes: 131 additions & 0 deletions Tests/Elasticsearch/Extension/SearchExtensionTest.php
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);
}
}

0 comments on commit 5e039c9

Please sign in to comment.