diff --git a/Doctrine/AbstractLookup.php b/Doctrine/AbstractLookup.php new file mode 100644 index 0000000..6560bab --- /dev/null +++ b/Doctrine/AbstractLookup.php @@ -0,0 +1,22 @@ +registry = $registry; + } +} diff --git a/Doctrine/MongoDB/Lookup.php b/Doctrine/MongoDB/Lookup.php new file mode 100644 index 0000000..6c48759 --- /dev/null +++ b/Doctrine/MongoDB/Lookup.php @@ -0,0 +1,49 @@ +createQueryBuilder($configuration); + $qb->hydrate($configuration->isHydrate()); + + $qb->field($configuration->getIdentifierProperty()) + ->in($ids); + + return $qb->getQuery()->execute()->toArray(); + } + + /** + * @param TypeConfigurationInterface $configuration + * @return \Doctrine\ODM\MongoDB\Query\Builder + */ + private function createQueryBuilder(TypeConfigurationInterface $configuration) + { + $method = $configuration->getRepositoryMethod(); + $manager = $this->registry->getManagerForClass($configuration->getModelClass()); + + return $manager->{$method}($configuration->getModelClass()); + } +} diff --git a/Doctrine/ORM/Lookup.php b/Doctrine/ORM/Lookup.php new file mode 100644 index 0000000..2e2cfe3 --- /dev/null +++ b/Doctrine/ORM/Lookup.php @@ -0,0 +1,58 @@ +isHydrate() ? + Query::HYDRATE_OBJECT : + Query::HYDRATE_ARRAY; + + $qb = $this->createQueryBuilder($configuration); + + $qb->andWhere($qb->expr()->in( + sprintf('%s.%s', static::ENTITY_ALIAS, $configuration->getIdentifierProperty()), + ':identifiers' + )); + $qb->setParameter('identifiers', $ids); + + return $qb->getQuery()->execute(array(), $hydrationMode); + } + + /** + * @param TypeConfigurationInterface $configuration + * @return \Doctrine\ORM\QueryBuilder + */ + private function createQueryBuilder(TypeConfigurationInterface $configuration) + { + $repository = $this->registry->getRepository($configuration->getModelClass()); + $method = $configuration->getRepositoryMethod(); + + return $repository->{$method}(static::ENTITY_ALIAS); + } +} diff --git a/Elastica/LoggingClient.php b/Elastica/LoggingClient.php index 0ff1997..faecdfc 100644 --- a/Elastica/LoggingClient.php +++ b/Elastica/LoggingClient.php @@ -5,6 +5,7 @@ namespace FOS\ElasticaBundle\Elastica; use Elastica\Client as Client; use Elastica\Request; use FOS\ElasticaBundle\Logger\ElasticaLogger; +use FOS\ElasticaBundle\Transformer\CombinedResultTransformer; /** * Extends the default Elastica client to provide logging for errors that occur @@ -14,6 +15,38 @@ use FOS\ElasticaBundle\Logger\ElasticaLogger; */ class LoggingClient extends Client { + /** + * @var CombinedResultTransformer + */ + private $resultTransformer; + + public function __construct(array $config = array(), $callback = null, CombinedResultTransformer $resultTransformer) + { + parent::__construct($config, $callback); + + $this->resultTransformer = $resultTransformer; + } + + /** + * Overridden Elastica method to return TransformingIndex instances instead of the + * default Index instances. + * + * @param string $name + * @return TransformingIndex + */ + public function getIndex($name) + { + return new TransformingIndex($this, $name, $this->resultTransformer); + } + + /** + * @return CombinedResultTransformer + */ + public function getResultTransformer() + { + return $this->resultTransformer; + } + /** * {@inheritdoc} */ @@ -39,9 +72,4 @@ class LoggingClient extends Client return $response; } - - public function getIndex($name) - { - return new DynamicIndex($this, $name); - } } diff --git a/Elastica/TransformingIndex.php b/Elastica/TransformingIndex.php index a0cc001..2bab636 100644 --- a/Elastica/TransformingIndex.php +++ b/Elastica/TransformingIndex.php @@ -1,8 +1,11 @@ getClient()); + $search = new TransformingSearch($this->getClient()); $search->addIndex($this); $search->setOptionsAndQuery($options, $query); return $search; } + /** + * Returns a type object for the current index with the given name + * + * @param string $type Type name + * @return TransformingType Type object + */ + public function getType($type) + { + return new TransformingType($this, $type); + } + /** * Reassign index name * @@ -35,8 +50,6 @@ class TransformingIndex extends Index * since it's used for a very specific case and normally should not be used * * @param string $name Index name - * - * @return void */ public function overrideName($name) { diff --git a/Elastica/TransformingResult.php b/Elastica/TransformingResult.php new file mode 100644 index 0000000..08d09bc --- /dev/null +++ b/Elastica/TransformingResult.php @@ -0,0 +1,51 @@ +resultSet = $resultSet; + } + + /** + * Returns the transformed result of the hit. + * + * @return mixed + */ + public function getTransformed() + { + if (null === $this->transformed) { + $this->resultSet->transform(); + } + + return $this->transformed; + } + + /** + * An internal method used to set the transformed result on the Result. + * + * @internal + */ + public function setTransformed($transformed) + { + $this->transformed = $transformed; + } +} diff --git a/Elastica/TransformingResultSet.php b/Elastica/TransformingResultSet.php new file mode 100644 index 0000000..c775cd3 --- /dev/null +++ b/Elastica/TransformingResultSet.php @@ -0,0 +1,82 @@ +resultTransformer = $resultTransformer; + } + + /** + * Overridden default method to set our TransformingResult objects. + * + * @param \Elastica\Response $response Response object + */ + protected function _init(Response $response) + { + $this->_response = $response; + $result = $response->getData(); + $this->_totalHits = isset($result['hits']['total']) ? $result['hits']['total'] : 0; + $this->_maxScore = isset($result['hits']['max_score']) ? $result['hits']['max_score'] : 0; + $this->_took = isset($result['took']) ? $result['took'] : 0; + $this->_timedOut = !empty($result['timed_out']); + if (isset($result['hits']['hits'])) { + foreach ($result['hits']['hits'] as $hit) { + $this->_results[] = new TransformingResult($hit, $this); + } + } + } + + /** + * Returns an array of transformed results. + * + * @return object[] + */ + public function getTransformed() + { + $this->transform(); + + return array_map(function (TransformingResult $result) { + return $result->getTransformed(); + }, $this->getResults()); + } + + /** + * Triggers the transformation of all Results. + */ + public function transform() + { + if ($this->transformed) { + return; + } + + if (!$this->count()) { + return; + } + + $this->resultTransformer->transform($this->getResults()); + $this->transformed = true; + } +} diff --git a/Elastica/TransformingSearch.php b/Elastica/TransformingSearch.php new file mode 100644 index 0000000..eee1a96 --- /dev/null +++ b/Elastica/TransformingSearch.php @@ -0,0 +1,73 @@ +value) + * @throws \Elastica\Exception\InvalidException + * @return TransformingResultSet + */ + public function search($query = '', $options = null) + { + $this->setOptionsAndQuery($options, $query); + + $query = $this->getQuery(); + $path = $this->getPath(); + + $params = $this->getOptions(); + + // Send scroll_id via raw HTTP body to handle cases of very large (> 4kb) ids. + if ('_search/scroll' == $path) { + $data = $params[self::OPTION_SCROLL_ID]; + unset($params[self::OPTION_SCROLL_ID]); + } else { + $data = $query->toArray(); + } + + $response = $this->getClient()->request( + $path, + Request::GET, + $data, + $params + ); + + return new TransformingResultSet($response, $query, $this->_client->getResultTransformer()); + } + + /** + * + * @param mixed $query + * @param $fullResult (default = false) By default only the total hit count is returned. If set to true, the full ResultSet including facets is returned. + * @return int|TransformingResultSet + */ + public function count($query = '', $fullResult = false) + { + $this->setOptionsAndQuery(null, $query); + + $query = $this->getQuery(); + $path = $this->getPath(); + + $response = $this->getClient()->request( + $path, + Request::GET, + $query->toArray(), + array(self::OPTION_SEARCH_TYPE => self::OPTION_SEARCH_TYPE_COUNT) + ); + $resultSet = new TransformingResultSet($response, $query, $this->_client->getResultTransformer()); + + return $fullResult ? $resultSet : $resultSet->getTotalHits(); + } +} diff --git a/Elastica/TransformingType.php b/Elastica/TransformingType.php new file mode 100644 index 0000000..f359c24 --- /dev/null +++ b/Elastica/TransformingType.php @@ -0,0 +1,25 @@ +getId() . '/_mlt'; + $query = Query::create($query); + $response = $this->request($path, Request::GET, $query->toArray(), $params); + + return new TransformingResultSet($response, $query, $this->_index->getClient()->getResultTransformer()); + } +} diff --git a/Exception/MissingModelException.php b/Exception/MissingModelException.php new file mode 100644 index 0000000..cfbb750 --- /dev/null +++ b/Exception/MissingModelException.php @@ -0,0 +1,15 @@ +createQuery($configuration, $ids); + + if (!$configuration->isHydrate()) { + return $query->toArray(); + } + + return $query->find(); + } + + /** + * Create a query to use in the findByIdentifiers() method. + * + * @param TypeConfigurationInterface $configuration + * @param array $ids + * @return \ModelCriteria + */ + protected function createQuery(TypeConfigurationInterface $configuration, array $ids) + { + $queryClass = $configuration->getModelClass() . 'Query'; + $query = $queryClass::create(); + $filterMethod = 'filterBy' . Inflector::camelize($configuration->getIdentifierProperty()); + + return $query->$filterMethod($ids); + } +} diff --git a/Tests/Elastica/LoggingClientTest.php b/Tests/Elastica/LoggingClientTest.php index b08a2cf..0b3e71d 100644 --- a/Tests/Elastica/LoggingClientTest.php +++ b/Tests/Elastica/LoggingClientTest.php @@ -4,9 +4,33 @@ namespace FOS\ElasticaBundle\Tests\Client; use Elastica\Request; use Elastica\Transport\Null as NullTransport; +use FOS\ElasticaBundle\Elastica\LoggingClient; class LoggingClientTest extends \PHPUnit_Framework_TestCase { + public function testOverriddenElasticaMethods() + { + $resultTransformer = $this->getMockBuilder('FOS\ElasticaBundle\Transformer\CombinedResultTransformer') + ->disableOriginalConstructor() + ->getMock(); + $client = new LoggingClient(array(), null, $resultTransformer); + $index = $client->getIndex('index'); + $type = $index->getType('type'); + + $this->assertInstanceOf('FOS\ElasticaBundle\Elastica\TransformingIndex', $index); + $this->assertInstanceOf('FOS\ElasticaBundle\Elastica\TransformingType', $type); + } + + public function testGetResultTransformer() + { + $resultTransformer = $this->getMockBuilder('FOS\ElasticaBundle\Transformer\CombinedResultTransformer') + ->disableOriginalConstructor() + ->getMock(); + $client = new LoggingClient(array(), null, $resultTransformer); + + $this->assertSame($resultTransformer, $client->getResultTransformer()); + } + public function testRequestsAreLogged() { $transport = new NullTransport; @@ -29,6 +53,7 @@ class LoggingClientTest extends \PHPUnit_Framework_TestCase ); $client = $this->getMockBuilder('FOS\ElasticaBundle\Elastica\LoggingClient') + ->disableOriginalConstructor() ->setMethods(array('getConnection')) ->getMock(); diff --git a/Tests/Elastica/TransformingIndexTest.php b/Tests/Elastica/TransformingIndexTest.php new file mode 100644 index 0000000..e652119 --- /dev/null +++ b/Tests/Elastica/TransformingIndexTest.php @@ -0,0 +1,42 @@ +index->createSearch(); + + $this->assertInstanceOf('FOS\ElasticaBundle\Elastica\TransformingSearch', $search); + } + + public function testOverrideName() + { + $this->assertEquals('testindex', $this->index->getName()); + + $this->index->overrideName('newindex'); + + $this->assertEquals('newindex', $this->index->getName()); + } + + protected function setUp() + { + $this->client = $this->getMockBuilder('FOS\ElasticaBundle\Elastica\LoggingClient') + ->disableOriginalConstructor() + ->getMock(); + $this->index = new TransformingIndex($this->client, 'testindex'); + } +} diff --git a/Tests/Elastica/TransformingResultSetTest.php b/Tests/Elastica/TransformingResultSetTest.php new file mode 100644 index 0000000..9b9b38a --- /dev/null +++ b/Tests/Elastica/TransformingResultSetTest.php @@ -0,0 +1,42 @@ + array( + 'hits' => array( + array(), + array(), + array(), + ) + ))); + $query = new Query(); + $transformer = $this->getMockBuilder('FOS\ElasticaBundle\Transformer\CombinedResultTransformer') + ->disableOriginalConstructor() + ->getMock(); + + $resultSet = new TransformingResultSet($response, $query, $transformer); + + $this->assertCount(3, $resultSet); + $this->assertInstanceOf('FOS\ElasticaBundle\Elastica\TransformingResult', $resultSet[0]); + + $transformer->expects($this->once()) + ->method('transform') + ->with($resultSet->getResults()); + + $resultSet->transform(); + $resultSet->transform(); + + $this->assertSame(array( + 0 => null, 1 => null, 2 => null + ), $resultSet->getTransformed()); + } +} diff --git a/Tests/Elastica/TransformingResultTest.php b/Tests/Elastica/TransformingResultTest.php new file mode 100644 index 0000000..286b7d1 --- /dev/null +++ b/Tests/Elastica/TransformingResultTest.php @@ -0,0 +1,22 @@ +getMockBuilder('FOS\ElasticaBundle\Elastica\TransformingResultSet') + ->disableOriginalConstructor() + ->getMock(); + $result = new TransformingResult(array(), $resultSet); + + $resultSet->expects($this->exactly(2)) + ->method('transform'); + + $result->getTransformed(); + $result->getTransformed(); + } +} diff --git a/Transformer/CombinedResultTransformer.php b/Transformer/CombinedResultTransformer.php new file mode 100644 index 0000000..791e601 --- /dev/null +++ b/Transformer/CombinedResultTransformer.php @@ -0,0 +1,71 @@ + + */ +class CombinedResultTransformer +{ + /** + * @var \FOS\ElasticaBundle\Type\TypeConfigurationInterface + */ + private $configurations; + + /** + * @var ResultTransformerInterface + */ + private $transformer; + + /** + * @param \FOS\ElasticaBundle\Type\TypeConfigurationInterface[] $configurations + * @param ResultTransformerInterface $transformer + */ + public function __construct(array $configurations, ResultTransformerInterface $transformer) + { + $this->configurations = $configurations; + $this->transformer = $transformer; + } + + /** + * Transforms Elastica results into Models. + * + * @param TransformingResult[] $results + * @return object[] + */ + public function transform($results) + { + $grouped = array(); + + foreach ($results as $result) { + $grouped[$result->getType()][] = $result; + } + + foreach ($grouped as $type => $group) { + $this->transformer->transform($this->getConfiguration($type), $group); + } + } + + /** + * Retrieves the transformer for a given type. + * + * @param string $type + * @return \FOS\ElasticaBundle\Type\TypeConfigurationInterface + * @throws \InvalidArgumentException + */ + private function getConfiguration($type) + { + if (!array_key_exists($type, $this->configurations)) { + throw new \InvalidArgumentException(sprintf( + 'Configuration for type "%s" is not registered with this combined transformer.', + $type + )); + } + + return $this->configurations[$type]; + } +} diff --git a/Transformer/ResultTransformer.php b/Transformer/ResultTransformer.php new file mode 100644 index 0000000..68b9364 --- /dev/null +++ b/Transformer/ResultTransformer.php @@ -0,0 +1,80 @@ +lookupManager = $lookupManager; + $this->propertyAccessor = $propertyAccessor; + } + + /** + * Transforms Elastica results into Models. + * + * @param TypeConfigurationInterface $configuration + * @param \FOS\ElasticaBundle\Elastica\TransformingResult[] $results + * @throws \FOS\ElasticaBundle\Exception\MissingModelException + * @throws \FOS\ElasticaBundle\Exception\UnexpectedObjectException + */ + public function transform(TypeConfigurationInterface $configuration, $results) + { + $results = $this->processResults($results); + $lookup = $this->lookupManager->getLookup($configuration->getType()); + $objects = $lookup->lookup($configuration, array_keys($results)); + + if (!$configuration->isIgnoreMissing() and count($objects) < count($results)) { + throw new MissingModelException(count($objects), count($results)); + } + + $identifierProperty = $configuration->getIdentifierProperty(); + foreach ($objects as $object) { + $id = $this->propertyAccessor->getValue($object, $identifierProperty); + + if (!array_key_exists($id, $results)) { + throw new UnexpectedObjectException($id); + } + + $results[$id]->setTransformed($object); + } + } + + /** + * Processes the results array into a more usable format for the transformation. + * + * @param \FOS\ElasticaBundle\Elastica\TransformingResult[] $results + * @return \FOS\ElasticaBundle\Elastica\TransformingResult[] + */ + private function processResults($results) + { + $sorted = array(); + foreach ($results as $result) { + $sorted[$result->getId()] = $result; + } + + return $sorted; + } +} diff --git a/Transformer/ResultTransformerInterface.php b/Transformer/ResultTransformerInterface.php new file mode 100644 index 0000000..c18fc7c --- /dev/null +++ b/Transformer/ResultTransformerInterface.php @@ -0,0 +1,16 @@ + + */ +interface LookupInterface +{ + /** + * Returns the lookup key. + * + * @return string + */ + public function getKey(); + + /** + * Look up objects of a specific type with ids as supplied. + * + * @param TypeConfigurationInterface $configuration + * @param int[] $ids + * @return object[] + */ + public function lookup(TypeConfigurationInterface $configuration, array $ids); +} diff --git a/Type/LookupManager.php b/Type/LookupManager.php new file mode 100644 index 0000000..0db0fd1 --- /dev/null +++ b/Type/LookupManager.php @@ -0,0 +1,36 @@ +lookups[$lookup->getKey()] = $lookup; + } + } + + + /** + * @param string $type + * @return LookupInterface + * @throws \InvalidArgumentException + */ + public function getLookup($type) + { + if (!array_key_exists($type, $this->lookups)) { + throw new \InvalidArgumentException(sprintf('Lookup with key "%s" does not exist', $type)); + } + + return $this->lookups[$type]; + } +} diff --git a/Type/TypeConfigurationInterface.php b/Type/TypeConfigurationInterface.php new file mode 100644 index 0000000..bee3388 --- /dev/null +++ b/Type/TypeConfigurationInterface.php @@ -0,0 +1,54 @@ + + */ +interface TypeConfigurationInterface +{ + /** + * The identifier property that is used to retrieve an identifier from the model. + * + * @return string + */ + public function getIdentifierProperty(); + + /** + * Returns the fully qualified class for the model that this type represents. + * + * @return string + */ + public function getModelClass(); + + /** + * Returns the repository method that will create a query builder or associated + * query object for lookup purposes. + * + * @return string + */ + public function getRepositoryMethod(); + + /** + * Returns the name of the type. + * + * @return string + */ + public function getType(); + + /** + * If the lookup should hydrate models to objects or leave data as an array. + * + * @return bool + */ + public function isHydrate(); + + /** + * If the type should ignore missing results from a lookup. + * + * @return bool + */ + public function isIgnoreMissing(); +} \ No newline at end of file diff --git a/composer.json b/composer.json index 8dd19b6..e827ac6 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ ], "require": { "php": ">=5.3.2", + "doctrine/inflector": "~1.0", "symfony/framework-bundle": "~2.3", "symfony/console": "~2.1", "symfony/form": "~2.1",